open repo panels from sidebar rows
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@@ -54,6 +54,9 @@ pub enum CustomIconName {
|
||||
Refresh,
|
||||
Grid,
|
||||
Init,
|
||||
EllipsisVertical,
|
||||
ArrowUp,
|
||||
Trash,
|
||||
}
|
||||
|
||||
impl IconNamed for CustomIconName {
|
||||
@@ -84,6 +87,9 @@ impl IconNamed for CustomIconName {
|
||||
CustomIconName::Recent => "icons/recent.svg",
|
||||
CustomIconName::Grid => "icons/grid.svg",
|
||||
CustomIconName::Init => "icons/init.svg",
|
||||
CustomIconName::EllipsisVertical => "icons/ellipsis-vertical.svg",
|
||||
CustomIconName::ArrowUp => "icons/arrow-up.svg",
|
||||
CustomIconName::Trash => "icons/trash.svg",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::clipboard::ClipboardButton;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::theme::ActiveTheme as _;
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::Announcement;
|
||||
use utils::middle_truncate;
|
||||
|
||||
use super::{close_global, open_global};
|
||||
|
||||
pub struct AboutDialog {
|
||||
announcement: Announcement,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
}
|
||||
|
||||
impl AboutDialog {
|
||||
pub fn new(
|
||||
announcement: Announcement,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|_| Self {
|
||||
announcement,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AboutDialog {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let announcement = self.announcement.clone();
|
||||
let directory = self.directory.clone();
|
||||
|
||||
Dialog::new("about-dialog")
|
||||
.open(true)
|
||||
.min_width(px(500.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("About")
|
||||
.content(move |_, cx| announcement_rows(&announcement, &directory, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn announcement_rows(
|
||||
announcement: &Announcement,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
|
||||
rows.push(row(
|
||||
"Name",
|
||||
text(
|
||||
announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("-")),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
rows.push(row(
|
||||
"Description",
|
||||
text(
|
||||
announcement
|
||||
.description
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("-")),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
|
||||
if !announcement.web.is_empty() {
|
||||
rows.push(row(
|
||||
"Web",
|
||||
list(
|
||||
"about-web",
|
||||
announcement.web.iter().map(|url| url.to_string()),
|
||||
cx,
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(euc) = &announcement.euc {
|
||||
rows.push(row(
|
||||
"Earliest Commit",
|
||||
copy_value("about-euc", euc.clone(), cx),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(upstream) = &announcement.upstream {
|
||||
rows.push(row("Upstream", text(upstream.display()), cx));
|
||||
}
|
||||
|
||||
if !announcement.hashtags.is_empty() {
|
||||
rows.push(row(
|
||||
"Hashtags",
|
||||
text(SharedString::from(announcement.hashtags.join(", "))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.clone.is_empty() {
|
||||
rows.push(row(
|
||||
"Clone URLs",
|
||||
list(
|
||||
"about-clone",
|
||||
announcement.clone.iter().map(|url| url.to_string()),
|
||||
cx,
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.relays.is_empty() {
|
||||
rows.push(row(
|
||||
"Grasp Relays",
|
||||
list(
|
||||
"about-relays",
|
||||
announcement.relays.iter().map(|url| url.to_string()),
|
||||
cx,
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.maintainers.is_empty() {
|
||||
rows.push(row(
|
||||
"Maintainers",
|
||||
maintainers(&announcement.maintainers, directory, cx),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
v_flex().gap_3().w_full().children(rows).into_any_element()
|
||||
}
|
||||
|
||||
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(label),
|
||||
)
|
||||
.child(value)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn text<T>(value: T) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
div()
|
||||
.text_sm()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.child(value.into())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font)
|
||||
.text_xs()
|
||||
.min_w_0()
|
||||
.child(SharedString::from(value.clone())),
|
||||
)
|
||||
.child(ClipboardButton::new(id, value))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn maintainers(
|
||||
maintainers: &[PublicKey],
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.children(maintainers.iter().map(|pubkey| {
|
||||
let profile = directory.read(cx).profile(pubkey, cx);
|
||||
let name = profile.name();
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.child(
|
||||
Avatar::new(*pubkey)
|
||||
.name(name.clone())
|
||||
.picture(profile.picture())
|
||||
.small(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.child(name),
|
||||
)
|
||||
.into_any_element()
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.children(items.into_iter().enumerate().map(|(ix, item)| {
|
||||
h_flex()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.bg(cx.theme().colors.secondary)
|
||||
.rounded(cx.theme().radius.control)
|
||||
.text_color(cx.theme().colors.secondary_foreground)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.child(SharedString::from(middle_truncate(&item, 28, 16))),
|
||||
)
|
||||
.child(ClipboardButton::new(
|
||||
SharedString::from(format!("{id}-{ix}")),
|
||||
item,
|
||||
))
|
||||
.into_any_element()
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Opens the read-only repository metadata dialog.
|
||||
pub fn open(announcement: Announcement, directory: Entity<ProfileDirectory>, cx: &mut App) {
|
||||
open_global(AboutDialog::new(announcement, directory, cx), cx);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _,
|
||||
PathPromptOptions, Render, Styled as _, WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::field::Field;
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::layout::dock::DockState;
|
||||
use gpui_native_kit::theme::ActiveTheme as _;
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use settings::SettingsStore;
|
||||
use signed_state::{Backend, CheckoutsStore};
|
||||
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
use super::progress::{DialogProgress, error_row, push_steps_row};
|
||||
use super::{close_global, open_global};
|
||||
use crate::views::repo::actions::open_repo_panel;
|
||||
|
||||
/// Progress of the create-repository flow, so async results can be rendered.
|
||||
pub struct CreateRepoDialog {
|
||||
name_input: Entity<InputState>,
|
||||
desc_input: Entity<InputState>,
|
||||
folder_input: Entity<InputState>,
|
||||
relay_input: Entity<InputState>,
|
||||
state: Entity<DialogProgress>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
dock: WeakEntity<DockState>,
|
||||
}
|
||||
|
||||
impl CreateRepoDialog {
|
||||
pub fn new(dock: WeakEntity<DockState>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let settings = SettingsStore::global(cx);
|
||||
let default_folder = settings
|
||||
.read(cx)
|
||||
.settings()
|
||||
.create_repository
|
||||
.default_folder
|
||||
.clone()
|
||||
.unwrap_or_else(paths::desktop_dir);
|
||||
let grasp_settings = settings.read(cx).settings().grasp_servers.clone();
|
||||
|
||||
let name_input = InputState::new(cx);
|
||||
let desc_input = InputState::new(cx);
|
||||
desc_input.update(cx, |state, cx| state.set_multiline(true, cx));
|
||||
let folder_input = InputState::new_with_text(default_folder.to_string_lossy(), cx);
|
||||
folder_input.update(cx, |state, cx| state.set_read_only(true, cx));
|
||||
let relay_input = InputState::new(cx);
|
||||
let state = cx.new(|_| DialogProgress::default());
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
|
||||
cx.new(|_| Self {
|
||||
name_input,
|
||||
desc_input,
|
||||
folder_input,
|
||||
relay_input,
|
||||
state,
|
||||
grasp_state,
|
||||
dock,
|
||||
})
|
||||
}
|
||||
|
||||
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.state.read(cx).busy {
|
||||
return;
|
||||
}
|
||||
|
||||
let name = self.name_input.read(cx).value().trim().to_owned();
|
||||
let description = self.desc_input.read(cx).value().trim().to_owned();
|
||||
let folder = PathBuf::from(self.folder_input.read(cx).value().trim());
|
||||
let servers = self.grasp_state.read(cx).grasp_servers.clone();
|
||||
|
||||
if name.is_empty() {
|
||||
self.fail("Repository name is required", cx);
|
||||
return;
|
||||
}
|
||||
if servers.is_empty() {
|
||||
self.fail("Add at least one grasp server", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
self.fail("Sign in to create a repository", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let addr =
|
||||
signed_core::RepoAddr::new(user, signed_core::RepoAddr::identifier_from_name(&name));
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.begin_push(addr, cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.create_repository(&name, &description, folder, servers, cx)
|
||||
});
|
||||
let state = self.state.clone();
|
||||
let dock = self.dock.clone();
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |_this, cx| match task.await {
|
||||
Ok((announcement, local_path)) => {
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |store, cx| {
|
||||
store.record(local_path.clone(), announcement.addr(), cx);
|
||||
});
|
||||
cx.open_with_system(&local_path);
|
||||
open_repo_panel(&dock, &announcement.addr(), Some(&announcement), window, cx);
|
||||
close_global(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(error) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.fail(error.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn fail(&mut self, message: impl Into<gpui::SharedString>, cx: &mut Context<Self>) {
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.fail(message);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CreateRepoDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let name_input = self.name_input.clone();
|
||||
let desc_input = self.desc_input.clone();
|
||||
let folder_input = self.folder_input.clone();
|
||||
let relay_input = self.relay_input.clone();
|
||||
let state = self.state.clone();
|
||||
let grasp_state = self.grasp_state.clone();
|
||||
let action_state = state.clone();
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
Dialog::new("create-repo-dialog")
|
||||
.open(true)
|
||||
.min_width(px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Create repository")
|
||||
.content(move |_window, cx| {
|
||||
let progress = state.read(cx);
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Publish a new repository to your grasp servers."),
|
||||
)
|
||||
.child(
|
||||
Field::new("repo-name", "Repository name")
|
||||
.description("Max 100 characters")
|
||||
.child(Input::new("repo-name-input", name_input.clone())),
|
||||
)
|
||||
.child(
|
||||
Field::new("repo-description", "Description").child(
|
||||
Input::new("repo-description-input", desc_input.clone()).rows(3),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Field::new("repo-folder", "Folder")
|
||||
.description("Where the repository's working copy is created.")
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(div().flex_1().child(Input::new(
|
||||
"repo-folder-input",
|
||||
folder_input.clone(),
|
||||
)))
|
||||
.child(
|
||||
Button::new("choose-folder")
|
||||
.icon(Icon::named(IconRole::Folder))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Choose folder"))
|
||||
.on_click({
|
||||
let folder_input = folder_input.clone();
|
||||
move |_, window, cx| {
|
||||
choose_folder(&folder_input, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
|
||||
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
|
||||
el.child(steps)
|
||||
})
|
||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, cx| {
|
||||
let busy = action_state.read(cx).busy;
|
||||
let this = this.clone();
|
||||
|
||||
let create: AnyElement = Button::new("create-repository")
|
||||
.label("Create repository")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click(move |_, window, cx| {
|
||||
let _ = this.update(cx, |this, cx| this.create(window, cx));
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![create]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let store = SettingsStore::global(cx);
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Choose folder".into()),
|
||||
});
|
||||
let handle = window.window_handle();
|
||||
let folder_input = folder_input.clone();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Ok(Some(mut paths))) = prompt.await
|
||||
&& let Some(path) = paths.pop()
|
||||
{
|
||||
if let Err(error) = cx.update_window(handle, |_, window, cx| {
|
||||
let text = path.to_string_lossy().to_string();
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
settings.create_repository.default_folder = Some(path.clone());
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
folder_input.update(cx, |input, cx| input.set_value(text, window, cx));
|
||||
}) {
|
||||
log::warn!("failed to apply selected folder: {error}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Opens the create-repository form.
|
||||
pub fn open(dock: WeakEntity<DockState>, window: &mut Window, cx: &mut App) {
|
||||
open_global(CreateRepoDialog::new(dock, window, cx), cx);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement as _, SharedString,
|
||||
Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::switch::Switch;
|
||||
use gpui_native_kit::theme::ActiveTheme as _;
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use nostr::prelude::RelayUrl;
|
||||
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
||||
use signed_state::Backend;
|
||||
|
||||
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct GraspServersState {
|
||||
/// Set while the user's kind `10317` grasp list loads.
|
||||
pub loading_servers: bool,
|
||||
pub grasp_servers: Vec<RelayUrl>,
|
||||
pub servers_enabled: bool,
|
||||
/// Error from the last grasp-server edit, such as an invalid relay URL.
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl GraspServersState {
|
||||
/// Defaults used until the user's grasp list loads and replaces them.
|
||||
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
||||
let urls: Vec<String> = if settings.default_servers.is_empty() {
|
||||
DEFAULT_GRASP_SERVERS
|
||||
.iter()
|
||||
.map(|url| (*url).to_owned())
|
||||
.collect()
|
||||
} else {
|
||||
settings.default_servers.clone()
|
||||
};
|
||||
Self {
|
||||
loading_servers: true,
|
||||
servers_enabled: false,
|
||||
grasp_servers: urls
|
||||
.iter()
|
||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Grasp servers section shared by the publish dialogs.
|
||||
pub fn grasp_servers_field(
|
||||
state: &Entity<GraspServersState>,
|
||||
relay_input: &Entity<InputState>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let state = state.clone();
|
||||
let relay_input = relay_input.clone();
|
||||
|
||||
let servers = state.read(cx).grasp_servers.clone();
|
||||
let loading_servers = state.read(cx).loading_servers;
|
||||
let servers_enabled = state.read(cx).servers_enabled;
|
||||
let error = state.read(cx).error.clone();
|
||||
|
||||
let add_state = state.clone();
|
||||
let add_input = relay_input.clone();
|
||||
let toggle_state = state.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(div().text_sm().child("Grasp servers"))
|
||||
.child(
|
||||
Switch::new("grasp-servers-toggle")
|
||||
.checked(servers_enabled)
|
||||
.on_change(move |checked, _, cx| {
|
||||
toggle_state.update(cx, |state, cx| {
|
||||
state.servers_enabled = checked;
|
||||
cx.notify();
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when(servers_enabled, |section| {
|
||||
section
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Where the repository is hosted"),
|
||||
)
|
||||
.child(
|
||||
v_flex().gap_1().children(
|
||||
servers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, relay)| render_server_row(ix, relay, state.clone(), cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new("grasp-relay-input", relay_input.clone())),
|
||||
)
|
||||
.child(
|
||||
Button::new("add-relay")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Add grasp server"))
|
||||
.on_click(move |_, window, cx| {
|
||||
add_relay(&add_state, &add_input, window, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when(loading_servers, |section| {
|
||||
section.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Loading your grasp servers..."),
|
||||
)
|
||||
})
|
||||
.when_some(error, |section, error| {
|
||||
section.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().ramps.destructive.base)
|
||||
.child(error),
|
||||
)
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_server_row(
|
||||
ix: usize,
|
||||
relay: &RelayUrl,
|
||||
state: Entity<GraspServersState>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.bg(cx.theme().colors.muted)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius.control)
|
||||
.child(server_host(relay)),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("remove-relay:{ix}")))
|
||||
.icon(Icon::named(IconRole::Close))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Remove"))
|
||||
.on_click(move |_, _, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
if ix < state.grasp_servers.len() {
|
||||
state.grasp_servers.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Accepts a bare host as well as a full URL.
|
||||
fn add_relay(
|
||||
state: &Entity<GraspServersState>,
|
||||
input: &Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match normalize_server(&value) {
|
||||
Some((_, relay)) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.error = None;
|
||||
if !state.grasp_servers.contains(&relay) {
|
||||
state.grasp_servers.push(relay);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||
}
|
||||
None => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.error = Some(format!("Invalid grasp server URL: {value}").into());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the user's kind `10317` grasp list from the local database.
|
||||
pub fn load_user_grasp_servers(
|
||||
state: Entity<GraspServersState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let backend = Backend::global(cx);
|
||||
let (user, client) = {
|
||||
let backend = backend.read(cx);
|
||||
(backend.current_user(), backend.client())
|
||||
};
|
||||
|
||||
let Some(user) = user else {
|
||||
state.update(cx, |state, _| state.loading_servers = false);
|
||||
return;
|
||||
};
|
||||
|
||||
let task =
|
||||
cx.background_spawn(
|
||||
async move { signed_state::user_grasp_list_servers(&client, user).await },
|
||||
);
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let result = task.await;
|
||||
|
||||
if let Err(error) = cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.loading_servers = false;
|
||||
if let Ok(servers) = result
|
||||
&& !servers.is_empty()
|
||||
{
|
||||
state.grasp_servers = servers;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}) {
|
||||
log::warn!("failed to apply grasp servers: {error}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
|
||||
pub fn normalize_server(input: &str) -> Option<(String, RelayUrl)> {
|
||||
let text = if input.contains("://") {
|
||||
input.to_owned()
|
||||
} else {
|
||||
format!("wss://{input}")
|
||||
};
|
||||
RelayUrl::parse(&text).ok().map(|relay| (text, relay))
|
||||
}
|
||||
|
||||
/// The host of a relay URL, which is what the server lists show; the scheme is implied.
|
||||
pub fn server_host(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.domain()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Subscription, Task, Window, div,
|
||||
Styled as _, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::v_flex;
|
||||
@@ -112,6 +112,7 @@ impl Render for ImportDialog {
|
||||
|
||||
Dialog::new("import-dialog")
|
||||
.open(true)
|
||||
.min_width(px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::field::Field;
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::select::{Select, SelectOption};
|
||||
use gpui_native_kit::theme::ActiveTheme as _;
|
||||
use settings::SettingsStore;
|
||||
use signed_core::RepoAddr;
|
||||
use signed_git::Repo;
|
||||
use signed_state::Backend;
|
||||
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
use super::progress::{DialogProgress, error_row, push_steps_row};
|
||||
use super::{close_global, open_global};
|
||||
use crate::views::repo::RepoDetailView;
|
||||
|
||||
pub struct InitRepoDialog {
|
||||
local_path: PathBuf,
|
||||
name_input: Entity<InputState>,
|
||||
desc_input: Entity<InputState>,
|
||||
relay_input: Entity<InputState>,
|
||||
state: Entity<DialogProgress>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
branches: Vec<SharedString>,
|
||||
selected_branch: Option<SharedString>,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
}
|
||||
|
||||
impl InitRepoDialog {
|
||||
pub fn new(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let default_name = local_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let grasp_settings = SettingsStore::global(cx)
|
||||
.read(cx)
|
||||
.settings()
|
||||
.grasp_servers
|
||||
.clone();
|
||||
|
||||
let state = cx.new(|_| DialogProgress::default());
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||
let name_input = InputState::new_with_text(default_name, cx);
|
||||
name_input.update(cx, |state, cx| state.set_read_only(true, cx));
|
||||
let desc_input = InputState::new(cx);
|
||||
desc_input.update(cx, |state, cx| state.set_multiline(true, cx));
|
||||
let relay_input = InputState::new(cx);
|
||||
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
|
||||
let dialog = cx.new(|_| Self {
|
||||
local_path,
|
||||
name_input,
|
||||
desc_input,
|
||||
relay_input,
|
||||
state,
|
||||
grasp_state,
|
||||
branches: Vec::new(),
|
||||
selected_branch: None,
|
||||
view,
|
||||
});
|
||||
|
||||
load_branches(dialog.clone(), cx);
|
||||
dialog
|
||||
}
|
||||
|
||||
fn init_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.state.read(cx).busy {
|
||||
return;
|
||||
}
|
||||
|
||||
let name = self.name_input.read(cx).value().trim().to_owned();
|
||||
let description = self.desc_input.read(cx).value().trim().to_owned();
|
||||
let servers = self.grasp_state.read(cx).grasp_servers.clone();
|
||||
let default_branch = self
|
||||
.selected_branch
|
||||
.as_ref()
|
||||
.map(|branch| branch.trim().to_owned())
|
||||
.filter(|branch| !branch.is_empty());
|
||||
|
||||
if name.is_empty() {
|
||||
self.fail("Repository name is required", cx);
|
||||
return;
|
||||
}
|
||||
if servers.is_empty() {
|
||||
self.fail("Add at least one grasp server", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
self.fail("Sign in to publish a repository", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name));
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.begin_push(addr, cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let local_path = self.local_path.clone();
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.publish_local_repo(local_path, &name, &description, servers, default_branch, cx)
|
||||
});
|
||||
let state = self.state.clone();
|
||||
let view = self.view.clone();
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |_this, cx| match task.await {
|
||||
Ok(announcement) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
close_global(cx);
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.apply_announcement(announcement, cx));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(error) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.fail(error.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn fail(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.fail(message);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InitRepoDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let name_input = self.name_input.clone();
|
||||
let desc_input = self.desc_input.clone();
|
||||
let relay_input = self.relay_input.clone();
|
||||
let state = self.state.clone();
|
||||
let grasp_state = self.grasp_state.clone();
|
||||
let action_state = state.clone();
|
||||
let this = cx.entity().downgrade();
|
||||
let folder = self.local_path.display().to_string();
|
||||
let branches = self.branches.clone();
|
||||
let selected_branch = self.selected_branch.clone();
|
||||
|
||||
Dialog::new("init-repo-dialog")
|
||||
.open(true)
|
||||
.min_width(px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Initialize repository")
|
||||
.content(move |_, cx| {
|
||||
let progress = state.read(cx);
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Publish this local repository to Nostr."),
|
||||
)
|
||||
.child(
|
||||
Field::new("init-name", "Repository name")
|
||||
.description("Max 100 characters")
|
||||
.child(Input::new("init-name-input", name_input.clone())),
|
||||
)
|
||||
.child(
|
||||
Field::new("init-description", "Description").child(
|
||||
Input::new("init-description-input", desc_input.clone()).rows(3),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Field::new("init-folder", "Folder")
|
||||
.description("The local repository being published")
|
||||
.child(
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.bg(cx.theme().colors.muted)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.rounded(cx.theme().radius.control)
|
||||
.child(folder.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Field::new("init-branch", "Default branch")
|
||||
.description("The local branch to announce as the default")
|
||||
.child(
|
||||
Select::new("init-branch-select")
|
||||
.options(
|
||||
branches
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|branch| SelectOption::new(branch.clone(), branch))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.selected(selected_branch.clone())
|
||||
.placeholder("Select branch"),
|
||||
),
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
|
||||
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
|
||||
el.child(steps)
|
||||
})
|
||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, cx| {
|
||||
let busy = action_state.read(cx).busy;
|
||||
let this = this.clone();
|
||||
|
||||
let init: AnyElement = Button::new("initialize-repository")
|
||||
.label("Initialize")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click(move |_, window, cx| {
|
||||
let _ = this.update(cx, |this, cx| this.init_repository(window, cx));
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![init]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_branches(dialog: Entity<InitRepoDialog>, cx: &mut App) {
|
||||
let local_path = dialog.read(cx).local_path.clone();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let loaded = cx
|
||||
.background_spawn(async move {
|
||||
let repo = Repo::open(&local_path)?;
|
||||
let current: Option<SharedString> = repo.current_branch().map(Into::into);
|
||||
Ok::<_, anyhow::Error>((repo.branches()?, current))
|
||||
})
|
||||
.await;
|
||||
|
||||
let Ok((branches, current)) = loaded else {
|
||||
log::warn!("failed to list branches for the default branch selector");
|
||||
return;
|
||||
};
|
||||
|
||||
dialog.update(cx, |dialog, cx| {
|
||||
dialog.branches = branches.into_iter().map(Into::into).collect();
|
||||
dialog.selected_branch = current;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Opens the initialize form for `local_path`.
|
||||
pub fn open(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
open_global(InitRepoDialog::new(local_path, view, window, cx), cx);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
pub mod about;
|
||||
pub mod create_repo;
|
||||
pub mod exit;
|
||||
pub mod grasp_servers;
|
||||
pub mod import;
|
||||
pub mod init_repo;
|
||||
pub mod onboarding;
|
||||
pub mod passphrase;
|
||||
pub mod progress;
|
||||
pub mod reset_remote;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Window, div,
|
||||
Styled as _, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::v_flex;
|
||||
@@ -88,6 +88,7 @@ impl Render for OnboardingDialog {
|
||||
|
||||
Dialog::new("onboarding-dialog")
|
||||
.open(true)
|
||||
.min_width(px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::theme::ActiveTheme as _;
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use super::{close_global, open_global};
|
||||
|
||||
pub struct ResetRemoteDialog {
|
||||
store: Entity<RepoStore>,
|
||||
deleted: Vec<String>,
|
||||
}
|
||||
|
||||
impl ResetRemoteDialog {
|
||||
pub fn new(store: Entity<RepoStore>, deleted: Vec<String>, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self { store, deleted })
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ResetRemoteDialog {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let deleted = self.deleted.clone();
|
||||
let store = self.store.clone();
|
||||
|
||||
Dialog::new("reset-remote-dialog")
|
||||
.open(true)
|
||||
.min_width(px(480.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Reset remote to local")
|
||||
.content(move |_, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(
|
||||
"Grasp servers are overwritten to match the local mirror. Refs that do not exist locally are deleted from the servers. This cannot be undone.",
|
||||
),
|
||||
)
|
||||
.child(if deleted.is_empty() {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("No server refs will be deleted.")
|
||||
.into_any_element()
|
||||
} else {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(deleted.iter().map(|name| {
|
||||
div()
|
||||
.text_sm()
|
||||
.font_family(cx.theme().mono_font)
|
||||
.child(name.clone())
|
||||
}))
|
||||
.into_any_element()
|
||||
})
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, _| {
|
||||
let store = store.clone();
|
||||
let cancel: AnyElement = Button::new("cancel-reset-remote")
|
||||
.label("Cancel")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.on_click(|_, _, cx| close_global(cx))
|
||||
.into_any_element();
|
||||
|
||||
let confirm: AnyElement = Button::new("confirm-reset-remote")
|
||||
.label("Reset remote")
|
||||
.variant(ButtonVariant::Destructive)
|
||||
.on_click(move |_, _, cx| {
|
||||
let task = store.update(cx, |store, cx| store.reset_repository(cx));
|
||||
cx.spawn(async move |_cx| {
|
||||
if let Err(error) = task.await {
|
||||
log::error!("failed to reset remote: {error:#}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
close_global(cx);
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![cancel, confirm]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the reset confirmation listing the refs the reset deletes.
|
||||
pub fn open(store: Entity<RepoStore>, deleted: Vec<String>, cx: &mut App) {
|
||||
open_global(ResetRemoteDialog::new(store, deleted, cx), cx);
|
||||
}
|
||||
@@ -1 +1,23 @@
|
||||
use gpui::{App, Entity, SharedString, WeakEntity, Window};
|
||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
||||
|
||||
/// Registers a panel builder under `id` and opens it in the dock.
|
||||
pub fn open<P, F>(
|
||||
dock: &WeakEntity<DockState>,
|
||||
id: impl Into<SharedString>,
|
||||
build: F,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) where
|
||||
P: Panel + 'static,
|
||||
F: Fn(&mut Window, &mut App) -> Entity<P> + 'static,
|
||||
{
|
||||
let id = id.into();
|
||||
|
||||
if let Err(error) = dock.update(cx, |dock, cx| {
|
||||
dock.register_panel(id.clone(), build);
|
||||
dock.open_panel(&id, window, cx);
|
||||
}) {
|
||||
log::warn!("failed to open panel {id}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod placeholder;
|
||||
pub mod repo;
|
||||
pub mod sidebar;
|
||||
pub mod tree;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use gpui::{App, AppContext as _, SharedString, WeakEntity, Window};
|
||||
use gpui_native_kit::layout::dock::DockState;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::panels;
|
||||
use crate::views::repo::RepoDetailView;
|
||||
|
||||
/// Opens a repository as a dock panel, keyed by its address.
|
||||
pub fn open_repo_panel(
|
||||
dock: &WeakEntity<DockState>,
|
||||
addr: &RepoAddr,
|
||||
hint: Option<&Announcement>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let id = SharedString::from(format!("repo:{addr}"));
|
||||
let addr = addr.clone();
|
||||
let hint = hint.cloned();
|
||||
let dock_for_build = dock.clone();
|
||||
|
||||
panels::open(
|
||||
dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
let addr = addr.clone();
|
||||
let hint = hint.clone();
|
||||
cx.new(|cx| RepoDetailView::new(dock_for_build.clone(), addr, hint, window, cx))
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use signed_state::{CheckoutStatus, CheckoutSyncStatus};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Banners {
|
||||
dismissed: HashSet<(PathBuf, String)>,
|
||||
sync_dismissed: HashSet<PathBuf>,
|
||||
ready_requested: bool,
|
||||
ready_head: Option<String>,
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
}
|
||||
|
||||
impl Banners {
|
||||
pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool {
|
||||
self.dismissed
|
||||
.contains(&(status.path.clone(), status.branch.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn dismiss(&mut self, status: &CheckoutStatus) {
|
||||
self.dismissed
|
||||
.insert((status.path.clone(), status.branch.clone()));
|
||||
}
|
||||
|
||||
pub(super) fn sync_dismissal(&self, checkout: &CheckoutSyncStatus) -> bool {
|
||||
self.sync_dismissed.contains(&checkout.path)
|
||||
}
|
||||
|
||||
pub(super) fn sync_dismiss(&mut self, checkout: &CheckoutSyncStatus) {
|
||||
self.sync_dismissed.insert(checkout.path.clone());
|
||||
}
|
||||
|
||||
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
|
||||
(self.ready_requested, &self.ready_head)
|
||||
}
|
||||
|
||||
pub(super) fn mark_ready_requested(&mut self, head: Option<String>) {
|
||||
self.ready_requested = true;
|
||||
self.ready_head = head;
|
||||
}
|
||||
|
||||
pub(super) fn set_statuses(
|
||||
&mut self,
|
||||
ready: Vec<CheckoutStatus>,
|
||||
push: Vec<CheckoutStatus>,
|
||||
) -> bool {
|
||||
let changed = ready != self.ready_statuses || push != self.push_statuses;
|
||||
self.ready_statuses = ready;
|
||||
self.push_statuses = push;
|
||||
changed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Task, WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::data::tree::{Tree, TreeItem};
|
||||
use gpui_native_kit::editor::code::CodeViewState;
|
||||
use gpui_native_kit::editor::highlight::Language;
|
||||
use gpui_native_kit::editor::markdown::MarkdownViewState;
|
||||
use gpui_native_kit::empty_state::EmptyState;
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::spinner::Spinner;
|
||||
use gpui_native_kit::theme::ActiveTheme as _;
|
||||
use signed_git::{FileCommit, Repo, WorktreeSnapshot};
|
||||
|
||||
use crate::views::tree::{TreeItemSeed, TreeRow, flatten};
|
||||
|
||||
const TREE_WIDTH: f32 = 240.;
|
||||
const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||
const MAX_PREVIEWED_FILES: usize = 32;
|
||||
const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
enum FileContent {
|
||||
Text(String),
|
||||
Binary,
|
||||
TooLarge,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
struct MarkdownView {
|
||||
/// `None` means the repository README.
|
||||
path: Option<SharedString>,
|
||||
state: Entity<MarkdownViewState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
struct CodeView {
|
||||
/// Source path, relative to the worktree root.
|
||||
path: SharedString,
|
||||
state: Entity<CodeViewState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
pub struct RepoFilesView {
|
||||
seeds: Vec<TreeItemSeed>,
|
||||
expanded: HashSet<String>,
|
||||
rows: Vec<TreeRow>,
|
||||
selected_index: Option<usize>,
|
||||
worktree: Option<PathBuf>,
|
||||
worktree_paths: Vec<String>,
|
||||
md: Option<MarkdownView>,
|
||||
code: Option<CodeView>,
|
||||
readme_name: Option<SharedString>,
|
||||
selected_file: Option<SharedString>,
|
||||
files: HashMap<String, FileContent>,
|
||||
file_order: VecDeque<String>,
|
||||
preview_bytes: usize,
|
||||
loading_files: HashSet<String>,
|
||||
commits: HashMap<String, FileCommit>,
|
||||
pending_commits: Vec<String>,
|
||||
loading_commits: bool,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl RepoFilesView {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let _ = cx;
|
||||
Self {
|
||||
seeds: Vec::new(),
|
||||
expanded: HashSet::new(),
|
||||
rows: Vec::new(),
|
||||
selected_index: None,
|
||||
worktree: None,
|
||||
worktree_paths: Vec::new(),
|
||||
md: None,
|
||||
code: None,
|
||||
readme_name: None,
|
||||
selected_file: None,
|
||||
files: HashMap::new(),
|
||||
file_order: VecDeque::new(),
|
||||
preview_bytes: 0,
|
||||
loading_files: HashSet::new(),
|
||||
commits: HashMap::new(),
|
||||
pending_commits: Vec::new(),
|
||||
loading_commits: false,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_worktree(&mut self, path: PathBuf) {
|
||||
self.worktree = Some(path);
|
||||
}
|
||||
|
||||
pub fn apply_entries(
|
||||
&mut self,
|
||||
tree: Vec<TreeItemSeed>,
|
||||
paths: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.worktree_paths = paths;
|
||||
self.seeds = tree;
|
||||
let folder_ids = folder_ids(&self.seeds);
|
||||
self.expanded.retain(|id| folder_ids.contains(id));
|
||||
self.rebuild_rows();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn rebuild_rows(&mut self) {
|
||||
self.rows = flatten(&self.seeds, &self.expanded);
|
||||
self.selected_index = self
|
||||
.selected_file
|
||||
.as_ref()
|
||||
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
|
||||
}
|
||||
|
||||
fn toggle_folder(&mut self, id: &str) {
|
||||
if !self.expanded.remove(id) {
|
||||
self.expanded.insert(id.to_owned());
|
||||
}
|
||||
self.rebuild_rows();
|
||||
}
|
||||
|
||||
/// Point the README pane at `path`/`bytes`, or clear it when absent.
|
||||
pub fn set_readme(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some((path, bytes)) = path.zip(bytes) else {
|
||||
let changed = self.readme_name.is_some() || self.md.is_some();
|
||||
self.readme_name = None;
|
||||
self.md = None;
|
||||
return changed;
|
||||
};
|
||||
|
||||
let name: SharedString = path.to_string_lossy().into();
|
||||
let mut changed = self.readme_name.as_ref() != Some(&name);
|
||||
self.readme_name = Some(name);
|
||||
self.load_commit(&path.to_string_lossy(), cx);
|
||||
|
||||
if let Ok(text) = String::from_utf8(bytes) {
|
||||
changed |= self.set_markdown(None, &text, cx);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Drop every cached preview and the README, e.g. on a branch switch.
|
||||
pub fn clear_previews(&mut self) {
|
||||
self.selected_file = None;
|
||||
self.selected_index = None;
|
||||
self.files.clear();
|
||||
self.file_order.clear();
|
||||
self.preview_bytes = 0;
|
||||
self.loading_files.clear();
|
||||
self.commits.clear();
|
||||
self.pending_commits.clear();
|
||||
self.loading_commits = false;
|
||||
self.md = None;
|
||||
self.code = None;
|
||||
self.readme_name = None;
|
||||
}
|
||||
|
||||
/// Refresh after the mirror caught up with the remote.
|
||||
pub fn catch_up(
|
||||
&mut self,
|
||||
snapshot: &WorktreeSnapshot,
|
||||
tree: Vec<TreeItemSeed>,
|
||||
paths: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
|
||||
if paths != self.worktree_paths {
|
||||
self.apply_entries(tree, paths, cx);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let present: HashSet<String> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
let mut previewed: Vec<String> = Vec::new();
|
||||
previewed.extend(self.files.keys().cloned());
|
||||
previewed.extend(self.selected_file.clone().map(|path| path.to_string()));
|
||||
|
||||
if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
|
||||
previewed.sort();
|
||||
previewed.dedup();
|
||||
|
||||
for path in previewed {
|
||||
if !present.contains(&path) {
|
||||
self.drop_preview_of(&path);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if self.selected_file.is_none() {
|
||||
changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
fn pane_title(&self) -> SharedString {
|
||||
self.selected_file
|
||||
.clone()
|
||||
.or_else(|| self.readme_name.clone())
|
||||
.unwrap_or_else(|| "Overview".into())
|
||||
}
|
||||
|
||||
fn activate_row(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(row) = self.rows.get(index) else {
|
||||
return;
|
||||
};
|
||||
let id = row.id.clone();
|
||||
let expandable = row.expandable;
|
||||
|
||||
self.selected_index = Some(index);
|
||||
|
||||
if expandable {
|
||||
self.toggle_folder(&id);
|
||||
} else {
|
||||
self.open_file(&id, window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_tree_column(
|
||||
&self,
|
||||
rows: Vec<TreeRow>,
|
||||
selected: Option<usize>,
|
||||
view: WeakEntity<Self>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let change_view = view.clone();
|
||||
let disclosure_view = view;
|
||||
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.p_2()
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.child(
|
||||
div().flex_1().min_h_0().child(
|
||||
Tree::new("repo-tree")
|
||||
.selected(selected)
|
||||
.items(move |_, _| {
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let item = TreeItem::new(row.id.clone(), row.label.clone())
|
||||
.depth(row.depth)
|
||||
.expandable(row.expandable)
|
||||
.expanded(row.expanded);
|
||||
|
||||
if row.expandable {
|
||||
item.icon(Icon::named(IconRole::Folder))
|
||||
} else {
|
||||
item
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.on_change({
|
||||
let view = change_view;
|
||||
move |index, window, cx| {
|
||||
if let Some(index) = index
|
||||
&& let Err(error) = view.update(cx, |this, cx| {
|
||||
this.activate_row(index, window, cx);
|
||||
})
|
||||
{
|
||||
log::warn!("failed to activate tree row: {error}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_disclosure({
|
||||
let view = disclosure_view;
|
||||
move |index, _, cx| {
|
||||
if let Err(error) = view.update(cx, |this, cx| {
|
||||
let id = this.rows.get(index).map(|row| row.id.clone());
|
||||
if let Some(id) = id {
|
||||
this.toggle_folder(&id);
|
||||
cx.notify();
|
||||
}
|
||||
}) {
|
||||
log::warn!("failed to toggle folder: {error}");
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_content_column(
|
||||
&self,
|
||||
pane_title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let body: AnyElement = if let Some(path) = self.selected_file.clone() {
|
||||
match self.files.get(path.as_ref()) {
|
||||
Some(FileContent::Text(_)) => {
|
||||
if is_markdown_path(path.as_ref()) {
|
||||
self.markdown_element(Some(path.as_ref()))
|
||||
} else {
|
||||
self.code_element(path.as_ref())
|
||||
}
|
||||
}
|
||||
Some(FileContent::Binary) => placeholder("Binary file - preview not supported"),
|
||||
Some(FileContent::TooLarge) => placeholder("File is too large to preview"),
|
||||
Some(FileContent::Failed(message)) => placeholder(message),
|
||||
None => preview_spinner(),
|
||||
}
|
||||
} else if self.readme_name.is_some() {
|
||||
self.markdown_element(None)
|
||||
} else {
|
||||
placeholder("No README found")
|
||||
};
|
||||
|
||||
let commit = match &self.selected_file {
|
||||
Some(path) => self.commits.get(path.as_ref()),
|
||||
None => self
|
||||
.readme_name
|
||||
.as_ref()
|
||||
.and_then(|name| self.commits.get(name.as_ref())),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.bg(cx.theme().colors.muted)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(pane_title),
|
||||
)
|
||||
.when_some(commit, |this, commit| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_family(cx.theme().mono_font)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(250.))
|
||||
.text_xs()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().flex_1().min_h_0().child(body))
|
||||
}
|
||||
|
||||
fn set_markdown(
|
||||
&mut self,
|
||||
path: Option<SharedString>,
|
||||
text: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let hash = source_hash(text);
|
||||
|
||||
if let Some(md) = &self.md
|
||||
&& md.path == path
|
||||
&& md.source_hash == hash
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(md) = self.md.take()
|
||||
&& md.path == path
|
||||
{
|
||||
md.state.update(cx, |state, cx| {
|
||||
state.set_source(text, cx);
|
||||
});
|
||||
self.md = Some(MarkdownView {
|
||||
path,
|
||||
state: md.state,
|
||||
source_hash: hash,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let state = cx.new(|_| MarkdownViewState::new("repo-readme", text));
|
||||
self.md = Some(MarkdownView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn markdown_element(&self, path: Option<&str>) -> AnyElement {
|
||||
let Some(md) = &self.md else {
|
||||
return preview_spinner();
|
||||
};
|
||||
|
||||
let ready = match path {
|
||||
Some(path) => md.path.as_deref() == Some(path),
|
||||
None => md.path.is_none(),
|
||||
};
|
||||
|
||||
if !ready {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
div()
|
||||
.p_4()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.child(md.state.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn set_code(
|
||||
&mut self,
|
||||
path: SharedString,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let hash = source_hash(text);
|
||||
let language = code_language(path.as_ref());
|
||||
|
||||
if let Some(code) = &self.code
|
||||
&& code.path == path
|
||||
&& code.source_hash == hash
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(code) = self.code.take()
|
||||
&& code.path == path
|
||||
{
|
||||
code.state.update(cx, |state, cx| {
|
||||
state.set_source(text, language, cx);
|
||||
});
|
||||
self.code = Some(CodeView {
|
||||
path,
|
||||
state: code.state,
|
||||
source_hash: hash,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let state = cx.new(|cx| CodeViewState::new("repo-code", text, language, cx));
|
||||
let _ = window;
|
||||
self.code = Some(CodeView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
}
|
||||
|
||||
fn code_element(&self, path: &str) -> AnyElement {
|
||||
let Some(code) = &self.code else {
|
||||
return preview_spinner();
|
||||
};
|
||||
if code.path.as_ref() != path {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.child(code.state.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
|
||||
if self.files.contains_key(path) {
|
||||
if let Some(FileContent::Text(text)) = self.files.get(path) {
|
||||
let text = text.clone();
|
||||
if is_markdown_path(path) {
|
||||
if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) {
|
||||
self.set_markdown(Some(path.into()), &text, cx);
|
||||
}
|
||||
} else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) {
|
||||
self.set_code(path.into(), &text, window, cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.loading_files.contains(path) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let rel = Path::new(path);
|
||||
let unsafe_path = rel.is_absolute()
|
||||
|| rel.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
});
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if unsafe_path {
|
||||
return;
|
||||
}
|
||||
|
||||
self.loading_files.insert(path.to_string());
|
||||
let path = path.to_string();
|
||||
|
||||
self.load_commit(&path, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
let path_for_read = path.clone();
|
||||
let content = cx
|
||||
.background_spawn(async move {
|
||||
let full = worktree.join(&path_for_read);
|
||||
|
||||
let metadata = match std::fs::metadata(&full) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
|
||||
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
|
||||
return Ok(FileContent::TooLarge);
|
||||
}
|
||||
|
||||
let bytes = match std::fs::read(&full) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
|
||||
match String::from_utf8(bytes) {
|
||||
Ok(text) => Ok(FileContent::Text(text)),
|
||||
Err(_) => Ok(FileContent::Binary),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading_files.remove(&path);
|
||||
|
||||
match content {
|
||||
Ok(kind) => {
|
||||
if let FileContent::Text(text) = &kind {
|
||||
if is_markdown_path(&path) {
|
||||
let same = this.md.as_ref().map(|md| md.path.as_deref())
|
||||
== Some(Some(path.as_str()));
|
||||
if !same {
|
||||
this.set_markdown(Some(path.clone().into()), text, cx);
|
||||
}
|
||||
} else {
|
||||
let same = this.code.as_ref().map(|code| code.path.as_str())
|
||||
== Some(path.as_str());
|
||||
if !same {
|
||||
this.set_code(path.clone().into(), text, window, cx);
|
||||
}
|
||||
}
|
||||
this.preview_bytes += text.len();
|
||||
}
|
||||
this.files.insert(path.clone(), kind);
|
||||
this.file_order.push_back(path);
|
||||
this.evict_previews();
|
||||
}
|
||||
Err(error) => {
|
||||
this.files
|
||||
.insert(path, FileContent::Failed(error.to_string()));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn drop_preview_of(&mut self, path: &str) {
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
|
||||
self.commits.remove(path);
|
||||
|
||||
if self.selected_file.as_deref() == Some(path) {
|
||||
self.selected_file = None;
|
||||
}
|
||||
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
|
||||
self.md = None;
|
||||
}
|
||||
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
|
||||
self.code = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_previews(&mut self) {
|
||||
while (self.files.len() > MAX_PREVIEWED_FILES
|
||||
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
|
||||
&& self.file_order.len() > 1
|
||||
{
|
||||
let Some(path) = self.file_order.pop_front() else {
|
||||
break;
|
||||
};
|
||||
|
||||
if Some(path.as_str()) == self.selected_file.as_deref() {
|
||||
self.file_order.push_back(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path.as_str()) {
|
||||
self.md = None;
|
||||
}
|
||||
if self
|
||||
.code
|
||||
.as_ref()
|
||||
.is_some_and(|code| code.path.as_ref() == path.as_str())
|
||||
{
|
||||
self.code = None;
|
||||
}
|
||||
self.commits.remove(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_commits.push(path.to_string());
|
||||
|
||||
if !self.loading_commits {
|
||||
self.load_commits(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_commits(&mut self, cx: &mut Context<Self>) {
|
||||
if self.pending_commits.is_empty() || self.loading_commits {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
self.pending_commits.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_commits = true;
|
||||
let paths = std::mem::take(&mut self.pending_commits);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||
let result = cx
|
||||
.background_spawn(async move {
|
||||
Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&rels))
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.loading_commits = false;
|
||||
|
||||
if let Ok(found) = result {
|
||||
for (path, commit) in found {
|
||||
this.commits
|
||||
.insert(path.to_string_lossy().into_owned(), commit);
|
||||
}
|
||||
}
|
||||
|
||||
if !this.pending_commits.is_empty() {
|
||||
this.load_commits(cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoFilesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let rows = self.rows.clone();
|
||||
let selected = self.selected_index;
|
||||
let view = cx.entity().downgrade();
|
||||
let pane_title = self.pane_title();
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.child(self.render_tree_column(rows, selected, view, cx))
|
||||
.child(self.render_content_column(pane_title, cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn folder_ids(seeds: &[TreeItemSeed]) -> HashSet<String> {
|
||||
fn push(seed: &TreeItemSeed, ids: &mut HashSet<String>) {
|
||||
if !seed.children.is_empty() {
|
||||
ids.insert(seed.id.clone());
|
||||
for child in &seed.children {
|
||||
push(child, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
for seed in seeds {
|
||||
push(seed, &mut ids);
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn source_hash(text: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn preview_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new("repo-preview-spinner"))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn placeholder(message: impl Into<SharedString>) -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(EmptyState::new(message))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The kit grammar for a path; only four languages are supported.
|
||||
fn code_language(path: &str) -> Language {
|
||||
match Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("rs") => Language::Rust,
|
||||
Some("ts" | "mts" | "cts" | "tsx") => Language::TypeScript,
|
||||
Some("js" | "mjs" | "cjs" | "jsx") => Language::JavaScript,
|
||||
Some("py") => Language::Python,
|
||||
_ => Language::Plain,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a file path has a markdown extension.
|
||||
fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"md" | "markdown" | "mdown" | "mkdn"
|
||||
)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
use gpui::{App, AppContext as _, Entity, SharedString, WeakEntity, Window};
|
||||
use gpui_native_kit::combobox::{Combobox, ComboboxState};
|
||||
|
||||
use super::RepoDetailView;
|
||||
|
||||
pub(super) struct RefSwitcher {
|
||||
pub(super) branch_select: Entity<ComboboxState<SharedString>>,
|
||||
pub(super) tag_select: Entity<ComboboxState<SharedString>>,
|
||||
ref_branches: Vec<SharedString>,
|
||||
ref_tags: Vec<SharedString>,
|
||||
pub(super) switching_ref: bool,
|
||||
}
|
||||
|
||||
impl RefSwitcher {
|
||||
pub(super) fn new(view: WeakEntity<RepoDetailView>, window: &mut Window, cx: &mut App) -> Self {
|
||||
let branch_view = view.clone();
|
||||
let branch_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
Combobox::new("branch-select", "Branch", Vec::new())
|
||||
.placeholder("Branch")
|
||||
.on_change(move |value, window, cx| {
|
||||
if let Some(name) = value {
|
||||
if let Err(error) = branch_view.update(cx, |this, cx| {
|
||||
this.switch_ref(super::RefKind::Branch, name, window, cx);
|
||||
}) {
|
||||
log::warn!("failed to switch branch: {error}");
|
||||
}
|
||||
}
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let tag_view = view;
|
||||
let tag_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
Combobox::new("tag-select", "Tag", Vec::new())
|
||||
.placeholder("Tag")
|
||||
.on_change(move |value, window, cx| {
|
||||
if let Some(name) = value {
|
||||
if let Err(error) = tag_view.update(cx, |this, cx| {
|
||||
this.switch_ref(super::RefKind::Tag, name, window, cx);
|
||||
}) {
|
||||
log::warn!("failed to switch tag: {error}");
|
||||
}
|
||||
}
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
Self {
|
||||
branch_select,
|
||||
tag_select,
|
||||
ref_branches: Vec::new(),
|
||||
ref_tags: Vec::new(),
|
||||
switching_ref: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_branches(
|
||||
&mut self,
|
||||
branches: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(
|
||||
&self.branch_select,
|
||||
&mut self.ref_branches,
|
||||
branches,
|
||||
selected,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn set_tags(
|
||||
&mut self,
|
||||
tags: Vec<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx)
|
||||
}
|
||||
|
||||
pub(super) fn restore_selection(
|
||||
&self,
|
||||
select: &Entity<ComboboxState<SharedString>>,
|
||||
previous: &Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
select.update(cx, |state, cx| {
|
||||
state.set_selected(previous.clone(), window, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_selector(
|
||||
select: &Entity<ComboboxState<SharedString>>,
|
||||
cached: &mut Vec<SharedString>,
|
||||
items: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let items_changed = *cached != items;
|
||||
|
||||
let selection_changed = selected
|
||||
.as_ref()
|
||||
.is_some_and(|value| select.read(cx).selected() != Some(value));
|
||||
|
||||
if !items_changed && !selection_changed {
|
||||
return false;
|
||||
}
|
||||
|
||||
select.update(cx, |state, cx| {
|
||||
if items_changed {
|
||||
state.set_options(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| (item.clone(), item.clone()))
|
||||
.collect(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
if let Some(value) = selected
|
||||
&& (items_changed || selection_changed)
|
||||
{
|
||||
state.set_selected(Some(value), window, cx);
|
||||
}
|
||||
});
|
||||
*cached = items;
|
||||
|
||||
true
|
||||
}
|
||||
@@ -3,23 +3,26 @@ use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use gpui::{
|
||||
App, Context, FontWeight, IntoElement, ObjectFit, ParentElement as _, SharedString,
|
||||
Styled as _, StyledImage as _, Subscription, WeakEntity, Window, div, img, px,
|
||||
AnyElement, App, AppContext as _, Context, FontWeight, IntoElement, ObjectFit,
|
||||
ParentElement as _, SharedString, Styled as _, StyledImage as _, Subscription, WeakEntity,
|
||||
Window, div, img, px,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::badge::{Badge, BadgeVariant};
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::data::sidebar::Sidebar;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::data::sidebar::{Sidebar, SidebarEntry};
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::layout::dock::DockState;
|
||||
use gpui_native_kit::menu::{Menu, MenuItem};
|
||||
use gpui_native_kit::theme::ActiveTheme as _;
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use metadata::{Profile, ProfileDirectory};
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, RepoListStore, ResolvedLocalRepo,
|
||||
SyncStatusDirectory,
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
|
||||
ResolvedLocalRepo, SyncStatusDirectory,
|
||||
};
|
||||
|
||||
use crate::dialogs;
|
||||
@@ -208,18 +211,198 @@ impl SidebarView {
|
||||
};
|
||||
|
||||
let weak = self.weak.clone();
|
||||
let announcements = self.announcements.clone();
|
||||
let local_repos = self.local_repos.clone();
|
||||
let unpushed = self.unpushed.clone();
|
||||
let directory = self.directory.clone();
|
||||
let sync_directory = self.sync_directory.clone();
|
||||
|
||||
Sidebar::new("signed-sidebar")
|
||||
.transparent(true)
|
||||
.header(self.render_user(&profile, cx))
|
||||
.entries(|_, _| Vec::new())
|
||||
.header(self.render_user(&profile, self.scanning, cx))
|
||||
.entries(move |_, cx| {
|
||||
let mut entries = vec![SidebarEntry::header("All Repositories")];
|
||||
|
||||
for announcement in announcements.iter() {
|
||||
let owner = directory.read(cx).profile(&announcement.owner, cx);
|
||||
let name = announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
||||
let addr = announcement.addr();
|
||||
let mut row = SidebarEntry::row(
|
||||
SharedString::from(format!("repo:{}", announcement.id)),
|
||||
name,
|
||||
)
|
||||
.prefix(
|
||||
Avatar::new(announcement.owner)
|
||||
.name(owner.name())
|
||||
.picture(owner.picture())
|
||||
.small(),
|
||||
);
|
||||
|
||||
let count = unpushed.get(&addr).copied().unwrap_or(0);
|
||||
let unsynced = sync_directory.read(cx).unsynced(&addr, cx);
|
||||
|
||||
if count > 0 || unsynced > 0 {
|
||||
let mut badges = h_flex().gap_1();
|
||||
if count > 0 {
|
||||
badges = badges.child(
|
||||
Badge::new(
|
||||
SharedString::from(format!("unpushed:{addr}")),
|
||||
count.to_string(),
|
||||
)
|
||||
.variant(BadgeVariant::Danger),
|
||||
);
|
||||
}
|
||||
if unsynced > 0 {
|
||||
badges = badges.child(
|
||||
Badge::new(
|
||||
SharedString::from(format!("unsynced:{addr}")),
|
||||
unsynced.to_string(),
|
||||
)
|
||||
.variant(BadgeVariant::Accent),
|
||||
);
|
||||
}
|
||||
row = row.suffix(badges);
|
||||
}
|
||||
|
||||
entries.push(row);
|
||||
}
|
||||
|
||||
for entry in local_repos.iter() {
|
||||
let suffix: AnyElement = match entry.nip34.as_ref() {
|
||||
Some(binding) => {
|
||||
let (label, note) = match binding.kind {
|
||||
Nip34Kind::Initialized => {
|
||||
let platform = local_platform(binding).unwrap_or("Grasp");
|
||||
(platform, "Initialized for NIP-34")
|
||||
}
|
||||
Nip34Kind::Cloned => ("Cloned", "Cloned but not initialized"),
|
||||
Nip34Kind::ToolingOnly => ("Tooling", "Grasp tooling only"),
|
||||
};
|
||||
Badge::new(
|
||||
SharedString::from(format!("local-badge:{}", entry.path.display())),
|
||||
label,
|
||||
)
|
||||
.tooltip(Tooltip::text(note))
|
||||
.into_any_element()
|
||||
}
|
||||
None => Badge::new(
|
||||
SharedString::from(format!(
|
||||
"local-unpublished:{}",
|
||||
entry.path.display()
|
||||
)),
|
||||
"Unpublished",
|
||||
)
|
||||
.variant(BadgeVariant::Warning)
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
entries.push(
|
||||
SidebarEntry::row(
|
||||
SharedString::from(format!("local-repo:{}", entry.path.display())),
|
||||
entry.name(),
|
||||
)
|
||||
.prefix(local_avatar(entry, &directory, cx))
|
||||
.suffix(suffix),
|
||||
);
|
||||
}
|
||||
|
||||
entries
|
||||
})
|
||||
.on_change(move |index, window, cx| {
|
||||
let _ = weak.update(cx, |this, cx| this.activate(index, window, cx));
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.activate(index, window, cx)) {
|
||||
log::warn!("failed to activate sidebar row: {error}");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn activate(&mut self, _index: Option<usize>, _window: &mut Window, _cx: &mut Context<Self>) {
|
||||
// Repository and navigation rows arrive with their screens.
|
||||
fn activate(&mut self, index: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(index) = index else {
|
||||
return;
|
||||
};
|
||||
let Some(row) = index.checked_sub(1) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if row < self.announcements.len() {
|
||||
let announcement = self.announcements[row].clone();
|
||||
let addr = announcement.addr();
|
||||
crate::views::repo::actions::open_repo_panel(
|
||||
&self.dock,
|
||||
&addr,
|
||||
Some(&announcement),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let local_ix = row - self.announcements.len();
|
||||
if let Some(entry) = self.local_repos.get(local_ix).cloned() {
|
||||
self.open_local_entry(entry, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// A local repository opens as the announced repository when its binding matches one.
|
||||
fn open_local_entry(
|
||||
&mut self,
|
||||
entry: ResolvedLocalRepo,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ResolvedLocalRepo {
|
||||
path,
|
||||
nip34,
|
||||
announcement,
|
||||
} = entry;
|
||||
|
||||
if let Some(announcement) = announcement {
|
||||
let id = SharedString::from(format!("repo:{}", announcement.addr()));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
crate::panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
crate::views::repo::RepoDetailView::new_local_announced(
|
||||
dock_for_build.clone(),
|
||||
announcement.clone(),
|
||||
path.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = SharedString::from(format!("repo-local:{}", path.display()));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
crate::panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
crate::views::repo::RepoDetailView::new_local(
|
||||
dock_for_build.clone(),
|
||||
path.clone(),
|
||||
nip34.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -231,15 +414,16 @@ impl SidebarView {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_user(&self, profile: &Profile, cx: &App) -> impl IntoElement {
|
||||
fn render_user(&self, profile: &Profile, scanning: bool, cx: &App) -> impl IntoElement {
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
let public_key = profile.public_key();
|
||||
let weak = self.weak.clone();
|
||||
let weak_for_open = self.weak.clone();
|
||||
let muted = cx.theme().colors.muted_foreground;
|
||||
let dock = self.dock.clone();
|
||||
|
||||
Menu::new("user-menu")
|
||||
let menu = Menu::new("user-menu")
|
||||
.open(self.user_menu_open)
|
||||
.on_change({
|
||||
let weak = weak_for_open.clone();
|
||||
@@ -302,7 +486,36 @@ impl SidebarView {
|
||||
.destructive(true)
|
||||
.handler(|_, cx| dialogs::exit::open(cx)),
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
h_flex().w_full().justify_between().child(menu).child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("rescan-repos")
|
||||
.icon(Icon::named(IconRole::Loader))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.loading(scanning)
|
||||
.disabled(scanning)
|
||||
.tooltip(Tooltip::text("Rescan for local repositories"))
|
||||
.on_click(move |_, _, cx| {
|
||||
let local_repos = LocalReposStore::global(cx);
|
||||
local_repos.update(cx, |store, cx| store.rescan(cx));
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("add-repo")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Create repository"))
|
||||
.on_click({
|
||||
let dock = dock.clone();
|
||||
move |_, window, cx| {
|
||||
dialogs::create_repo::open(dock.clone(), window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Shown while no identity is signed in.
|
||||
@@ -367,3 +580,37 @@ fn pick_banner() -> SharedString {
|
||||
+ 1;
|
||||
format!("backgrounds/banner{num}.jpg").into()
|
||||
}
|
||||
|
||||
/// The platform that bound a repository, `"nak"` or `"ngit"`.
|
||||
fn local_platform(binding: &Nip34Binding) -> Option<&'static str> {
|
||||
let signals = &binding.signals;
|
||||
|
||||
if signals.nip34_json || signals.nip34_grasp_remote || signals.nip34_state_refs {
|
||||
Some("nak")
|
||||
} else if signals.nostr_repo_config {
|
||||
Some("ngit")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The bound owner's avatar, falling back to the path's pixel avatar when unowned.
|
||||
fn local_avatar(
|
||||
entry: &ResolvedLocalRepo,
|
||||
directory: &gpui::Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
||||
return Avatar::new(entry.path.to_string_lossy())
|
||||
.small()
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let profile = directory.read(cx).profile(&owner, cx);
|
||||
|
||||
Avatar::new(owner)
|
||||
.name(profile.name())
|
||||
.picture(profile.picture())
|
||||
.small()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
/// One visible row of the flattened tree.
|
||||
#[derive(Clone)]
|
||||
pub struct TreeRow {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub depth: usize,
|
||||
pub expandable: bool,
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
/// Flattens the seeds into the rows a flat tree renders, honoring `expanded`.
|
||||
pub fn flatten(seeds: &[TreeItemSeed], expanded: &HashSet<String>) -> Vec<TreeRow> {
|
||||
fn push(
|
||||
seed: &TreeItemSeed,
|
||||
depth: usize,
|
||||
expanded: &HashSet<String>,
|
||||
rows: &mut Vec<TreeRow>,
|
||||
) {
|
||||
let is_expanded = expanded.contains(&seed.id);
|
||||
rows.push(TreeRow {
|
||||
id: seed.id.clone(),
|
||||
label: seed.label.clone(),
|
||||
depth,
|
||||
expandable: !seed.children.is_empty(),
|
||||
expanded: is_expanded,
|
||||
});
|
||||
|
||||
if is_expanded {
|
||||
for child in &seed.children {
|
||||
push(child, depth + 1, expanded, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for seed in seeds {
|
||||
push(seed, 0, expanded, &mut rows);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut path = String::new();
|
||||
for part in entry.components() {
|
||||
let label = part.as_os_str().to_string_lossy().into_owned();
|
||||
path = if path.is_empty() {
|
||||
label.clone()
|
||||
} else {
|
||||
format!("{path}/{label}")
|
||||
};
|
||||
let ix = *index.entry(path.clone()).or_insert_with(|| {
|
||||
let ix = nodes.len();
|
||||
nodes.push((path.clone(), label.clone(), Vec::new()));
|
||||
match parent {
|
||||
Some(parent) => nodes[parent].2.push(ix),
|
||||
None => roots.push(ix),
|
||||
}
|
||||
ix
|
||||
});
|
||||
parent = Some(ix);
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
|
||||
let (id, label, children) = &nodes[ix];
|
||||
TreeItemSeed {
|
||||
id: id.clone(),
|
||||
label: label.clone(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|child| assemble(*child, nodes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
roots.iter().map(|root| assemble(*root, &nodes)).collect()
|
||||
}
|
||||
|
||||
/// Sorted relative paths of a worktree snapshot.
|
||||
pub fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec<String> {
|
||||
let mut paths: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids(rows: &[TreeRow]) -> Vec<(&str, usize)> {
|
||||
rows.iter()
|
||||
.map(|row| (row.id.as_str(), row.depth))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_folders_hide_children() {
|
||||
let seeds = build_tree_items(&[
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
]);
|
||||
|
||||
let rows = flatten(&seeds, &HashSet::new());
|
||||
|
||||
assert_eq!(ids(&rows), vec![("src", 0), ("README.md", 0)]);
|
||||
assert!(rows[0].expandable);
|
||||
assert!(!rows[0].expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_a_folder_shows_children_in_order() {
|
||||
let seeds = build_tree_items(&[
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("src/views/mod.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
]);
|
||||
let expanded = HashSet::from(["src".to_owned(), "src/views".to_owned()]);
|
||||
|
||||
let rows = flatten(&seeds, &expanded);
|
||||
|
||||
assert_eq!(
|
||||
ids(&rows),
|
||||
vec![
|
||||
("src", 0),
|
||||
("src/lib.rs", 1),
|
||||
("src/views", 1),
|
||||
("src/views/mod.rs", 2),
|
||||
("README.md", 0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indices_map_to_row_ids() {
|
||||
let seeds = build_tree_items(&[
|
||||
PathBuf::from("docs"),
|
||||
PathBuf::from("docs/guide.md"),
|
||||
PathBuf::from("README.md"),
|
||||
]);
|
||||
let expanded = HashSet::from(["docs".to_owned()]);
|
||||
let rows = flatten(&seeds, &expanded);
|
||||
|
||||
assert_eq!(rows[0].id, "docs");
|
||||
assert_eq!(rows[1].id, "docs/guide.md");
|
||||
assert_eq!(rows[2].id, "README.md");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user