From 89e718cb6a311e8839f21a204324f7366abd62c7 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 11 Nov 2026 20:12:44 +0700 Subject: [PATCH] open repo panels from sidebar rows --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/assets/assets/icons/trash.svg | 1 + crates/assets/src/lib.rs | 6 + crates/signed_app/src/dialogs/about.rs | 265 +++ crates/signed_app/src/dialogs/create_repo.rs | 269 +++ .../signed_app/src/dialogs/grasp_servers.rs | 271 +++ crates/signed_app/src/dialogs/import.rs | 3 +- crates/signed_app/src/dialogs/init_repo.rs | 286 +++ crates/signed_app/src/dialogs/mod.rs | 5 + crates/signed_app/src/dialogs/onboarding.rs | 3 +- crates/signed_app/src/dialogs/reset_remote.rs | 99 + crates/signed_app/src/panels.rs | 22 + crates/signed_app/src/views/mod.rs | 2 + crates/signed_app/src/views/repo/actions.rs | 32 + crates/signed_app/src/views/repo/banners.rs | 54 + crates/signed_app/src/views/repo/files.rs | 809 ++++++++ crates/signed_app/src/views/repo/mod.rs | 1833 +++++++++++++++++ crates/signed_app/src/views/repo/refs.rs | 139 ++ crates/signed_app/src/views/sidebar.rs | 275 ++- crates/signed_app/src/views/tree.rs | 170 ++ 21 files changed, 4537 insertions(+), 25 deletions(-) create mode 100644 crates/assets/assets/icons/trash.svg create mode 100644 crates/signed_app/src/dialogs/about.rs create mode 100644 crates/signed_app/src/dialogs/create_repo.rs create mode 100644 crates/signed_app/src/dialogs/grasp_servers.rs create mode 100644 crates/signed_app/src/dialogs/init_repo.rs create mode 100644 crates/signed_app/src/dialogs/reset_remote.rs create mode 100644 crates/signed_app/src/views/repo/actions.rs create mode 100644 crates/signed_app/src/views/repo/banners.rs create mode 100644 crates/signed_app/src/views/repo/files.rs create mode 100644 crates/signed_app/src/views/repo/mod.rs create mode 100644 crates/signed_app/src/views/repo/refs.rs create mode 100644 crates/signed_app/src/views/tree.rs diff --git a/Cargo.lock b/Cargo.lock index da7f635..87c88d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3059,7 +3059,7 @@ dependencies = [ [[package]] name = "gpui-native-core" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" dependencies = [ "cpal", "flume 0.12.0", @@ -3074,7 +3074,7 @@ dependencies = [ [[package]] name = "gpui-native-data" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" dependencies = [ "gpui", "gpui-native-core", @@ -3086,7 +3086,7 @@ dependencies = [ [[package]] name = "gpui-native-editor" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" dependencies = [ "gpui", "gpui-native-core", @@ -3105,7 +3105,7 @@ dependencies = [ [[package]] name = "gpui-native-icons" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" dependencies = [ "anyhow", "gpui", @@ -3114,7 +3114,7 @@ dependencies = [ [[package]] name = "gpui-native-kit" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" dependencies = [ "gpui", "gpui-native-core", @@ -3128,7 +3128,7 @@ dependencies = [ [[package]] name = "gpui-native-layout" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" dependencies = [ "gpui", "gpui-native-core", @@ -3141,7 +3141,7 @@ dependencies = [ [[package]] name = "gpui-native-theme" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" dependencies = [ "anyhow", "async-channel", @@ -3162,7 +3162,7 @@ dependencies = [ [[package]] name = "gpui-native-utils" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" dependencies = [ "gpui", "gpui-native-theme", diff --git a/Cargo.toml b/Cargo.toml index d171adc..bb5465e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503de gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc", features = ["font-kit"] } reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" } -gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "4469a4c280194db033dd0abf9c4d6b8f4daa1858", features = ["layout", "editor"] } +gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "97106b7a3a54ec929c47feb70cdab4bed044d20e", features = ["layout", "editor"] } settings = { path = "crates/settings" } signed_app = { path = "crates/signed_app" } diff --git a/crates/assets/assets/icons/trash.svg b/crates/assets/assets/icons/trash.svg new file mode 100644 index 0000000..076517c --- /dev/null +++ b/crates/assets/assets/icons/trash.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 224451a..04620ae 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -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() } diff --git a/crates/signed_app/src/dialogs/about.rs b/crates/signed_app/src/dialogs/about.rs new file mode 100644 index 0000000..6311239 --- /dev/null +++ b/crates/signed_app/src/dialogs/about.rs @@ -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, +} + +impl AboutDialog { + pub fn new( + announcement: Announcement, + directory: Entity, + cx: &mut App, + ) -> Entity { + cx.new(|_| Self { + announcement, + directory, + }) + } +} + +impl Render for AboutDialog { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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, + cx: &App, +) -> AnyElement { + let mut rows: Vec = 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(value: T) -> AnyElement +where + T: Into, +{ + 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, + 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, 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, cx: &mut App) { + open_global(AboutDialog::new(announcement, directory, cx), cx); +} diff --git a/crates/signed_app/src/dialogs/create_repo.rs b/crates/signed_app/src/dialogs/create_repo.rs new file mode 100644 index 0000000..c934de4 --- /dev/null +++ b/crates/signed_app/src/dialogs/create_repo.rs @@ -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, + desc_input: Entity, + folder_input: Entity, + relay_input: Entity, + state: Entity, + grasp_state: Entity, + dock: WeakEntity, +} + +impl CreateRepoDialog { + pub fn new(dock: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { + 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) { + 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, cx: &mut Context) { + 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) -> 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, 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, window: &mut Window, cx: &mut App) { + open_global(CreateRepoDialog::new(dock, window, cx), cx); +} diff --git a/crates/signed_app/src/dialogs/grasp_servers.rs b/crates/signed_app/src/dialogs/grasp_servers.rs new file mode 100644 index 0000000..024d14a --- /dev/null +++ b/crates/signed_app/src/dialogs/grasp_servers.rs @@ -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, + pub servers_enabled: bool, + /// Error from the last grasp-server edit, such as an invalid relay URL. + pub error: Option, +} + +impl GraspServersState { + /// Defaults used until the user's grasp list loads and replaces them. + pub fn new_default(settings: &GraspServersSettings) -> Self { + let urls: Vec = 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, + relay_input: &Entity, + 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, + 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, + input: &Entity, + 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, + 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())) +} diff --git a/crates/signed_app/src/dialogs/import.rs b/crates/signed_app/src/dialogs/import.rs index a058383..d1c4f20 100644 --- a/crates/signed_app/src/dialogs/import.rs +++ b/crates/signed_app/src/dialogs/import.rs @@ -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); diff --git a/crates/signed_app/src/dialogs/init_repo.rs b/crates/signed_app/src/dialogs/init_repo.rs new file mode 100644 index 0000000..2759ee1 --- /dev/null +++ b/crates/signed_app/src/dialogs/init_repo.rs @@ -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, + desc_input: Entity, + relay_input: Entity, + state: Entity, + grasp_state: Entity, + branches: Vec, + selected_branch: Option, + view: WeakEntity, +} + +impl InitRepoDialog { + pub fn new( + local_path: PathBuf, + view: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + 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) { + 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, cx: &mut Context) { + 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) -> 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::>(), + ) + .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, 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 = 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, + window: &mut Window, + cx: &mut App, +) { + open_global(InitRepoDialog::new(local_path, view, window, cx), cx); +} diff --git a/crates/signed_app/src/dialogs/mod.rs b/crates/signed_app/src/dialogs/mod.rs index 43f12b2..757a4b5 100644 --- a/crates/signed_app/src/dialogs/mod.rs +++ b/crates/signed_app/src/dialogs/mod.rs @@ -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::{ diff --git a/crates/signed_app/src/dialogs/onboarding.rs b/crates/signed_app/src/dialogs/onboarding.rs index 82a7df3..9ab8b47 100644 --- a/crates/signed_app/src/dialogs/onboarding.rs +++ b/crates/signed_app/src/dialogs/onboarding.rs @@ -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); diff --git a/crates/signed_app/src/dialogs/reset_remote.rs b/crates/signed_app/src/dialogs/reset_remote.rs new file mode 100644 index 0000000..cca20d4 --- /dev/null +++ b/crates/signed_app/src/dialogs/reset_remote.rs @@ -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, + deleted: Vec, +} + +impl ResetRemoteDialog { + pub fn new(store: Entity, deleted: Vec, cx: &mut App) -> Entity { + cx.new(|_| Self { store, deleted }) + } +} + +impl Render for ResetRemoteDialog { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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, deleted: Vec, cx: &mut App) { + open_global(ResetRemoteDialog::new(store, deleted, cx), cx); +} diff --git a/crates/signed_app/src/panels.rs b/crates/signed_app/src/panels.rs index 8b13789..ad60d37 100644 --- a/crates/signed_app/src/panels.rs +++ b/crates/signed_app/src/panels.rs @@ -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( + dock: &WeakEntity, + id: impl Into, + build: F, + window: &mut Window, + cx: &mut App, +) where + P: Panel + 'static, + F: Fn(&mut Window, &mut App) -> Entity

+ '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}"); + } +} diff --git a/crates/signed_app/src/views/mod.rs b/crates/signed_app/src/views/mod.rs index 4e24430..812ad52 100644 --- a/crates/signed_app/src/views/mod.rs +++ b/crates/signed_app/src/views/mod.rs @@ -1,2 +1,4 @@ pub mod placeholder; +pub mod repo; pub mod sidebar; +pub mod tree; diff --git a/crates/signed_app/src/views/repo/actions.rs b/crates/signed_app/src/views/repo/actions.rs new file mode 100644 index 0000000..1486851 --- /dev/null +++ b/crates/signed_app/src/views/repo/actions.rs @@ -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, + 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, + ); +} diff --git a/crates/signed_app/src/views/repo/banners.rs b/crates/signed_app/src/views/repo/banners.rs new file mode 100644 index 0000000..e0fca04 --- /dev/null +++ b/crates/signed_app/src/views/repo/banners.rs @@ -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, + ready_requested: bool, + ready_head: Option, + ready_statuses: Vec, + push_statuses: Vec, +} + +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) { + (self.ready_requested, &self.ready_head) + } + + pub(super) fn mark_ready_requested(&mut self, head: Option) { + self.ready_requested = true; + self.ready_head = head; + } + + pub(super) fn set_statuses( + &mut self, + ready: Vec, + push: Vec, + ) -> bool { + let changed = ready != self.ready_statuses || push != self.push_statuses; + self.ready_statuses = ready; + self.push_statuses = push; + changed + } +} diff --git a/crates/signed_app/src/views/repo/files.rs b/crates/signed_app/src/views/repo/files.rs new file mode 100644 index 0000000..becd657 --- /dev/null +++ b/crates/signed_app/src/views/repo/files.rs @@ -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, + state: Entity, + /// 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, + /// Hash of the source, so the same document is not re-parsed on a refresh. + source_hash: u64, +} + +pub struct RepoFilesView { + seeds: Vec, + expanded: HashSet, + rows: Vec, + selected_index: Option, + worktree: Option, + worktree_paths: Vec, + md: Option, + code: Option, + readme_name: Option, + selected_file: Option, + files: HashMap, + file_order: VecDeque, + preview_bytes: usize, + loading_files: HashSet, + commits: HashMap, + pending_commits: Vec, + loading_commits: bool, + tasks: Vec>>, +} + +impl RepoFilesView { + pub fn new(cx: &mut Context) -> 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, + paths: Vec, + cx: &mut Context, + ) { + 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, + bytes: Option>, + cx: &mut Context, + ) -> 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, + paths: Vec, + cx: &mut Context, + ) -> bool { + let mut changed = false; + + if paths != self.worktree_paths { + self.apply_entries(tree, paths, cx); + changed = true; + } + + let present: HashSet = snapshot + .entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + + let mut previewed: Vec = 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) { + 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, + selected: Option, + view: WeakEntity, + cx: &mut Context, + ) -> 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, + ) -> 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, + text: &str, + cx: &mut Context, + ) -> 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, + ) { + 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.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> = 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) { + 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) { + 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> = cx.spawn(async move |this, cx| { + let rels: Vec = 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) -> 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 { + fn push(seed: &TreeItemSeed, ids: &mut HashSet) { + 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) -> 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" + ) + }) +} diff --git a/crates/signed_app/src/views/repo/mod.rs b/crates/signed_app/src/views/repo/mod.rs new file mode 100644 index 0000000..37d0dd7 --- /dev/null +++ b/crates/signed_app/src/views/repo/mod.rs @@ -0,0 +1,1833 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use anyhow::Error; +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, ClipboardItem, Context, Entity, InteractiveElement as _, + IntoElement, ParentElement as _, PathPromptOptions, Render, SharedString, + StatefulInteractiveElement as _, Styled as _, Subscription, Task, WeakEntity, Window, div, px, +}; +use gpui_native_kit::alert::{Alert, AlertSeverity}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::badge::Badge; +use gpui_native_kit::button::{Button, ButtonVariant}; +use gpui_native_kit::clipboard::ClipboardButton; +use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_kit::dropdown_menu::DropdownMenu; +use gpui_native_kit::icons::{Icon, IconRole}; +use gpui_native_kit::layout::dock::{DockState, Panel}; +use gpui_native_kit::menu::MenuItem; +use gpui_native_kit::popover::Popover; +use gpui_native_kit::spinner::Spinner; +use gpui_native_kit::theme::ActiveTheme as _; +use gpui_native_kit::tooltip::Tooltip; +use metadata::ProfileDirectory; +use nostr::nips::nip19::Nip19Coordinate; +use nostr::prelude::{PublicKey, RelayUrl, ToBech32, Url}; +use signed_core::{Announcement, RepoAddr}; +use signed_git::{FileCommit, GitCache, Repo}; +use signed_state::{ + Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors, + Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusDirectory, +}; +use utils::middle_truncate; + +pub mod actions; +pub mod banners; +pub mod files; +pub mod refs; + +use super::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; +use crate::dialogs; +use banners::Banners; +use files::RepoFilesView; +use refs::RefSwitcher; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RefKind { + /// HEAD stays attached. + Branch, + /// HEAD becomes detached. + Tag, +} + +pub struct RepoDetailView { + dock: WeakEntity, + store: Entity, + /// A repository opened by address alone starts without an announcement. + repo_started: bool, + /// The Files tab, which owns the explorer, previews and the per-file commit map. + files: Entity, + /// The checked-out worktree path. + worktree: Option, + loading: bool, + error: Option, + head_commit: Option, + refs: RefSwitcher, + banners: Banners, + tasks: Vec>>, + /// Profiles rendered by the header and maintainers. + directory: Entity, + /// Sync status feeding the sync-to-Nostr banner. + sync_directory: Entity, + share_open: bool, + repo_menu_open: bool, + clone_open: bool, + _subscriptions: Vec, +} + +struct RepoData { + tree: Vec, + entries: Vec, + readme_path: Option, + readme: Option>, + worktree: Option, + branches: Vec, + tags: Vec, + current_branch: Option, + head_commit: Option, +} + +impl RepoDetailView { + pub fn new( + dock: WeakEntity, + addr: RepoAddr, + hint: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let store = cx.new(|cx| RepoStore::new(addr, hint, cx)); + Self::new_common(dock, store, window, cx) + } + + pub fn new_local( + dock: WeakEntity, + local_path: PathBuf, + nip34: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let store = cx.new(move |_cx| RepoStore::new_local(local_path, nip34)); + Self::new_common(dock, store, window, cx) + } + + /// A local repository whose detected binding matches an announcement. + pub fn new_local_announced( + dock: WeakEntity, + announcement: Announcement, + local_path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let addr = announcement.addr(); + let store = cx.new(move |cx| RepoStore::from_worktree(addr, announcement, local_path, cx)); + Self::new_common(dock, store, window, cx) + } + + fn new_common( + dock: WeakEntity, + store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let checkouts = CheckoutsStore::global(cx); + let files = cx.new(RepoFilesView::new); + let weak = cx.entity().downgrade(); + let refs = RefSwitcher::new(weak.clone(), window, cx); + + let subscriptions = vec![cx.observe(&checkouts, |this, _checkouts, cx| { + if this.refresh_statuses(cx) { + cx.notify(); + } + })]; + + window.defer(cx, move |window, cx| { + if let Err(error) = weak.update(cx, |this, cx| this.load_repo(window, cx)) { + log::warn!("failed to load repository: {error}"); + } + }); + + let mut view = Self { + dock, + store: store.clone(), + repo_started: false, + files, + worktree: None, + loading: true, + error: None, + head_commit: None, + refs, + tasks: Vec::new(), + banners: Banners::default(), + directory: ProfileDirectory::new(cx), + sync_directory: SyncStatusDirectory::new(cx), + share_open: false, + repo_menu_open: false, + clone_open: false, + _subscriptions: subscriptions, + }; + + view.attach_store(&store, window, cx); + view + } + + pub(crate) fn apply_announcement( + &mut self, + announcement: Announcement, + cx: &mut Context, + ) { + let path = self.store.read(cx).path.clone(); + + if let Some(path) = path { + LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); + } + + self.store + .update(cx, |store, cx| store.announce(announcement, cx)); + + self.refresh_ready_statuses(cx); + cx.notify(); + } + + fn attach_store( + &mut self, + store: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + self._subscriptions + .push(cx.observe_in(store, window, |this, store, window, cx| { + this.refresh_ready_statuses(cx); + this.track_profiles(cx); + this.track_sync_status(cx); + if !this.repo_started && store.read(cx).announcement.is_some() { + this.load_repo(window, cx); + } + cx.notify(); + })); + self.refresh_ready_statuses(cx); + } + + fn track_profiles(&mut self, cx: &mut Context) { + let store = self.store.read(cx); + + let mut keys: Vec = store + .announcement + .as_ref() + .map(|announcement| { + let mut keys = vec![announcement.owner]; + keys.extend( + announcement + .maintainers + .iter() + .copied() + .filter(|key| *key != announcement.owner), + ); + keys + }) + .unwrap_or_default(); + + if let Some(owner) = store.nip34.as_ref().and_then(|binding| binding.owner) { + keys.push(owner); + } + + self.directory.update(cx, |directory, cx| { + directory.track(keys, cx); + }); + } + + fn track_sync_status(&mut self, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + self.sync_directory.update(cx, |directory, cx| { + directory.track([addr], cx); + }); + } + + fn refresh_ready_statuses(&mut self, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let head = self.store.read(cx).head.clone(); + let (requested, requested_head) = self.banners.ready_requested_at(); + + if requested && requested_head == &head { + return; + } + + self.banners.mark_ready_requested(head.clone()); + + let backend = Backend::global(cx); + let checkout = CheckoutsStore::global(cx); + let owned = backend + .read(cx) + .current_user() + .is_some_and(|user| self.store.read(cx).is_author(&user)); + + checkout.update(cx, |store, cx| { + store.request_statuses(&addr, head, cx); + if owned { + store.request_push_statuses(&addr, cx); + } + }); + } + + fn refresh_statuses(&mut self, cx: &mut Context) -> bool { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return false; + }; + + let checkouts = CheckoutsStore::global(cx).read(cx); + let ready_statuses = checkouts.ready_statuses_of(&addr); + let push_statuses = checkouts.push_statuses_of(&addr); + + self.banners.set_statuses(ready_statuses, push_statuses) + } + + fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { + self.store.read(cx).announcement.as_ref() + } + + /// Load the repository and populate the file explorer. + fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { + self.loading = true; + self.error = None; + self.track_profiles(cx); + self.track_sync_status(cx); + cx.notify(); + + let (announcement, local_path) = { + let store = self.store.read(cx); + (store.announcement.clone(), store.path.clone()) + }; + + if let Some(local_path) = local_path { + self.repo_started = true; + + let task: Task> = cx.spawn_in(window, async move |this, cx| { + let data = cx + .background_spawn(async move { + let repo = Repo::open(&local_path)?; + load_repo_data(&repo) + }) + .await; + + this.update_in(cx, |this, window, cx| { + match data { + Ok(data) => this.apply_repo_data(data, window, cx), + Err(error) => this.error = Some(error.to_string().into()), + } + this.loading = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + return; + } + + let Some(announcement) = announcement else { + return; + }; + + self.repo_started = true; + + let store = self.store.downgrade(); + let addr = announcement.addr(); + let clone_urls: Vec = announcement.clone.clone(); + + let disk = { + let addr = addr.clone(); + cx.background_spawn(async move { + match Mirrors::open(&addr)? { + Some(repo) => Ok(Some(load_repo_data(&repo)?)), + None => Ok(None), + } + }) + }; + + let task: Task> = cx.spawn_in(window, async move |this, cx| { + let disk = disk.await; + let had_clone = matches!(&disk, Ok(Some(_))); + + let data = match disk { + Ok(Some(data)) => Ok(data), + Ok(None) => { + let addr = addr.clone(); + let clone_urls = clone_urls.clone(); + cx.background_spawn(async move { + let repo = Mirrors::ensure(&addr, &clone_urls)?; + load_repo_data(&repo) + }) + .await + } + Err(error) => Err(error), + }; + + this.update_in(cx, |this, window, cx| { + match data { + Ok(data) => this.apply_repo_data(data, window, cx), + Err(error) => this.error = Some(error.to_string().into()), + } + this.loading = false; + cx.notify(); + })?; + + if !had_clone { + return Ok(()); + } + + let pushing = store.read_with(cx, |this, _| this.pushing)?; + if pushing { + return Ok(()); + } + + let refresh = { + let addr = addr.clone(); + + cx.background_spawn(async move { + let Some(repo) = Mirrors::open(&addr)? else { + return Ok::<_, Error>(None); + }; + + repo.fetch().ok(); + let moved = repo.fast_forward_branches().unwrap_or(false); + Ok::<_, Error>(Some((moved, repo.snapshot()?))) + }) + .await + }; + + this.update_in(cx, |this, window, cx| { + if let Ok(Some((moved, snapshot))) = refresh { + let branches: Vec = + snapshot.branches.iter().map(Into::into).collect(); + let tags: Vec = snapshot.tags.iter().map(Into::into).collect(); + let current_branch = snapshot.current_branch.clone(); + let head_commit = snapshot.head_commit.clone(); + + let branches_changed = this.refs.set_branches( + branches, + current_branch.map(Into::into), + window, + cx, + ); + let tags_changed = this.refs.set_tags(tags, window, cx); + + let new_head_commit = head_commit.as_ref().map(|c| &c.id); + let current_head_commit = this.head_commit.as_ref().map(|c| &c.id); + let head_changed = new_head_commit != current_head_commit; + this.head_commit = head_commit; + + if moved { + this.catch_up_worktree(cx); + } + + if branches_changed || tags_changed || head_changed { + cx.notify(); + } + } + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { + let RepoData { + tree, + entries, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + } = data; + + let Some(worktree) = worktree else { + self.error = Some("Repository has no worktree".into()); + return; + }; + + self.worktree = Some(worktree.clone()); + self.head_commit = head_commit; + + self.files.update(cx, |files, cx| { + files.set_worktree(worktree.clone()); + files.apply_entries(tree, sorted_worktree_paths(&entries), cx); + files.set_readme(readme_path, readme, cx); + }); + + let branches: Vec = branches.into_iter().map(Into::into).collect(); + let tags: Vec = tags.into_iter().map(Into::into).collect(); + + self.refs + .set_branches(branches, current_branch.map(Into::into), window, cx); + self.refs.set_tags(tags, window, cx); + } + + fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { + let store = self.store.clone(); + + let name = { + let Some(announcement) = self.announcement(cx) else { + return; + }; + let addr = announcement.addr(); + let name = announcement + .name + .as_ref() + .map(|name| name.to_string()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| addr.identifier().to_owned()); + let name = GitCache::sanitize_path_component(&name); + + if name.is_empty() { + "repository".to_owned() + } else { + name + } + }; + + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Clone".into()), + }); + + let task: Task> = cx.spawn_in(window, async move |this, cx| { + let picked = match prompt.await { + Ok(Ok(Some(mut paths))) => paths.pop(), + _ => None, + }; + let Some(folder) = picked else { + return Ok(()); + }; + + let destination = folder.join(&name); + let destination_for_open = destination.clone(); + + let clone = this.update_in(cx, |_this, _window, cx| { + store.update(cx, |store, cx| store.clone_to_folder(destination, cx)) + })?; + + if let Ok(()) = clone.await { + this.update_in(cx, |_this, _window, cx| { + cx.open_with_system(&destination_for_open); + })?; + } + + Ok(()) + }); + + self.tasks.push(task); + } + + fn switch_ref(&mut self, kind: RefKind, name: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { + if self.refs.switching_ref { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let name = name.into(); + let previous_branch = self.refs.branch_select.read(cx).selected().cloned(); + let previous_tag = self.refs.tag_select.read(cx).selected().cloned(); + + match kind { + RefKind::Branch => { + self.refs + .tag_select + .update(cx, |state, cx| state.set_selected(None, window, cx)); + } + RefKind::Tag => { + self.refs + .branch_select + .update(cx, |state, cx| state.set_selected(None, window, cx)); + } + } + + self.refs.switching_ref = true; + cx.notify(); + + let checkout_name = name.clone(); + let task: Task> = cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn(async move { + let repo = Repo::open(&worktree); + match kind { + RefKind::Branch => { + repo.and_then(|repo| repo.checkout_branch(&checkout_name)) + } + RefKind::Tag => repo.and_then(|repo| repo.checkout_tag(&checkout_name)), + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + match result { + Ok(()) => this.reload_worktree(cx), + Err(error) => { + this.error = Some(format!("Failed to check out {name}: {error}").into()); + this.refs.switching_ref = false; + this.refs.restore_selection( + &this.refs.branch_select, + &previous_branch, + window, + cx, + ); + this.refs.restore_selection( + &this.refs.tag_select, + &previous_tag, + window, + cx, + ); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + fn reload_worktree(&mut self, cx: &mut Context) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let task: Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let snapshot = Repo::open(&worktree)?.snapshot()?; + let tree = build_tree_items(&snapshot.entries); + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) + }) + .await; + + this.update(cx, |this, cx| { + this.refs.switching_ref = false; + + match result { + Ok((snapshot, tree, paths)) => { + this.head_commit = snapshot.head_commit.clone(); + let readme_path = snapshot.readme_path; + let readme = snapshot.readme; + this.files.update(cx, |files, cx| { + files.clear_previews(); + files.apply_entries(tree, paths, cx); + files.set_readme(readme_path, readme, cx); + }); + } + Err(error) => { + this.error = Some(error.to_string().into()); + this.head_commit = None; + this.files.update(cx, |files, cx| { + files.apply_entries(Vec::new(), Vec::new(), cx); + }); + } + } + + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Refresh the file explorer and previews after the mirror caught up with the remote. + fn catch_up_worktree(&mut self, cx: &mut Context) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let task: Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let snapshot = Repo::open(&worktree)?.snapshot()?; + let tree = build_tree_items(&snapshot.entries); + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) + }) + .await; + + this.update(cx, |this, cx| match result { + Ok((snapshot, tree, paths)) => { + this.head_commit = snapshot.head_commit.clone(); + this.files.update(cx, |files, cx| { + files.catch_up(&snapshot, tree, paths, cx); + }); + cx.notify(); + } + Err(error) => { + this.error = Some(error.to_string().into()); + cx.notify(); + } + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { + self.error = None; + cx.notify(); + + let task = self.store.update(cx, |store, cx| store.push_repository(cx)); + self.tasks.push(task); + } + + fn push_unpushed_checkout( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let store = self.store.clone(); + + if store.read(cx).pushing { + return; + } + + self.error = None; + cx.notify(); + + let task: Task> = cx.spawn_in(window, async move |this, cx| { + let push = this.update_in(cx, |_this, _window, cx| { + store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) + })?; + + if let Ok(()) = push.await { + this.update_in(cx, |this, window, cx| { + this.load_repo(window, cx); + })?; + } + + Ok(()) + }); + + self.tasks.push(task); + } + + fn reset_remote(&mut self, window: &mut Window, cx: &mut Context) { + let Some(announcement) = self.announcement(cx).cloned() else { + return; + }; + + let store = self.store.clone(); + let mirror = Mirrors::path(&announcement.addr()); + let urls: Vec = announcement + .clone + .iter() + .map(|url| url.to_string()) + .collect(); + + let probe = cx.background_spawn(async move { + let mut deleted: Vec = Vec::new(); + let repo = Repo::open(&mirror)?; + + let local: HashSet = repo + .ref_state()? + .refs + .into_iter() + .map(|(name, _)| name) + .collect(); + + for url in &urls { + let Ok(remote_refs) = repo.ls_remote(url) else { + continue; + }; + for (name, _) in remote_refs { + if !local.contains(&name) && !deleted.contains(&name) { + deleted.push(name); + } + } + } + + deleted.sort(); + Ok::<_, Error>(deleted) + }); + + cx.spawn_in(window, async move |this, cx| { + let deleted = probe.await; + + this.update_in(cx, |this, _window, cx| match deleted { + Ok(deleted) => dialogs::reset_remote::open(store, deleted, cx), + Err(error) => { + this.error = Some(error.to_string().into()); + cx.notify(); + } + })?; + + Ok::<(), Error>(()) + }) + .detach(); + } + + /// Delete the repository from nostr, announcement, state and activity. + fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let task = self + .store + .update(cx, |store, cx| store.delete_repository(cx)); + self.tasks.push(task); + } + + /// Open the upstream repository, the `u` tag of this fork's announcement. + fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self + .announcement(cx) + .and_then(|announcement| announcement.upstream.as_ref()) + .and_then(|upstream| upstream.addr.clone()) + else { + return; + }; + + actions::open_repo_panel(&self.dock, &addr, None, window, cx); + } + + /// Open the dialog that publishes the local repository to NIP-34. + fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(local_path) = self.store.read(cx).path.clone() else { + return; + }; + dialogs::init_repo::open(local_path, cx.entity().downgrade(), window, cx); + } + + fn render_header(&mut self, cx: &mut Context) -> AnyElement { + if self.store.read(cx).addr().is_none() { + return self.render_local_header(cx); + } + + let store = self.store.read(cx); + let pushing = store.pushing; + let cloning = store.cloning; + + let Some(announcement) = store.announcement.clone() else { + return div().into_any_element(); + }; + + let nip05 = self + .directory + .read(cx) + .profile(&announcement.owner, cx) + .metadata() + .nip05 + .clone() + .filter(|nip05| !nip05.trim().is_empty()); + + let share = ShareTargets::from_announcement(&announcement); + let nostr_url = nostr_clone_url(&announcement, nip05.as_deref()); + let ngit_command = SharedString::from(format!("git clone {nostr_url}")); + let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); + let git_commands: Vec = announcement.clone_urls(); + + let name = repo_display_name(self.store.read(cx)); + let description = announcement.description(); + let avatar = Avatar::new(format!("{}:{}", announcement.owner, announcement.id)); + + let share_naddr = share.naddr.clone(); + let share_for_menu = share.clone(); + let announcement_for_menu = announcement.clone(); + let weak_share = cx.entity().downgrade(); + let weak_menu_open = cx.entity().downgrade(); + let weak_menu_items = cx.entity().downgrade(); + let weak_clone = cx.entity().downgrade(); + let weak_download = cx.entity().downgrade(); + let ngit_for_popover = ngit_command.clone(); + let nak_for_popover = nak_command.clone(); + let git_for_popover = git_commands.clone(); + + v_flex() + .p_4() + .w_full() + .gap_6() + .border_b_1() + .border_color(cx.theme().colors.border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(avatar) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .line_clamp(2) + .child(description), + ) + .when_some(fork_row(&announcement, cx), |this, row| this.child(row)) + .child( + h_flex() + .mt_2() + .w_full() + .gap_1() + .child( + div() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("Maintainers:"), + ) + .child(self.render_maintainers(cx)), + ), + ) + .child( + h_flex() + .flex_none() + .gap_2() + .justify_end() + .child( + DropdownMenu::new("share") + .icon(Icon::named(IconRole::Copy)) + .variant(ButtonVariant::Secondary) + .open(self.share_open) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + share_naddr.clone(), + )); + }) + .on_open_change(move |open, _, cx| { + if let Err(error) = weak_share.update(cx, |this, cx| { + this.share_open = open; + cx.notify(); + }) { + log::warn!("failed to update share menu: {error}"); + } + }) + .items(move |_, _| share_for_menu.menu()), + ) + .child( + DropdownMenu::new("repo-menu") + .icon(Icon::named(CustomIconName::EllipsisVertical)) + .variant(ButtonVariant::Secondary) + .loading(pushing) + .disabled(pushing) + .open(self.repo_menu_open) + .on_open_change(move |open, _, cx| { + if let Err(error) = weak_menu_open.update(cx, |this, cx| { + this.repo_menu_open = open; + cx.notify(); + }) { + log::warn!("failed to update repo menu: {error}"); + } + }) + .items(move |_, cx| { + let owner = Backend::global(cx) + .read(cx) + .current_user() + == Some(announcement_for_menu.owner); + + let mut items = vec![MenuItem::new("About").handler({ + let weak = weak_menu_items.clone(); + let announcement = announcement_for_menu.clone(); + move |_, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + dialogs::about::open( + announcement.clone(), + this.directory.clone(), + cx, + ); + }) { + log::warn!("failed to open about: {error}"); + } + } + })]; + + if owner { + items.push(MenuItem::new("Republish").handler({ + let weak = weak_menu_items.clone(); + move |window, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.push_repository(window, cx); + }) { + log::warn!("failed to republish: {error}"); + } + } + })); + items.push(MenuItem::new("Reset remote to local").handler({ + let weak = weak_menu_items.clone(); + move |window, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.reset_remote(window, cx); + }) { + log::warn!("failed to reset remote: {error}"); + } + } + })); + items.push(MenuItem::separator()); + items.push( + MenuItem::new("Delete") + .destructive(true) + .handler({ + let weak = weak_menu_items.clone(); + move |window, cx| { + if let Err(error) = + weak.update(cx, |this, cx| { + this.delete_repository(window, cx); + }) + { + log::warn!( + "failed to delete repository: {error}" + ); + } + } + }), + ); + } + + items + }), + ) + .child( + Popover::new("clone") + .open(self.clone_open) + .on_change(move |open, _, cx| { + if let Err(error) = weak_clone.update(cx, |this, cx| { + this.clone_open = open; + cx.notify(); + }) { + log::warn!("failed to update clone popover: {error}"); + } + }) + .trigger(move |_, _| { + Button::new("clone") + .icon(Icon::named(CustomIconName::GitClone)) + .tooltip(Tooltip::text("Clone")) + .loading(cloning) + .disabled(cloning) + .into_any_element() + }) + .content(move |_, cx| { + v_flex() + .w(px(440.)) + .p_3() + .gap_4() + .child(clone_group( + "Clone with ngit", + "copy-ngit", + &ngit_for_popover, + )) + .child(clone_group( + "Clone with nak", + "copy-nak", + &nak_for_popover, + )) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .text_color( + cx.theme().colors.muted_foreground, + ) + .child("Grasp Servers"), + ) + .when(git_for_popover.is_empty(), |this| { + this.child( + div() + .text_xs() + .child("No git clone urls."), + ) + }) + .children(git_for_popover.iter().enumerate().map( + |(ix, command)| { + ClipboardButton::new( + SharedString::from(format!( + "copy-git-{ix}" + )), + command.clone(), + ) + }, + )), + ) + .child( + h_flex().justify_end().child( + Button::new("download") + .icon(Icon::named(CustomIconName::GitClone)) + .label("Download") + .on_click({ + let weak = weak_download.clone(); + move |_, window, cx| { + if let Err(error) = + weak.update(cx, |this, cx| { + this.clone_to_folder(window, cx); + }) + { + log::warn!( + "failed to clone: {error}" + ); + } + } + }), + ), + ) + .into_any_element() + }), + ), + ), + ) + .into_any_element() + } + + fn render_local_header(&self, cx: &mut Context) -> AnyElement { + let name = repo_display_name(self.store.read(cx)); + let path = self + .store + .read(cx) + .path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + let avatar = Avatar::new(path.clone()); + + let bound = self.store.read(cx).nip34.clone(); + let action = match bound + .as_ref() + .filter(|binding| binding.kind == Nip34Kind::Initialized) + { + Some(binding) => bound_repo_label(binding, &self.directory, cx), + None => Button::new("init") + .icon(Icon::named(CustomIconName::Init)) + .label("Initialize on Nostr") + .tooltip(Tooltip::text("Publish this repository to Nostr")) + .on_click(cx.listener(|this, _, window, cx| { + this.open_init_dialog(window, cx); + })) + .into_any_element(), + }; + + v_flex() + .px_4() + .pb_4() + .w_full() + .gap_6() + .border_b_1() + .border_color(cx.theme().colors.border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(avatar) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(path), + ), + ) + .child(action), + ) + .into_any_element() + } + + fn render_maintainers(&self, cx: &mut Context) -> AnyElement { + let Some(announcement) = self.announcement(cx) else { + return div().into_any_element(); + }; + + let mut seen = HashSet::new(); + let rest: Vec<_> = announcement + .maintainers + .iter() + .copied() + .filter(|key| key != &announcement.owner && seen.insert(*key)) + .collect(); + + let owner = self.directory.read(cx).profile(&announcement.owner, cx); + let owner_name = owner.name(); + + h_flex() + .w_full() + .gap_3() + .child( + h_flex() + .gap_2() + .child( + h_flex() + .gap_1() + .child( + Avatar::new(announcement.owner) + .name(owner_name.clone()) + .picture(owner.picture()) + .small(), + ) + .child(div().text_xs().whitespace_nowrap().child(owner_name)), + ) + .when(!rest.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .child(SharedString::from(format!("+{}", rest.len()))), + ) + }), + ) + .into_any_element() + } + + fn push_suggestion(&self, cx: &App) -> Option { + let store = self.store.read(cx); + let addr = store.addr()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if !store.is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr); + + statuses + .into_iter() + .find(|status| !self.banners.dismissal(status)) + } + + fn sync_suggestion(&self, cx: &App) -> Option { + let store = self.store.read(cx); + let addr = store.addr()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if !store.is_author(&user) { + return None; + } + + let statuses = self.sync_directory.read(cx).statuses_of(addr, cx); + + statuses.into_iter().find(|checkout| { + checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout) + }) + } + + fn render_push_banner(&self, cx: &Context) -> Option { + let status = self.push_suggestion(cx)?; + let path = status.path.clone(); + let pushing = self.store.read(cx).pushing; + let commits = if status.ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", status.ahead)) + }; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .justify_between() + .bg(cx.theme().colors.muted) + .child( + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(Badge::new("push-branch", status.branch.clone())) + .child(commits) + .child("ready to push"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("push-checkout-banner") + .icon(Icon::named(CustomIconName::ArrowUp)) + .label("Push") + .small() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(move |this, _, window, cx| { + this.push_unpushed_checkout(path.clone(), window, cx); + })), + ) + .child( + Button::new("dismiss-push-banner") + .icon(Icon::named(IconRole::Close)) + .variant(ButtonVariant::Ghost) + .small() + .disabled(pushing) + .on_click(cx.listener(move |this, _, _, cx| { + this.banners.dismiss(&status); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + fn render_sync_banner(&self, cx: &Context) -> Option { + let checkout = self.sync_suggestion(cx)?; + let path = checkout.path.clone(); + let pushing = self.store.read(cx).pushing; + + let (branch, ahead) = checkout + .status + .refs + .iter() + .filter_map(|(name, sync)| match sync { + signed_git::RefSync::LocalAhead { ahead } => Some((name, *ahead)), + signed_git::RefSync::Diverged { ahead, .. } => Some((name, *ahead)), + _ => None, + }) + .max_by_key(|(_, ahead)| *ahead)?; + + let branch = SharedString::from(branch.clone()); + let commits = if ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", ahead)) + }; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .justify_between() + .bg(cx.theme().colors.muted) + .child( + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(Badge::new("sync-branch", branch)) + .child(commits) + .child("ahead of Nostr"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("sync-banner") + .icon(Icon::named(CustomIconName::Refresh)) + .label("Sync now") + .small() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(move |this, _, window, cx| { + this.push_unpushed_checkout(path.clone(), window, cx); + })), + ) + .child( + Button::new("dismiss-sync-banner") + .icon(Icon::named(IconRole::Close)) + .variant(ButtonVariant::Ghost) + .small() + .disabled(pushing) + .on_click(cx.listener(move |this, _, _, cx| { + this.banners.sync_dismiss(&checkout); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + fn render_push_progress_banner(&self, cx: &Context) -> Option { + let store = self.store.read(cx); + if !store.pushing || store.push_steps.is_empty() { + return None; + } + + let steps = store + .push_steps + .iter() + .map(|status| { + let label = SharedString::from(status.step().label()); + + match status { + PushStepStatus::Running(_) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(Spinner::new("push-step")) + .child(label) + .into_any_element(), + PushStepStatus::Done(_) => h_flex() + .gap_2() + .text_sm() + .child(Icon::named(IconRole::Check).size(px(14.))) + .child(label) + .into_any_element(), + PushStepStatus::Failed(_, reason) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().ramps.destructive.base) + .child(Icon::named(IconRole::Close).size(px(14.))) + .child(SharedString::from(format!( + "{}: {reason}", + status.step().label() + ))) + .into_any_element(), + } + }) + .collect::>(); + + Some( + v_flex() + .p_4() + .gap_1() + .w_full() + .bg(cx.theme().colors.muted) + .child(div().text_sm().child("Pushing")) + .children(steps) + .into_any_element(), + ) + } + + fn render_push_warning_banner(&self, cx: &Context) -> Option { + let store = self.store.read(cx); + let warning = store.last_push_warning.clone()?; + let pushing = store.pushing; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_start() + .justify_between() + .bg(cx.theme().colors.muted) + .child( + h_flex() + .gap_2() + .min_w_0() + .flex_1() + .items_start() + .child(Icon::named(IconRole::Warning).size(px(14.))) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_color(cx.theme().ramps.warning.base) + .child(SharedString::from(warning)), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("republish-after-partial-push") + .icon(Icon::named(CustomIconName::Init)) + .label("Republish") + .small() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(|this, _, window, cx| { + this.push_repository(window, cx); + })), + ) + .child( + Button::new("dismiss-push-warning") + .icon(Icon::named(IconRole::Close)) + .variant(ButtonVariant::Ghost) + .small() + .disabled(pushing) + .on_click(cx.listener(|this, _, _, cx| { + this.store.update(cx, |store, _| { + store.last_push_warning = None; + }); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + fn render_files_tab(&self, cx: &mut Context) -> AnyElement { + if self.loading { + let label = if self.store.read(cx).path.is_some() { + "Loading repository..." + } else { + "Cloning repository..." + }; + + return v_flex() + .flex_1() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child(Spinner::new("repo-loading")) + .child( + div() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(label), + ) + .into_any_element(); + } + + self.files.clone().into_any_element() + } +} + +impl Panel for RepoDetailView { + fn title(&self, cx: &App) -> Option { + Some(repo_display_name(self.store.read(cx))) + } + + fn lead(&self, cx: &App) -> Option { + Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx)) + } +} + +impl Render for RepoDetailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let banner = self + .render_push_banner(cx) + .or_else(|| self.render_sync_banner(cx)); + + let error = self.error.clone().or_else(|| { + self.store + .read(cx) + .last_error + .clone() + .map(SharedString::from) + }); + + let weak = cx.entity().downgrade(); + + v_flex() + .id("repo") + .size_full() + .when_some(banner, |this, banner| this.child(banner)) + .when_some(self.render_push_progress_banner(cx), |this, banner| { + this.child(banner) + }) + .when_some(self.render_push_warning_banner(cx), |this, banner| { + this.child(banner) + }) + .child(self.render_header(cx)) + .when_some(error, |this, error| { + this.child( + Alert::new("repo-error") + .severity(AlertSeverity::Danger) + .title("Repository error") + .message(error) + .on_dismiss(move |cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.error = None; + this.store.update(cx, |store, _| store.last_error = None); + cx.notify(); + }) { + log::warn!("failed to dismiss repo error: {error}"); + } + }), + ) + }) + .child(self.render_files_tab(cx)) + } +} + +fn clone_group(label: &str, id: &'static str, command: &SharedString) -> AnyElement { + v_flex() + .gap_1() + .child(div().text_xs().child(SharedString::from(label))) + .child(ClipboardButton::new(id, command.clone())) + .into_any_element() +} + +fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { + let owner = announcement.owner; + let user = nip05 + .map(str::to_owned) + .unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_string())); + + let mut url = format!("nostr://{user}"); + if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { + url.push('/'); + url.push_str(hint); + } + url.push('/'); + url.push_str(&announcement.id); + + SharedString::from(url) +} + +fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { + let upstream = announcement.upstream.as_ref()?; + + let (label, clickable) = match &upstream.addr { + Some(addr) => { + let name = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|a| a.addr() == *addr) + .map(|a| { + a.name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(a.id.clone())) + }) + .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); + (SharedString::from(format!("Forked from {name}")), true) + } + None => (SharedString::from(upstream.display().as_str()), false), + }; + + let row = h_flex() + .gap_1() + .min_w_0() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(Icon::named(CustomIconName::GitBranch).size(px(14.))) + .child(div().whitespace_nowrap().text_ellipsis().child(label)); + + Some(if clickable { + row.id("fork-upstream") + .cursor_pointer() + .on_click(cx.listener(|this, _, window, cx| this.open_upstream(window, cx))) + .into_any_element() + } else { + row.into_any_element() + }) +} + +#[derive(Clone)] +struct ShareTargets { + /// NIP-19 `naddr1...` of the announcement, with its announced relays. + naddr: String, + /// Hex ID of the announcement event itself. + event_id: String, + /// NIP-34 coordinate `30617::`. + coordinate: String, + gitworkshop: String, + ditto: String, +} + +impl ShareTargets { + fn from_announcement(announcement: &Announcement) -> Self { + let addr = announcement.addr(); + let coordinate = addr.to_string(); + let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned()) + .to_bech32() + .unwrap_or_else(|_| coordinate.clone()); + + Self { + naddr: naddr.clone(), + event_id: announcement + .event_id + .to_bech32() + .unwrap_or_else(|_| announcement.event_id.to_string()), + coordinate, + gitworkshop: format!("https://gitworkshop.dev/{naddr}"), + ditto: format!("https://ditto.pub/{naddr}"), + } + } + + fn menu(&self) -> Vec { + let items = [ + ("GitWorkshop", truncate_naddr_link(&self.gitworkshop, 4)), + ("Ditto", truncate_naddr_link(&self.ditto, 4)), + ("Event ID", middle_truncate(&self.event_id, 10, 10)), + ("Coordinate", middle_truncate(&self.coordinate, 10, 10)), + ]; + + let values = [ + self.gitworkshop.clone(), + self.ditto.clone(), + self.event_id.clone(), + self.coordinate.clone(), + ]; + + items + .into_iter() + .zip(values) + .map(|((label, detail), value)| { + MenuItem::new(SharedString::from(format!("{label} {detail}"))).handler( + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(value.clone())); + }, + ) + }) + .collect() + } +} + +fn truncate_naddr_link(url: &str, tail: usize) -> String { + let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { + return url.to_string(); + }; + if url.len() - end <= tail + 3 { + return url.to_string(); + } + format!("{}...{}", &url[..end], &url[url.len() - tail..]) +} + +fn load_repo_data(repo: &Repo) -> Result { + let snapshot = repo.snapshot()?; + let worktree = repo.workdir().map(Path::to_path_buf); + let tree = build_tree_items(&snapshot.entries); + + Ok(RepoData { + tree, + entries: snapshot.entries, + readme_path: snapshot.readme_path, + readme: snapshot.readme, + worktree, + branches: snapshot.branches, + tags: snapshot.tags, + current_branch: snapshot.current_branch, + head_commit: snapshot.head_commit, + }) +} + +/// The announcement's name or ID for announced repositories, the directory name for local ones. +pub fn repo_display_name(store: &RepoStore) -> SharedString { + if store.addr().is_none() { + return store + .path + .as_ref() + .map(|path| { + SharedString::from( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ) + }) + .unwrap_or_default(); + } + + store + .announcement + .as_ref() + .map(|announcement| { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + }) + .unwrap_or_default() +} + +/// The owner and identifier a local repository is already bound to. +fn bound_repo_label( + binding: &Nip34Binding, + directory: &Entity, + cx: &App, +) -> AnyElement { + let profile = binding.owner.map(|pk| directory.read(cx).profile(&pk, cx)); + + h_flex() + .flex_shrink_0() + .gap_2() + .text_sm() + .child("Initialized by") + .text_color(cx.theme().colors.muted_foreground) + .when_some(profile, |this, profile| { + let name = profile.name(); + this.child( + h_flex() + .gap_1() + .text_color(cx.theme().colors.foreground) + .child( + Avatar::new(profile.public_key()) + .name(name.clone()) + .picture(profile.picture()) + .small(), + ) + .child(name), + ) + }) + .when_some(binding.identifier.as_deref(), |this, ident| { + this.child( + div() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(SharedString::from(format!("/{ident}"))), + ) + }) + .into_any_element() +} + +/// The profile avatar shown on the repository tab. +fn repo_tab_avatar( + store: &RepoStore, + directory: &Entity, + cx: &App, +) -> AnyElement { + let seed = store + .announcement + .as_ref() + .map(|announcement| format!("{}:{}", announcement.owner, announcement.id)) + .or_else(|| store.addr().map(|addr| addr.to_string())) + .or_else(|| { + store + .path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + }) + .unwrap_or_default(); + + let owner = store + .announcement + .as_ref() + .map(|announcement| directory.read(cx).profile(&announcement.owner, cx)); + + let (name, picture) = owner + .map(|profile| (Some(profile.name()), profile.picture())) + .unwrap_or((None, None)); + + let avatar = match name { + Some(name) => Avatar::new(seed).name(name), + None => Avatar::new(seed), + }; + + avatar.picture(picture).small().into_any_element() +} diff --git a/crates/signed_app/src/views/repo/refs.rs b/crates/signed_app/src/views/repo/refs.rs new file mode 100644 index 0000000..5604cb1 --- /dev/null +++ b/crates/signed_app/src/views/repo/refs.rs @@ -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>, + pub(super) tag_select: Entity>, + ref_branches: Vec, + ref_tags: Vec, + pub(super) switching_ref: bool, +} + +impl RefSwitcher { + pub(super) fn new(view: WeakEntity, 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, + selected: Option, + 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, + 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>, + previous: &Option, + window: &mut Window, + cx: &mut App, + ) { + select.update(cx, |state, cx| { + state.set_selected(previous.clone(), window, cx) + }); + } +} + +fn sync_selector( + select: &Entity>, + cached: &mut Vec, + items: Vec, + selected: Option, + 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 +} diff --git a/crates/signed_app/src/views/sidebar.rs b/crates/signed_app/src/views/sidebar.rs index 5748d4b..4ad680e 100644 --- a/crates/signed_app/src/views/sidebar.rs +++ b/crates/signed_app/src/views/sidebar.rs @@ -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, _window: &mut Window, _cx: &mut Context) { - // Repository and navigation rows arrive with their screens. + fn activate(&mut self, index: Option, window: &mut Window, cx: &mut Context) { + 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, + ) { + 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) { @@ -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, + 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() +} diff --git a/crates/signed_app/src/views/tree.rs b/crates/signed_app/src/views/tree.rs new file mode 100644 index 0000000..6ce3fa3 --- /dev/null +++ b/crates/signed_app/src/views/tree.rs @@ -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, +} + +/// 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) -> Vec { + fn push( + seed: &TreeItemSeed, + depth: usize, + expanded: &HashSet, + rows: &mut Vec, + ) { + 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 { + // Node indices by full path, so parents resolve in constant time while inserting. + let mut index: HashMap = HashMap::new(); + let mut nodes: Vec<(String, String, Vec)> = Vec::new(); + let mut roots: Vec = Vec::new(); + + for entry in entries { + let mut parent: Option = 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)]) -> 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 { + let mut paths: Vec = 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"); + } +}