From f4972798869a9728b0f6468b6e98ca4e5e25530e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 5 Aug 2026 09:03:25 +0700 Subject: [PATCH 01/64] add basic repo browser --- Cargo.lock | 20 +++ crates/paths/Cargo.toml | 8 ++ crates/paths/src/lib.rs | 166 ++++++++++++++++++++++++ crates/workspace/Cargo.toml | 12 ++ crates/workspace/src/lib.rs | 15 +++ crates/workspace/src/views/mod.rs | 3 + crates/workspace/src/views/repo_list.rs | 105 +++++++++++++++ crates/workspace/src/workspace.rs | 73 +++++++++++ desktop/Cargo.toml | 4 + desktop/src/main.rs | 32 +---- 10 files changed, 411 insertions(+), 27 deletions(-) create mode 100644 crates/paths/Cargo.toml create mode 100644 crates/paths/src/lib.rs create mode 100644 crates/workspace/Cargo.toml create mode 100644 crates/workspace/src/lib.rs create mode 100644 crates/workspace/src/views/mod.rs create mode 100644 crates/workspace/src/views/repo_list.rs create mode 100644 crates/workspace/src/workspace.rs diff --git a/Cargo.lock b/Cargo.lock index fe4b891..e14dad8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6055,6 +6055,13 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "paths" +version = "1.0.0" +dependencies = [ + "dirs", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -7741,8 +7748,11 @@ dependencies = [ "gpui_platform", "gpui_windows", "log", + "paths", "reqwest_client", + "signed_state", "tracing-subscriber", + "workspace", ] [[package]] @@ -10294,6 +10304,16 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "workspace" +version = "1.0.0" +dependencies = [ + "gpui", + "gpui-component", + "signed_core", + "signed_state", +] + [[package]] name = "workspace-hack" version = "0.1.0" diff --git a/crates/paths/Cargo.toml b/crates/paths/Cargo.toml new file mode 100644 index 0000000..da558cc --- /dev/null +++ b/crates/paths/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "paths" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +dirs = "6" diff --git a/crates/paths/src/lib.rs b/crates/paths/src/lib.rs new file mode 100644 index 0000000..9d51d04 --- /dev/null +++ b/crates/paths/src/lib.rs @@ -0,0 +1,166 @@ +//! Paths to locations used by Signed. +//! +//! Follows the same pattern as Zed's `paths` crate: platform-correct base +//! directories, resolved once and cached, with an optional custom data dir +//! override for portable/dev installs. + +use std::path::PathBuf; +use std::sync::OnceLock; + +/// The application name, used to derive platform-specific data, config and +/// cache directory paths. +pub const APP_NAME: &str = "Signed"; + +/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on +/// Linux/FreeBSD and the macOS `~/.config` fallback. +pub const APP_NAME_LOWERCASE: &str = "signed"; + +/// A custom data directory override, set only by [`set_custom_data_dir`]. +static CUSTOM_DATA_DIR: OnceLock = OnceLock::new(); + +/// The resolved data directory. +/// On macOS, this is `~/Library/Application Support/Signed`. +/// On Linux/FreeBSD, this is `$XDG_DATA_HOME/signed`. +/// On Windows, this is `%LOCALAPPDATA%\Signed`. +static CURRENT_DATA_DIR: OnceLock = OnceLock::new(); + +/// The resolved config directory. +/// On macOS, this is `~/.config/signed`. +/// On Linux/FreeBSD, this is `$XDG_CONFIG_HOME/signed`. +/// On Windows, this is `%APPDATA%\Signed`. +static CONFIG_DIR: OnceLock = OnceLock::new(); + +/// Returns the current user's home directory. +pub fn home_dir() -> PathBuf { + dirs::home_dir().expect("failed to determine home directory") +} + +/// Sets a custom directory for all user data, overriding the default data +/// directory. Must be called before any other path operation. The directory +/// is created if it doesn't exist and canonicalized to an absolute path. +/// +/// # Panics +/// +/// Panics if called after [`data_dir`] or [`config_dir`] was initialized, or +/// if the directory cannot be created/canonicalized. +pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf { + if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() { + panic!("set_custom_data_dir called after data_dir or config_dir was initialized"); + } + + CUSTOM_DATA_DIR.get_or_init(|| { + let path = PathBuf::from(dir); + std::fs::create_dir_all(&path).expect("failed to create custom data directory"); + path.canonicalize() + .expect("failed to canonicalize custom data directory") + }) +} + +/// Returns the path to the configuration directory. +pub fn config_dir() -> &'static PathBuf { + CONFIG_DIR.get_or_init(|| { + if let Some(custom_dir) = CUSTOM_DATA_DIR.get() { + custom_dir.join("config") + } else if cfg!(target_os = "windows") { + dirs::config_dir() + .expect("failed to determine RoamingAppData directory") + .join(APP_NAME) + } else if cfg!(any(target_os = "linux", target_os = "freebsd")) { + if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") { + flatpak_xdg_config.into() + } else { + dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory") + } + .join(APP_NAME_LOWERCASE) + } else { + home_dir().join(".config").join(APP_NAME_LOWERCASE) + } + }) +} + +/// Returns the path to the data directory. +pub fn data_dir() -> &'static PathBuf { + CURRENT_DATA_DIR.get_or_init(|| { + if let Some(custom_dir) = CUSTOM_DATA_DIR.get() { + custom_dir.clone() + } else if cfg!(target_os = "macos") { + home_dir() + .join("Library/Application Support") + .join(APP_NAME) + } else if cfg!(any(target_os = "linux", target_os = "freebsd")) { + if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") { + flatpak_xdg_data.into() + } else { + dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory") + } + .join(APP_NAME_LOWERCASE) + } else if cfg!(target_os = "windows") { + dirs::data_local_dir() + .expect("failed to determine LocalAppData directory") + .join(APP_NAME) + } else { + config_dir().clone() + } + }) +} + +/// Returns the path to the cache directory. +pub fn cache_dir() -> &'static PathBuf { + static CACHE_DIR: OnceLock = OnceLock::new(); + CACHE_DIR.get_or_init(|| { + if cfg!(target_os = "macos") { + dirs::cache_dir() + .expect("failed to determine caches directory") + .join(APP_NAME) + } else if cfg!(target_os = "windows") { + dirs::cache_dir() + .expect("failed to determine LocalAppData directory") + .join(APP_NAME) + } else if cfg!(any(target_os = "linux", target_os = "freebsd")) { + if let Ok(flatpak_xdg_cache) = std::env::var("FLATPAK_XDG_CACHE_HOME") { + flatpak_xdg_cache.into() + } else { + dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory") + } + .join(APP_NAME_LOWERCASE) + } else { + home_dir().join(".cache").join(APP_NAME_LOWERCASE) + } + }) +} + +/// Returns the path to the logs directory. +pub fn logs_dir() -> &'static PathBuf { + static LOGS_DIR: OnceLock = OnceLock::new(); + LOGS_DIR.get_or_init(|| { + if cfg!(target_os = "macos") { + home_dir().join("Library/Logs").join(APP_NAME) + } else { + data_dir().join("logs") + } + }) +} + +/// Returns the path to the nostr database directory (LMDB). +pub fn nostr_dir() -> &'static PathBuf { + static NOSTR_DIR: OnceLock = OnceLock::new(); + NOSTR_DIR.get_or_init(|| data_dir().join("nostr")) +} + +/// Returns the path to the local git clone cache (grasp mirrors). +pub fn repos_dir() -> &'static PathBuf { + static REPOS_DIR: OnceLock = OnceLock::new(); + REPOS_DIR.get_or_init(|| data_dir().join("repos")) +} + +/// Returns the path to the `settings.json` file. +pub fn settings_file() -> &'static PathBuf { + static SETTINGS_FILE: OnceLock = OnceLock::new(); + SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json")) +} + +/// Returns the path to the `keymap.json` file. +pub fn keymap_file() -> &'static PathBuf { + static KEYMAP_FILE: OnceLock = OnceLock::new(); + KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json")) +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml new file mode 100644 index 0000000..7df7966 --- /dev/null +++ b/crates/workspace/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "workspace" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +signed_core = { path = "../signed_core" } +signed_state = { path = "../signed_state" } + +gpui.workspace = true +gpui-component.workspace = true diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs new file mode 100644 index 0000000..213cb6c --- /dev/null +++ b/crates/workspace/src/lib.rs @@ -0,0 +1,15 @@ +mod views; +mod workspace; + +pub use views::RepoListView; +pub use workspace::Workspace; + +use gpui::{App, AppContext, Entity, Window}; +use gpui_component::Root; + +/// Build the root view tree. Requires `signed_state::init` and +/// `gpui_component::init` to have been called first. +pub fn root(window: &mut Window, cx: &mut App) -> Entity { + let view = cx.new(|cx| Workspace::new(window, cx)); + cx.new(|cx| Root::new(view, window, cx)) +} diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs new file mode 100644 index 0000000..7d8f7f2 --- /dev/null +++ b/crates/workspace/src/views/mod.rs @@ -0,0 +1,3 @@ +mod repo_list; + +pub use repo_list::RepoListView; diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs new file mode 100644 index 0000000..db5636b --- /dev/null +++ b/crates/workspace/src/views/repo_list.rs @@ -0,0 +1,105 @@ +use gpui::prelude::*; +use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; +use gpui_component::scroll::ScrollableElement; +use gpui_component::{ActiveTheme, StyledExt}; +use signed_core::Announcement; +use signed_state::{ProfileStore, RepoListStore}; + +/// Browse all announced repositories. +pub struct RepoListView { + store: Entity, + _subscription: Subscription, +} + +impl RepoListView { + pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + let store = cx.new(|cx| RepoListStore::new(None, cx)); + let subscription = cx.observe(&store, |_this, _store, cx| cx.notify()); + + Self { + store, + _subscription: subscription, + } + } + + fn render_card(&self, announcement: &Announcement, cx: &mut Context) -> impl IntoElement { + let name = announcement + .name + .clone() + .unwrap_or_else(|| announcement.id.clone()); + + let owner = ProfileStore::global(cx) + .update(cx, |store, cx| store.get(announcement.owner, cx)) + .name(); + + div() + .v_flex() + .gap_1() + .px_4() + .py_3() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child( + div() + .h_flex() + .gap_2() + .items_center() + .child(div().text_sm().font_semibold().child(name)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(owner), + ), + ) + .children(announcement.description.as_ref().map(|description| { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(description.clone()) + })) + } +} + +impl Render for RepoListView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let announcements = self.store.read(cx).announcements.clone(); + let count = announcements.len(); + + let mut list = div().v_flex().flex_1().overflow_y_scrollbar(); + + if announcements.is_empty() { + list = list.child( + div().size_full().items_center().justify_center().child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No repositories found. Waiting for relays..."), + ), + ); + } else { + for announcement in &announcements { + list = list.child(self.render_card(announcement, cx)); + } + } + + div() + .v_flex() + .size_full() + .child( + div() + .h_flex() + .px_4() + .py_2() + .items_center() + .child(div().text_sm().font_semibold().child("Repositories")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!(" ({count})"))), + ), + ) + .child(list) + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs new file mode 100644 index 0000000..47b6ee7 --- /dev/null +++ b/crates/workspace/src/workspace.rs @@ -0,0 +1,73 @@ +use gpui::prelude::*; +use gpui::{AnyView, Context, Render, SharedString, Subscription, Window, div, px}; +use gpui_component::{ActiveTheme, StyledExt}; +use signed_state::{Backend, BackendEvent}; + +use crate::views::RepoListView; + +/// Root view of the app: header, active screen, status bar. +pub struct Workspace { + active_screen: AnyView, + status: SharedString, + _subscription: Subscription, +} + +impl Workspace { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let repo_list = cx.new(|cx| RepoListView::new(window, cx)); + + let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| { + match event { + BackendEvent::Connected => this.status = "Connected".into(), + BackendEvent::Error(error) => this.status = error.clone().into(), + _ => return, + } + cx.notify(); + }); + + Self { + active_screen: repo_list.into(), + status: SharedString::from("Connecting..."), + _subscription: subscription, + } + } +} + +impl Render for Workspace { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .v_flex() + .size_full() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child( + div() + .h_flex() + .px_4() + .py_2() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child(div().text_lg().font_semibold().child("Signed")), + ) + .child( + div() + .flex_1() + .overflow_hidden() + .child(self.active_screen.clone()), + ) + .child( + div() + .h_flex() + .px_4() + .py_1() + .border_t(px(1.)) + .border_color(cx.theme().border) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.status.clone()), + ), + ) + } +} diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index ed55d1c..6551e17 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -9,6 +9,10 @@ name = "signed" path = "src/main.rs" [dependencies] +paths = { path = "../crates/paths" } +signed_state = { path = "../crates/signed_state" } +workspace = { path = "../crates/workspace" } + gpui.workspace = true gpui_platform.workspace = true gpui_linux.workspace = true diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 7c707a1..8f3bb5c 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1,30 +1,8 @@ use std::sync::Arc; use gpui::*; -use gpui_component::button::*; -use gpui_component::*; use gpui_platform::application; -pub struct HelloWorld; - -impl Render for HelloWorld { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div() - .v_flex() - .gap_2() - .size_full() - .items_center() - .justify_center() - .child("Hello, World!") - .child( - Button::new("ok") - .primary() - .label("Let's Go!") - .on_click(|_ev, _window, _cx| println!("Clicked!")), - ) - } -} - fn main() { // Initialize logging tracing_subscriber::fmt::init(); @@ -34,6 +12,10 @@ fn main() { .run(move |cx| { gpui_component::init(cx); + // Initialize backend and stores (connects relays, restores session) + std::fs::create_dir_all(paths::nostr_dir()).ok(); + signed_state::init(paths::nostr_dir(), cx); + // Set up the window bounds let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx); @@ -53,11 +35,7 @@ fn main() { }; cx.spawn(async move |cx| { - cx.open_window(opts, |window, cx| { - let view = cx.new(|_| HelloWorld); - cx.new(|cx| Root::new(view, window, cx)) - }) - .ok(); + let _ = cx.open_window(opts, workspace::root); }) .detach(); -- 2.54.0 From fdf74327bbdc76cf9228b827db9de475e6b4ef7b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 5 Aug 2026 09:24:46 +0700 Subject: [PATCH 02/64] update repo list --- crates/signed_state/src/backend.rs | 19 +++- crates/signed_state/src/repo_list.rs | 6 +- crates/workspace/src/views/repo_list.rs | 122 ++++++++++++++---------- crates/workspace/src/workspace.rs | 11 ++- 4 files changed, 102 insertions(+), 56 deletions(-) diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 668063a..d288578 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -60,6 +60,7 @@ impl BackendEvent { pub struct Backend { inner: NostrBackend, current_user: Option, + connected: bool, tasks: Vec>>, } @@ -106,6 +107,7 @@ impl Backend { let mut this = Self { inner, current_user: None, + connected: false, tasks: vec![pump], }; @@ -132,7 +134,11 @@ impl Backend { self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(()) => { - this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?; + this.update(cx, |this, cx| { + this.connected = true; + cx.emit(BackendEvent::Connected); + cx.notify(); + })?; } Err(e) => { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; @@ -371,6 +377,11 @@ impl Backend { self.current_user } + /// Whether the relay bootstrap has completed. + pub fn is_connected(&self) -> bool { + self.connected + } + /// Update the signer (any type implementing the async signer traits, /// e.g. `Keys`, `NostrConnect`, a browser extension proxy). pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) @@ -418,7 +429,11 @@ impl Backend { self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(()) => { - this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?; + this.update(cx, |this, cx| { + this.connected = true; + cx.emit(BackendEvent::Connected); + cx.notify(); + })?; } Err(e) => { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index 7d88dfd..379e678 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -20,7 +20,8 @@ pub struct RepoListStore { impl RepoListStore { /// Create a store. If `author` is `None`, all announcements are listed. pub fn new(author: Option, cx: &mut Context) -> Self { - let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| { + let backend = Backend::global(cx); + let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { BackendEvent::NostrUpdate(update) => { update.kind == Kind::GitRepoAnnouncement @@ -60,9 +61,10 @@ impl RepoListStore { } fn subscribe_remote(&mut self, cx: &mut Context) { + let backend = Backend::global(cx); let author = self.author; - Backend::global(cx).update(cx, |backend, cx| { + backend.update(cx, |backend, cx| { let filter = match author { Some(a) => filters::announcements_by(a), None => filters::all_announcements(500), diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index db5636b..a51b2e0 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -1,11 +1,13 @@ use gpui::prelude::*; -use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; -use gpui_component::scroll::ScrollableElement; +use gpui::{ + AnyElement, App, Context, Entity, Render, SharedString, Subscription, Window, div, px, + uniform_list, +}; use gpui_component::{ActiveTheme, StyledExt}; use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore}; -/// Browse all announced repositories. +/// Browse all announced repositories (works anonymously). pub struct RepoListView { store: Entity, _subscription: Subscription, @@ -21,44 +23,58 @@ impl RepoListView { _subscription: subscription, } } +} - fn render_card(&self, announcement: &Announcement, cx: &mut Context) -> impl IntoElement { - let name = announcement - .name - .clone() - .unwrap_or_else(|| announcement.id.clone()); +fn render_card(announcement: &Announcement, cx: &mut App) -> AnyElement { + let name = announcement + .name + .clone() + .unwrap_or_else(|| announcement.id.clone()); - let owner = ProfileStore::global(cx) - .update(cx, |store, cx| store.get(announcement.owner, cx)) - .name(); + let owner = ProfileStore::global(cx) + .update(cx, |store, cx| store.get(announcement.owner, cx)) + .name(); - div() - .v_flex() - .gap_1() - .px_4() - .py_3() - .border_b(px(1.)) - .border_color(cx.theme().border) - .child( - div() - .h_flex() - .gap_2() - .items_center() - .child(div().text_sm().font_semibold().child(name)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(owner), - ), - ) - .children(announcement.description.as_ref().map(|description| { - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(description.clone()) - })) - } + let description = announcement.description.clone().unwrap_or_default(); + + div() + .v_flex() + .h(px(60.)) + .justify_center() + .gap_1() + .px_4() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child( + div() + .h_flex() + .gap_2() + .items_center() + .child( + div() + .text_sm() + .font_semibold() + .whitespace_nowrap() + .text_ellipsis() + .child(name), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(owner), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .text_ellipsis() + .child(description), + ) + .into_any_element() } impl Render for RepoListView { @@ -66,22 +82,28 @@ impl Render for RepoListView { let announcements = self.store.read(cx).announcements.clone(); let count = announcements.len(); - let mut list = div().v_flex().flex_1().overflow_y_scrollbar(); - - if announcements.is_empty() { - list = list.child( - div().size_full().items_center().justify_center().child( + let body = if announcements.is_empty() { + div() + .size_full() + .v_flex() + .items_center() + .justify_center() + .child( div() .text_sm() .text_color(cx.theme().muted_foreground) .child("No repositories found. Waiting for relays..."), - ), - ); + ) + .into_any_element() } else { - for announcement in &announcements { - list = list.child(self.render_card(announcement, cx)); - } - } + uniform_list("repo-list", count, move |range, _window, cx| { + range + .map(|index| render_card(&announcements[index], cx)) + .collect() + }) + .size_full() + .into_any_element() + }; div() .v_flex() @@ -100,6 +122,6 @@ impl Render for RepoListView { .child(SharedString::from(format!(" ({count})"))), ), ) - .child(list) + .child(body) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 47b6ee7..e0f9d80 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -14,9 +14,12 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let backend = Backend::global(cx); let repo_list = cx.new(|cx| RepoListView::new(window, cx)); - let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| { + let connected = backend.read(cx).is_connected(); + + let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { match event { BackendEvent::Connected => this.status = "Connected".into(), BackendEvent::Error(error) => this.status = error.clone().into(), @@ -27,7 +30,11 @@ impl Workspace { Self { active_screen: repo_list.into(), - status: SharedString::from("Connecting..."), + status: if connected { + "Connected".into() + } else { + "Connecting...".into() + }, _subscription: subscription, } } -- 2.54.0 From 2ec7d14c3345460cba7e20f395fa509cf73d2e5d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 5 Aug 2026 10:08:01 +0700 Subject: [PATCH 03/64] use negentropy sync --- crates/signed_state/src/backend.rs | 83 ++++++++++++++++++++++++++++ crates/signed_state/src/profile.rs | 60 ++++++++++++++++++-- crates/signed_state/src/repo.rs | 14 +++-- crates/signed_state/src/repo_list.rs | 4 +- 4 files changed, 151 insertions(+), 10 deletions(-) diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index d288578..f47c6e3 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,8 +1,10 @@ +use std::collections::HashMap; use std::time::Duration; use anyhow::{Error, anyhow}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use nostr_connect::prelude::*; +use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; use signed_core::filters; use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update}; @@ -39,6 +41,10 @@ pub enum BackendEvent { Connected, /// A new event was received from a relay and stored in the database. NostrUpdate(Update), + /// A negentropy sync completed; the database was updated directly, + /// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired + /// for synced events). + Synced, /// An event built locally was signed, broadcast and stored. Published(Box), /// An error occurred. @@ -479,6 +485,56 @@ impl Backend { })); } + /// Start a one-shot subscription targeted only at the bootstrap relays, + /// auto-closing after EOSE or a short timeout. Matching events are stored + /// in the database and surface as [`BackendEvent::NostrUpdate`] while the + /// subscription is open. + pub fn subscribe_bootstrap(&mut self, filters: Vec, cx: &mut Context) { + let backend = self.inner.clone(); + + let task = cx.background_spawn(async move { + subscribe_bootstrap_only(&backend.client(), filters).await + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(e) = task.await { + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + } + Ok(()) + })); + } + + /// Negentropy-sync the given filter against the bootstrap relays: + /// reconciles the local database with the relays in both directions. + /// Emits [`BackendEvent::Synced`] on completion. + pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { + let backend = self.inner.clone(); + + let task = cx.background_spawn(async move { + sync_bootstrap_only(&backend.client(), filter).await + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(summary) => { + log::debug!( + "sync done: {} received, {} sent", + summary.received.len(), + summary.sent.len() + ); + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::Synced); + cx.notify(); + })?; + } + Err(e) => { + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + } + } + Ok(()) + })); + } + /// Sign, broadcast and locally store an event. Emits /// [`BackendEvent::Published`] on success so stores can refresh. /// @@ -517,3 +573,30 @@ impl Backend { rx } } + +/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a +/// short timeout. Use for one-shot data fetches (repo events, profiles) +/// instead of persistent gossip-routed subscriptions. +pub(crate) async fn subscribe_bootstrap_only(client: &Client, filters: Vec) -> Result<(), Error> { + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(Duration::from_secs(10))); + + let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS + .iter() + .map(|relay| (*relay, filters.clone())) + .collect(); + + client.subscribe(target).close_on(opts).await?; + + Ok(()) +} + +/// Negentropy-sync the filter against the bootstrap relays only. +pub(crate) async fn sync_bootstrap_only( + client: &Client, + filter: Filter, +) -> Result { + let output = client.sync(filter).with(BOOTSTRAP_RELAYS).await?; + Ok(output.value) +} diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index b72f62c..64742e3 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -5,7 +5,7 @@ use anyhow::Error; use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task}; use nostr_sdk::prelude::*; -use crate::backend::{Backend, BackendEvent}; +use crate::backend::{Backend, BackendEvent, sync_bootstrap_only}; /// A user profile (kind `0` metadata), as plain data for the UI. #[derive(Debug, Clone)] @@ -181,6 +181,50 @@ impl ProfileStore { self.tasks.push(task); } + /// Re-read the latest metadata of every requested author from the local + /// database (used after a sync, which produces no NostrUpdate events). + fn apply_seen(&mut self, cx: &mut Context) { + if self.seen.is_empty() { + return; + } + + let client = Backend::global(cx).read(cx).client(); + let authors: Vec = self.seen.iter().copied().collect(); + + let task = cx.spawn(async move |this, cx| { + let filter = Filter::new().kind(Kind::Metadata).authors(authors); + let events = client.database().query(filter).await?; + + let mut latest: HashMap = HashMap::new(); + for event in events { + match latest.get(&event.pubkey) { + Some((ts, _)) if *ts >= event.created_at => {} + _ => { + latest.insert( + event.pubkey, + ( + event.created_at, + Metadata::from_json(&event.content).unwrap_or_default(), + ), + ); + } + } + } + + this.update(cx, |this, cx| { + for (public_key, (_, metadata)) in latest { + this.profiles + .insert(public_key, Profile::new(public_key, metadata)); + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + /// Drain the queue in a batched fetch, debounced to collect requests. fn queue_fetch(&mut self, cx: &mut Context) { if self.fetching { @@ -210,10 +254,16 @@ impl ProfileStore { .kind(Kind::Metadata) .authors(batch.into_iter().collect::>()); - // Gossip routes the fetch to each author's relays. Fetched - // events land in the database and surface via NostrUpdate. - if let Err(e) = client.fetch_events(filter).await { - log::warn!("profile fetch failed: {e}"); + // Negentropy-sync with the bootstrap relays. Synced events + // are written to the database directly (no NostrUpdate), so + // re-apply from the database afterwards. + match sync_bootstrap_only(&client, filter).await { + Ok(_) => { + this.update(cx, |this, cx| this.apply_seen(cx))?; + } + Err(e) => { + log::warn!("profile sync failed: {e}"); + } } } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index e288145..43de9ef 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -78,14 +78,20 @@ impl RepoStore { &self.addr } - /// Subscribe the relay pool to this repository's activity. + /// Fetch this repository's events from the bootstrap relays (one-shot, + /// auto-closing subscription). fn subscribe_remote(&mut self, cx: &mut Context) { let addr = self.addr.clone(); Backend::global(cx).update(cx, |backend, cx| { - backend.subscribe(filters::announcement(&addr), cx); - backend.subscribe(filters::state(&addr), cx); - backend.subscribe(filters::activity(&addr), cx); + backend.subscribe_bootstrap( + vec![ + filters::announcement(&addr), + filters::state(&addr), + filters::activity(&addr), + ], + cx, + ); }); } diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index 379e678..ed83a7b 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -31,6 +31,7 @@ impl RepoListStore { event.kind == Kind::GitRepoAnnouncement && this.author.is_none_or(|a| a == event.pubkey) } + BackendEvent::Synced => true, _ => false, }; @@ -60,6 +61,7 @@ impl RepoListStore { self.refresh(cx); } + /// Negentropy-sync announcements with the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let author = self.author; @@ -69,7 +71,7 @@ impl RepoListStore { Some(a) => filters::announcements_by(a), None => filters::all_announcements(500), }; - backend.subscribe(filter, cx); + backend.sync_bootstrap(filter, cx); }); } -- 2.54.0 From ed93d81a2602326ecb5a686006af0b58284960b9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 5 Aug 2026 10:20:12 +0700 Subject: [PATCH 04/64] sync all repos --- crates/signed_core/src/filters.rs | 10 +++-- crates/signed_state/src/backend.rs | 65 +++++++++++++++++++++++++--- crates/signed_state/src/profile.rs | 2 +- crates/signed_state/src/repo_list.rs | 6 +-- crates/workspace/src/workspace.rs | 9 +++- 5 files changed, 78 insertions(+), 14 deletions(-) diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index f275343..3277462 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -68,8 +68,10 @@ pub fn announcements_by(public_key: PublicKey) -> Filter { } /// All repository announcements (for global discovery). -pub fn all_announcements(limit: usize) -> Filter { - Filter::new() - .kind(Kind::GitRepoAnnouncement) - .limit(limit) +/// +/// Unbounded: intended for negentropy sync, which reconciles sets +/// efficiently regardless of size. Local database queries with this +/// filter are served by LMDB, so they stay fast as the database grows. +pub fn all_announcements() -> Filter { + Filter::new().kind(Kind::GitRepoAnnouncement) } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index f47c6e3..8b1f1f9 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -45,6 +45,14 @@ pub enum BackendEvent { /// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired /// for synced events). Synced, + /// A negentropy sync is in flight. Stores may re-query to render + /// incrementally; UI can show `current`/`total` progress. + SyncProgress { + /// Total events to process. + total: u64, + /// Events processed so far. + current: u64, + }, /// An event built locally was signed, broadcast and stored. Published(Box), /// An error occurred. @@ -67,6 +75,7 @@ pub struct Backend { inner: NostrBackend, current_user: Option, connected: bool, + sync_progress: Option<(u64, u64)>, tasks: Vec>>, } @@ -114,6 +123,7 @@ impl Backend { inner, current_user: None, connected: false, + sync_progress: None, tasks: vec![pump], }; @@ -388,6 +398,11 @@ impl Backend { self.connected } + /// Progress of the in-flight negentropy sync, if any: `(total, current)`. + pub fn sync_progress(&self) -> Option<(u64, u64)> { + self.sync_progress + } + /// Update the signer (any type implementing the async signer traits, /// e.g. `Keys`, `NostrConnect`, a browser extension proxy). pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) @@ -506,12 +521,47 @@ impl Backend { /// Negentropy-sync the given filter against the bootstrap relays: /// reconciles the local database with the relays in both directions. - /// Emits [`BackendEvent::Synced`] on completion. + /// Emits [`BackendEvent::SyncProgress`] while running (throttled to + /// whole-percent changes) and [`BackendEvent::Synced`] on completion. pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { let backend = self.inner.clone(); + self.sync_progress = Some((0, 0)); + cx.notify(); + + let (tx, mut rx) = SyncProgress::channel(); + + self.tasks.push(cx.spawn(async move |this, cx| { + let mut last_percent: u64 = 0; + + while rx.changed().await.is_ok() { + let progress = *rx.borrow_and_update(); + let percent = (progress.percentage() * 100.0) as u64; + + if progress.current > 0 && percent != last_percent { + last_percent = percent; + + let alive = this.update(cx, |this, cx| { + this.sync_progress = Some((progress.total, progress.current)); + cx.emit(BackendEvent::SyncProgress { + total: progress.total, + current: progress.current, + }); + cx.notify(); + }); + + if alive.is_err() { + break; + } + } + } + + Ok(()) + })); + let task = cx.background_spawn(async move { - sync_bootstrap_only(&backend.client(), filter).await + let opts = SyncOptions::default().progress(tx); + sync_bootstrap_only(&backend.client(), filter, opts).await }); self.tasks.push(cx.spawn(async move |this, cx| { @@ -522,13 +572,17 @@ impl Backend { summary.received.len(), summary.sent.len() ); - this.update(cx, |_this, cx| { + this.update(cx, |this, cx| { + this.sync_progress = None; cx.emit(BackendEvent::Synced); cx.notify(); })?; } Err(e) => { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |this, cx| { + this.sync_progress = None; + cx.emit(BackendEvent::error(e.to_string())) + })?; } } Ok(()) @@ -596,7 +650,8 @@ pub(crate) async fn subscribe_bootstrap_only(client: &Client, filters: Vec Result { - let output = client.sync(filter).with(BOOTSTRAP_RELAYS).await?; + let output = client.sync(filter).with(BOOTSTRAP_RELAYS).opts(opts).await?; Ok(output.value) } diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 64742e3..9bd05c0 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -257,7 +257,7 @@ impl ProfileStore { // Negentropy-sync with the bootstrap relays. Synced events // are written to the database directly (no NostrUpdate), so // re-apply from the database afterwards. - match sync_bootstrap_only(&client, filter).await { + match sync_bootstrap_only(&client, filter, SyncOptions::default()).await { Ok(_) => { this.update(cx, |this, cx| this.apply_seen(cx))?; } diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index ed83a7b..b0fdd4b 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -31,7 +31,7 @@ impl RepoListStore { event.kind == Kind::GitRepoAnnouncement && this.author.is_none_or(|a| a == event.pubkey) } - BackendEvent::Synced => true, + BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, _ => false, }; @@ -69,7 +69,7 @@ impl RepoListStore { backend.update(cx, |backend, cx| { let filter = match author { Some(a) => filters::announcements_by(a), - None => filters::all_announcements(500), + None => filters::all_announcements(), }; backend.sync_bootstrap(filter, cx); }); @@ -93,7 +93,7 @@ impl RepoListStore { loop { let filter = match author { Some(a) => filters::announcements_by(a), - None => filters::all_announcements(500), + None => filters::all_announcements(), }; let events = match client.database().query(filter).await { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e0f9d80..e8bbd42 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -18,10 +18,15 @@ impl Workspace { let repo_list = cx.new(|cx| RepoListView::new(window, cx)); let connected = backend.read(cx).is_connected(); + let sync_progress = backend.read(cx).sync_progress(); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { match event { BackendEvent::Connected => this.status = "Connected".into(), + BackendEvent::SyncProgress { total, current } => { + this.status = format!("Syncing repositories... {current}/{total}").into() + } + BackendEvent::Synced => this.status = "Connected".into(), BackendEvent::Error(error) => this.status = error.clone().into(), _ => return, } @@ -30,7 +35,9 @@ impl Workspace { Self { active_screen: repo_list.into(), - status: if connected { + status: if let Some((total, current)) = sync_progress { + format!("Syncing repositories... {current}/{total}").into() + } else if connected { "Connected".into() } else { "Connecting...".into() -- 2.54.0 From 8de6018e28febd006d8f0447749796d58a8e42d4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 5 Aug 2026 10:37:33 +0700 Subject: [PATCH 05/64] update nostr connect --- crates/signed_state/src/backend.rs | 87 +++++++++++++++--------------- 1 file changed, 45 insertions(+), 42 deletions(-) diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 8b1f1f9..fa3e145 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -9,10 +9,9 @@ use nostr_sdk::prelude::*; use signed_core::filters; use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update}; -/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`). -pub const USER_KEYRING: &str = "su.reya.signed#user"; -/// Keyring entry holding the locally generated key for NIP-46 sessions. -pub const MASTER_KEYRING: &str = "su.reya.signed#master"; +/// Keyring entry holding the user credential (`nsec1...` or `bunker://...` +/// with an embedded `?master=` NIP-46 session key). +pub const USER_KEYRING: &str = "Signed Safe Storage"; /// Timeout for NIP-46 signer responses. pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; @@ -175,7 +174,6 @@ impl Backend { } let user = cx.read_credentials(USER_KEYRING); - let master = self.master_key(cx); self.tasks.push(cx.spawn(async move |this, cx| { let content = match user.await { @@ -191,10 +189,11 @@ impl Backend { let keys = Keys::new(SecretKey::parse(&content)?); this.update(cx, |this, cx| this.set_signer(keys, cx))?; } else if content.starts_with("bunker://") { - let uri = NostrConnectUri::parse(&content)?; + let (base, keys) = extract_master_key(&content); + let uri = NostrConnectUri::parse(base)?; let mut signer = NostrConnect::new( uri, - master.await, + keys, Duration::from_secs(NOSTR_CONNECT_TIMEOUT), None, )?; @@ -246,9 +245,11 @@ impl Backend { })); } - /// Login with a `bunker://...` URI (NIP-46). The auth URL, if any, is - /// opened in the default browser. The credential is persisted in the - /// keyring after the signer proves reachable. + /// Login with a `bunker://...` URI (NIP-46). A fresh session key is + /// generated and embedded into the stored URI as `?master=`, so + /// no separate keyring entry is needed. The auth URL, if any, is opened + /// in the default browser. The credential is persisted in the keyring + /// after the signer proves reachable. pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context) { let uri_string = uri.trim().to_owned(); @@ -260,14 +261,15 @@ impl Backend { } }; - let master = self.master_key(cx); - let write = cx.write_credentials(USER_KEYRING, "bunker", uri_string.as_bytes()); + let keys = Keys::generate(); + let credential = with_master_key(&uri_string, &keys); + let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); self.tasks.push(cx.spawn(async move |this, cx| { let result = async { let mut signer = NostrConnect::new( connect_uri, - master.await, + keys, Duration::from_secs(NOSTR_CONNECT_TIMEOUT), None, )?; @@ -310,33 +312,6 @@ impl Backend { })); } - /// Get (or generate and persist) the key used for NIP-46 sessions. - fn master_key(&self, cx: &App) -> Task { - let task = cx.read_credentials(MASTER_KEYRING); - - cx.spawn(async move |cx| { - let (keys, new_key) = match task.await { - Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) { - Ok(secret_key) => (Keys::new(secret_key), false), - _ => (Keys::generate(), true), - }, - _ => (Keys::generate(), true), - }; - - if new_key { - let username = keys.public_key().to_hex(); - let password = keys.secret_key().to_secret_bytes(); - - cx.update(|cx| { - let task = cx.write_credentials(MASTER_KEYRING, &username, &password); - cx.background_spawn(async move { task.await.ok() }).detach(); - }); - } - - keys - }) - } - /// Fetch the user's grasp list (kind `10317`) and add the listed grasp /// servers as relays. fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { @@ -631,7 +606,10 @@ impl Backend { /// Subscribe only on the bootstrap relays, auto-closing after EOSE or a /// short timeout. Use for one-shot data fetches (repo events, profiles) /// instead of persistent gossip-routed subscriptions. -pub(crate) async fn subscribe_bootstrap_only(client: &Client, filters: Vec) -> Result<(), Error> { +pub(crate) async fn subscribe_bootstrap_only( + client: &Client, + filters: Vec, +) -> Result<(), Error> { let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) .timeout(Some(Duration::from_secs(10))); @@ -652,6 +630,31 @@ pub(crate) async fn sync_bootstrap_only( filter: Filter, opts: SyncOptions, ) -> Result { - let output = client.sync(filter).with(BOOTSTRAP_RELAYS).opts(opts).await?; + let output = client + .sync(filter) + .with(BOOTSTRAP_RELAYS) + .opts(opts) + .await?; Ok(output.value) } + +/// Embed a NIP-46 session key into a bunker URI as `?master=`. +fn with_master_key(uri: &str, keys: &Keys) -> String { + let separator = if uri.contains('?') { '&' } else { '?' }; + let nsec = keys.secret_key().to_bech32().expect("infallible"); + format!("{uri}{separator}master={nsec}") +} + +/// Split a stored bunker credential into the plain URI and the session key. +/// Credentials without an embedded key (legacy) get a fresh one. +fn extract_master_key(credential: &str) -> (&str, Keys) { + match credential.split_once("master=") { + Some((base, nsec)) => { + let keys = SecretKey::parse(nsec) + .map(Keys::new) + .unwrap_or_else(|_| Keys::generate()); + (base.trim_end_matches(['?', '&']), keys) + } + None => (credential, Keys::generate()), + } +} -- 2.54.0 From 6b13d8a8f7b9ac398a87bcbc96e493c2df075fda Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 5 Aug 2026 11:07:12 +0700 Subject: [PATCH 06/64] add workspace --- crates/workspace/src/lib.rs | 2 +- crates/workspace/src/views/mod.rs | 2 + crates/workspace/src/views/repo_list.rs | 25 +++++- crates/workspace/src/views/sidebar.rs | 77 ++++++++++++++++++ crates/workspace/src/workspace.rs | 100 ++++++++++++++---------- 5 files changed, 162 insertions(+), 44 deletions(-) create mode 100644 crates/workspace/src/views/sidebar.rs diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 213cb6c..7ee8808 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -1,7 +1,7 @@ mod views; mod workspace; -pub use views::RepoListView; +pub use views::{RepoListView, SidebarPanel}; pub use workspace::Workspace; use gpui::{App, AppContext, Entity, Window}; diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 7d8f7f2..3a96c03 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,3 +1,5 @@ mod repo_list; +mod sidebar; pub use repo_list::RepoListView; +pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index a51b2e0..1d6b6af 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -1,8 +1,9 @@ use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, Render, SharedString, Subscription, Window, div, px, - uniform_list, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, + Subscription, Window, div, px, uniform_list, }; +use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::{ActiveTheme, StyledExt}; use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore}; @@ -10,6 +11,7 @@ use signed_state::{ProfileStore, RepoListStore}; /// Browse all announced repositories (works anonymously). pub struct RepoListView { store: Entity, + focus_handle: FocusHandle, _subscription: Subscription, } @@ -20,11 +22,30 @@ impl RepoListView { Self { store, + focus_handle: cx.focus_handle(), _subscription: subscription, } } } +impl Panel for RepoListView { + fn panel_name(&self) -> &'static str { + "repo_list" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + "Explore" + } +} + +impl EventEmitter for RepoListView {} + +impl Focusable for RepoListView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + fn render_card(announcement: &Announcement, cx: &mut App) -> AnyElement { let name = announcement .name diff --git a/crates/workspace/src/views/sidebar.rs b/crates/workspace/src/views/sidebar.rs new file mode 100644 index 0000000..5c5a62a --- /dev/null +++ b/crates/workspace/src/views/sidebar.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use gpui::prelude::*; +use gpui::{App, Context, EventEmitter, FocusHandle, Focusable, Render, WeakEntity, Window}; +use gpui_component::button::Button; +use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; +use gpui_component::v_flex; + +use crate::views::RepoListView; + +/// Left-dock panel with navigation entries. Entries open content panels in +/// the dock area. +pub struct SidebarPanel { + focus_handle: FocusHandle, + dock_area: WeakEntity, + explore: Option>, +} + +impl SidebarPanel { + pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + dock_area, + explore: None, + } + } + + /// Open the Explore (repository list) panel in the center of the dock + /// area. No-op if it's already open. + pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { + if self.explore.as_ref().and_then(WeakEntity::upgrade).is_some() { + return; + } + + let panel = cx.new(|cx| RepoListView::new(window, cx)); + self.explore = Some(panel.downgrade()); + + self.dock_area + .update(cx, |dock_area, cx| { + dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); + }) + .ok(); + } +} + +impl Panel for SidebarPanel { + fn panel_name(&self) -> &'static str { + "sidebar" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + "Navigation" + } + + fn closable(&self, _cx: &App) -> bool { + false + } +} + +impl EventEmitter for SidebarPanel {} + +impl Focusable for SidebarPanel { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for SidebarPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex().gap_2().p_2().child( + Button::new("explore") + .label("Explore") + .w_full() + .on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))), + ) + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e8bbd42..333af3c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,13 +1,14 @@ use gpui::prelude::*; -use gpui::{AnyView, Context, Render, SharedString, Subscription, Window, div, px}; -use gpui_component::{ActiveTheme, StyledExt}; +use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; +use gpui_component::dock::{DockArea, DockItem}; +use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, v_flex}; use signed_state::{Backend, BackendEvent}; -use crate::views::RepoListView; +use crate::views::SidebarPanel; -/// Root view of the app: header, active screen, status bar. +/// Root view of the app: title bar, dock area, status bar. pub struct Workspace { - active_screen: AnyView, + dock: Entity, status: SharedString, _subscription: Subscription, } @@ -15,11 +16,34 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let backend = Backend::global(cx); - let repo_list = cx.new(|cx| RepoListView::new(window, cx)); + let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx)); + + let weak_dock = dock.downgrade(); + let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); + + dock.update(cx, |dock_area, cx| { + dock_area.set_left_dock( + DockItem::tab(sidebar.clone(), &weak_dock, window, cx), + Some(px(240.)), + true, + window, + cx, + ); + }); + + sidebar.update(cx, |sidebar, cx| sidebar.open_explore(window, cx)); let connected = backend.read(cx).is_connected(); let sync_progress = backend.read(cx).sync_progress(); + let status = if let Some((total, current)) = sync_progress { + format!("Syncing repositories... {current}/{total}").into() + } else if connected { + "Connected".into() + } else { + "Connecting...".into() + }; + let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { match event { BackendEvent::Connected => this.status = "Connected".into(), @@ -34,54 +58,48 @@ impl Workspace { }); Self { - active_screen: repo_list.into(), - status: if let Some((total, current)) = sync_progress { - format!("Syncing repositories... {current}/{total}").into() - } else if connected { - "Connected".into() - } else { - "Connecting...".into() - }, + dock, + status, _subscription: subscription, } } } impl Render for Workspace { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let dialog_layer = Root::render_dialog_layer(window, cx); + let notification_layer = Root::render_notification_layer(window, cx); + div() + .id("workspace") .v_flex() .size_full() - .bg(cx.theme().background) - .text_color(cx.theme().foreground) .child( - div() - .h_flex() - .px_4() - .py_2() - .border_b(px(1.)) - .border_color(cx.theme().border) - .child(div().text_lg().font_semibold().child("Signed")), - ) - .child( - div() - .flex_1() - .overflow_hidden() - .child(self.active_screen.clone()), - ) - .child( - div() - .h_flex() - .px_4() - .py_1() - .border_t(px(1.)) - .border_color(cx.theme().border) + v_flex() + .size_full() + // Title Bar + .child(TitleBar::new()) + // Dock Area + .child(self.dock.clone()) + // Status Bar .child( div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.status.clone()), + .h_flex() + .px_4() + .py_1() + .border_t(px(1.)) + .border_color(cx.theme().border) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.status.clone()), + ), ), ) + // Notifications + .children(notification_layer) + // Modals + .children(dialog_layer) } } -- 2.54.0 From a97dfac23fd9a0f1fbee517df76a114d8e879161 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 5 Aug 2026 17:07:50 +0700 Subject: [PATCH 07/64] . --- crates/signed_state/src/backend.rs | 26 ++++ crates/workspace/src/views/repo_list.rs | 163 +++++++++++++----------- crates/workspace/src/views/sidebar.rs | 89 ++++++++++--- crates/workspace/src/workspace.rs | 30 ++--- 4 files changed, 200 insertions(+), 108 deletions(-) diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index fa3e145..4b2c6d8 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -218,6 +218,32 @@ impl Backend { })); } + /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on + /// the credential's prefix. + pub fn login(&mut self, credential: &str, cx: &mut Context) { + let credential = credential.trim(); + + if credential.starts_with("nsec1") { + self.login_with_nsec(credential, cx); + } else if credential.starts_with("bunker://") { + self.login_with_bunker(credential, cx); + } else { + cx.emit(BackendEvent::error( + "Unsupported credential, expected nsec1... or bunker://...", + )); + } + } + + /// Create a fresh identity and login with it. The generated key is + /// persisted in the keyring like any other `nsec` credential. + pub fn login_with_new_identity(&mut self, cx: &mut Context) { + let nsec = Keys::generate() + .secret_key() + .to_bech32() + .expect("infallible"); + self.login_with_nsec(&nsec, cx); + } + /// Login with an `nsec1...` secret key. The credential is verified by /// the signer flow and persisted in the keyring. pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) { diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 1d6b6af..1e3f05e 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -26,6 +26,59 @@ impl RepoListView { _subscription: subscription, } } + + fn render_card(&self, announcement: &Announcement, cx: &mut App) -> AnyElement { + let name = announcement + .name + .clone() + .unwrap_or_else(|| announcement.id.clone()); + + let owner = ProfileStore::global(cx) + .update(cx, |store, cx| store.get(announcement.owner, cx)) + .name(); + + let description = announcement.description.clone().unwrap_or_default(); + + div() + .v_flex() + .h(px(60.)) + .w_full() + .justify_center() + .gap_1() + .px_4() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child( + div() + .h_flex() + .gap_2() + .items_center() + .child( + div() + .text_sm() + .font_semibold() + .whitespace_nowrap() + .text_ellipsis() + .child(name), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(owner), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .text_ellipsis() + .child(description), + ) + .into_any_element() + } } impl Panel for RepoListView { @@ -46,86 +99,12 @@ impl Focusable for RepoListView { } } -fn render_card(announcement: &Announcement, cx: &mut App) -> AnyElement { - let name = announcement - .name - .clone() - .unwrap_or_else(|| announcement.id.clone()); - - let owner = ProfileStore::global(cx) - .update(cx, |store, cx| store.get(announcement.owner, cx)) - .name(); - - let description = announcement.description.clone().unwrap_or_default(); - - div() - .v_flex() - .h(px(60.)) - .justify_center() - .gap_1() - .px_4() - .border_b(px(1.)) - .border_color(cx.theme().border) - .child( - div() - .h_flex() - .gap_2() - .items_center() - .child( - div() - .text_sm() - .font_semibold() - .whitespace_nowrap() - .text_ellipsis() - .child(name), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .child(owner), - ), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .text_ellipsis() - .child(description), - ) - .into_any_element() -} - impl Render for RepoListView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let announcements = self.store.read(cx).announcements.clone(); + let has_announcements = !announcements.is_empty(); let count = announcements.len(); - let body = if announcements.is_empty() { - div() - .size_full() - .v_flex() - .items_center() - .justify_center() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("No repositories found. Waiting for relays..."), - ) - .into_any_element() - } else { - uniform_list("repo-list", count, move |range, _window, cx| { - range - .map(|index| render_card(&announcements[index], cx)) - .collect() - }) - .size_full() - .into_any_element() - }; - div() .v_flex() .size_full() @@ -143,6 +122,38 @@ impl Render for RepoListView { .child(SharedString::from(format!(" ({count})"))), ), ) - .child(body) + .when(!has_announcements, |this| { + this.child( + div() + .size_full() + .v_flex() + .items_center() + .justify_center() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No repositories found. Waiting for relays..."), + ), + ) + }) + .when(has_announcements, |this| { + this.child( + uniform_list( + "repos", + count, + cx.processor(move |this, range, _window, cx| { + let mut items = vec![]; + + for ix in range { + items.push(this.render_card(&announcements[ix], cx)); + } + + items + }), + ) + .size_full(), + ) + }) } } diff --git a/crates/workspace/src/views/sidebar.rs b/crates/workspace/src/views/sidebar.rs index 5c5a62a..235dd8f 100644 --- a/crates/workspace/src/views/sidebar.rs +++ b/crates/workspace/src/views/sidebar.rs @@ -1,10 +1,14 @@ use std::sync::Arc; use gpui::prelude::*; -use gpui::{App, Context, EventEmitter, FocusHandle, Focusable, Render, WeakEntity, Window}; -use gpui_component::button::Button; +use gpui::{ + App, Context, EventEmitter, FocusHandle, Focusable, Render, Subscription, WeakEntity, Window, + div, +}; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; -use gpui_component::v_flex; +use gpui_component::{ActiveTheme, v_flex}; +use signed_state::{Backend, BackendEvent}; use crate::views::RepoListView; @@ -14,32 +18,55 @@ pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, explore: Option>, + logged_in: bool, + _subscription: Subscription, } impl SidebarPanel { pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { + let backend = Backend::global(cx); + let logged_in = backend.read(cx).current_user().is_some(); + + let subscription = cx.subscribe(&backend, |this, backend, event, cx| { + match event { + BackendEvent::SignerChanged => { + this.logged_in = backend.read(cx).current_user().is_some(); + } + BackendEvent::SignerRequired => { + this.logged_in = false; + } + _ => return, + } + cx.notify(); + }); + Self { focus_handle: cx.focus_handle(), dock_area, explore: None, + logged_in, + _subscription: subscription, } } /// Open the Explore (repository list) panel in the center of the dock /// area. No-op if it's already open. pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { - if self.explore.as_ref().and_then(WeakEntity::upgrade).is_some() { + if self + .explore + .as_ref() + .and_then(WeakEntity::upgrade) + .is_some() + { return; } let panel = cx.new(|cx| RepoListView::new(window, cx)); self.explore = Some(panel.downgrade()); - self.dock_area - .update(cx, |dock_area, cx| { - dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); - }) - .ok(); + let _ = self.dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); + }); } } @@ -49,12 +76,16 @@ impl Panel for SidebarPanel { } fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - "Navigation" + div() } fn closable(&self, _cx: &App) -> bool { false } + + fn inner_padding(&self, _cx: &App) -> bool { + false + } } impl EventEmitter for SidebarPanel {} @@ -67,11 +98,37 @@ impl Focusable for SidebarPanel { impl Render for SidebarPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex().gap_2().p_2().child( - Button::new("explore") - .label("Explore") - .w_full() - .on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))), - ) + if self.logged_in { + v_flex().child( + Button::new("explore") + .label("Explore") + .w_full() + .on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))), + ) + } else { + v_flex() + .p_4() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Sign in to continue"), + ) + .child( + Button::new("get-started") + .label("Get started") + .primary() + .w_full(), + ) + .child( + Button::new("import-identity") + .label("Import identity") + .w_full(), + ) + } } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 333af3c..25cdcac 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,7 +1,7 @@ use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; use gpui_component::dock::{DockArea, DockItem}; -use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, v_flex}; +use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex}; use signed_state::{Backend, BackendEvent}; use crate::views::SidebarPanel; @@ -78,24 +78,22 @@ impl Render for Workspace { v_flex() .size_full() // Title Bar - .child(TitleBar::new()) - // Dock Area - .child(self.dock.clone()) - // Status Bar .child( - div() - .h_flex() - .px_4() - .py_1() - .border_t(px(1.)) - .border_color(cx.theme().border) + TitleBar::new() + // Left + .child(div()) + // Right .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.status.clone()), + h_flex().px_2().child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.status.clone()), + ), ), - ), + ) + // Dock Area + .child(self.dock.clone()), ) // Notifications .children(notification_layer) -- 2.54.0 From 627abbdcafb91210404e03c9d88531bb794d95dd Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 6 Aug 2026 14:02:16 +0700 Subject: [PATCH 08/64] add create new identity --- crates/signed_core/src/builders.rs | 29 +++++ crates/signed_core/src/lib.rs | 1 + crates/signed_state/src/backend.rs | 126 ++++++++++++++++++- crates/workspace/src/views/sidebar.rs | 172 ++++++++++++++++++++++++-- crates/workspace/src/workspace.rs | 6 +- desktop/src/main.rs | 2 +- 6 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 crates/signed_core/src/builders.rs diff --git a/crates/signed_core/src/builders.rs b/crates/signed_core/src/builders.rs new file mode 100644 index 0000000..5650448 --- /dev/null +++ b/crates/signed_core/src/builders.rs @@ -0,0 +1,29 @@ +use nostr::prelude::*; + +/// Build a NIP-34 user grasp list (kind `10317`). +pub fn grasp_list(grasp_servers: Vec) -> EventBuilder { + GitUserGraspList { grasp_servers }.into_event_builder() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grasp_list_tags() { + let servers = vec![ + RelayUrl::parse("wss://gitnostr.com").unwrap(), + RelayUrl::parse("wss://relay.ngit.dev").unwrap(), + ]; + + let builder = grasp_list(servers); + + let urls: Vec<&str> = builder + .tags + .iter() + .filter_map(|t| t.content()) + .collect(); + + assert_eq!(urls, vec!["wss://gitnostr.com", "wss://relay.ngit.dev"]); + } +} diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index f4958fd..e103493 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -1,4 +1,5 @@ pub mod addr; +pub mod builders; pub mod clone_url; pub mod filters; pub mod model; diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 4b2c6d8..f6121d7 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -6,7 +6,7 @@ use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::filters; +use signed_core::{builders, filters}; use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update}; /// Keyring entry holding the user credential (`nsec1...` or `bunker://...` @@ -199,6 +199,11 @@ impl Backend { )?; signer.auth_url_handler(SignedAuthUrlHandler); this.update(cx, |this, cx| this.set_signer(signer, cx))?; + } else if content.starts_with("ncryptsec1") { + // Encrypted identity: a passphrase is required to + // decrypt it, which is not implemented yet. + log::warn!("stored identity is ncryptsec-encrypted; passphrase restore is not implemented"); + this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; } else { this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; } @@ -218,6 +223,115 @@ impl Backend { })); } + /// Create a new identity: generate keys, encrypt the secret key with the + /// passphrase (NIP-49) and persist it in the keyring, then publish the + /// user's NIP-65 relay list, metadata and grasp list. + /// + /// The heavy encryption runs off the UI thread. The returned receiver + /// yields the new public key on success, or the failure reason, so + /// callers can render progress and inline errors. + pub fn create_identity( + &mut self, + name: &str, + password: &str, + cx: &mut Context, + ) -> flume::Receiver> { + let (tx, rx) = flume::bounded(1); + + let name = name.trim().to_owned(); + let password = password.to_owned(); + + let validation_error = if name.is_empty() || name.len() > 255 { + Some("Name must be 1-255 characters") + } else if password.is_empty() { + Some("Passphrase must not be empty") + } else { + None + }; + + if let Some(message) = validation_error { + tx.try_send(Err(anyhow!(message))).ok(); + return rx; + } + + let job = cx.background_spawn(async move { + let keys = Keys::generate(); + let encrypted = + EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?; + let ncryptsec = encrypted.to_bech32()?; + Ok::<_, Error>((keys, ncryptsec)) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = async { + let (keys, ncryptsec) = job.await?; + let public_key = keys.public_key(); + + // Persist the encrypted credential. + let write = cx.update(|cx| { + cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes()) + }); + write.await?; + + this.update(cx, |this, cx| { + // Become the new identity, so the publishes below are + // signed with the new keys. + this.inner.signer().swap_inner(keys); + this.current_user = Some(public_key); + this.bootstrap_user(public_key, cx); + cx.emit(BackendEvent::SignerChanged); + cx.notify(); + + let relays: Vec<(RelayUrl, Option)> = [ + ( + RelayUrl::parse("wss://relay.primal.net").unwrap(), + Some(RelayMetadata::Read), + ), + ( + RelayUrl::parse("wss://relay.ditto.pub").unwrap(), + Some(RelayMetadata::Read), + ), + ( + RelayUrl::parse("wss://relay.nostr.net").unwrap(), + Some(RelayMetadata::Write), + ), + ( + RelayUrl::parse("wss://nos.lol").unwrap(), + Some(RelayMetadata::Write), + ), + ] + .to_vec(); + + this.send(RelayList::new(relays).into_event_builder(), cx); + + let metadata = Metadata::new() + .name(&name) + .display_name(&name) + .into_event_builder(); + + this.send(metadata, cx); + + let grasp_servers: Vec = + ["wss://gitnostr.com", "wss://relay.ngit.dev"] + .into_iter() + .map(|url| RelayUrl::parse(url).expect("valid relay URL")) + .collect(); + + this.send(builders::grasp_list(grasp_servers), cx); + })?; + + Ok(public_key) + } + .await; + + tx.send_async(result).await.ok(); + + Ok(()) + })); + + rx + } + /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on /// the credential's prefix. pub fn login(&mut self, credential: &str, cx: &mut Context) { @@ -394,6 +508,11 @@ impl Backend { self.current_user } + /// Surface an error message through [`BackendEvent::Error`]. + pub fn emit_error(&mut self, message: impl Into, cx: &mut Context) { + cx.emit(BackendEvent::error(message)); + } + /// Whether the relay bootstrap has completed. pub fn is_connected(&self) -> bool { self.connected @@ -602,7 +721,6 @@ impl Backend { cx: &mut Context, ) -> flume::Receiver> { let (tx, rx) = flume::bounded(1); - let backend = self.inner.clone(); let task = cx.background_spawn(async move { backend.send(builder).await }); @@ -616,7 +734,9 @@ impl Backend { })?; } Err(e) => { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); + })?; } } diff --git a/crates/workspace/src/views/sidebar.rs b/crates/workspace/src/views/sidebar.rs index 235dd8f..1734e40 100644 --- a/crates/workspace/src/views/sidebar.rs +++ b/crates/workspace/src/views/sidebar.rs @@ -2,16 +2,26 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - App, Context, EventEmitter, FocusHandle, Focusable, Render, Subscription, WeakEntity, Window, - div, + App, Context, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription, + WeakEntity, Window, div, px, }; use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; -use gpui_component::{ActiveTheme, v_flex}; +use gpui_component::form::{field, v_form}; +use gpui_component::input::{Input, InputState}; +use gpui_component::{ActiveTheme, Disableable, WindowExt, v_flex}; use signed_state::{Backend, BackendEvent}; use crate::views::RepoListView; +/// Shared state for the Join Now dialog, so async results can be rendered. +#[derive(Default)] +struct JoinNowState { + busy: bool, + error: Option, +} + /// Left-dock panel with navigation entries. Entries open content panels in /// the dock area. pub struct SidebarPanel { @@ -68,6 +78,145 @@ impl SidebarPanel { dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); }); } + + /// Show the Join Now dialog. + fn open_join_now(&mut self, window: &mut Window, cx: &mut Context) { + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name")); + let pass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Passphrase to protect your keys") + .masked(true) + }); + let repass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Repeat passphrase") + .masked(true) + }); + let state = cx.new(|_| JoinNowState::default()); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let name_input = name_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + dialog + .width(px(520.)) + .margin_top(px(50.)) + .content(move |content, _window, cx| { + let busy = state.read(cx).busy; + let error = state.read(cx).error.clone(); + + content + .child( + DialogHeader::new() + .child(DialogTitle::new().child("Create identity")) + .child( + DialogDescription::new() + .child("Set up your Signed identity to get started."), + ), + ) + .child( + v_form() + .child( + field() + .label("Name") + .description("Max 255 characters") + .required(true) + .child(Input::new(&name_input)), + ) + .child( + field() + .label("Passphrase") + .required(true) + .child(Input::new(&pass_input)), + ) + .child(field().required(true).child(Input::new(&repass_input))), + ) + .children(error.map(|message| { + div().text_sm().text_color(cx.theme().danger).child(message) + })) + .child( + DialogFooter::new().justify_end().child( + Button::new("continue") + .primary() + .label("Create new identity") + .tooltip("Create identity") + .loading(busy) + .disabled(busy) + .on_click({ + let name_input = name_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + move |_ev, window, cx| { + let name = name_input.read(cx).value().to_string(); + let pass = pass_input.read(cx).value().to_string(); + let repass = repass_input.read(cx).value().to_string(); + + if pass != repass { + state.update(cx, |state, _| { + state.busy = false; + state.error = + Some("Passphrases do not match".into()); + }); + return; + } + + state.update(cx, |state, _| { + state.busy = true; + state.error = None; + }); + + let rx = + Backend::global(cx).update(cx, |backend, cx| { + backend.create_identity(&name, &pass, cx) + }); + + let window_handle = window.window_handle(); + let state = state.clone(); + + cx.spawn(async move |cx| match rx.recv_async().await { + Ok(Ok(_)) => { + cx.update_window( + window_handle, + |_, window, cx| { + window.close_dialog(cx); + }, + ) + .ok(); + } + Ok(Err(e)) => { + cx.update_window( + window_handle, + |_, _window, cx| { + state.update(cx, |state, _| { + state.busy = false; + state.error = + Some(e.to_string().into()); + }); + }, + ) + .ok(); + } + Err(_) => {} + }) + .detach(); + } + }), + ), + ) + }) + }); + } + + /// Show the Import Identity dialog. + fn open_import(&mut self, window: &mut Window, cx: &mut Context) { + window.open_dialog(cx, move |dialog, _window, _cx| { + dialog.title("Import identity").width(px(400.)) + }); + } } impl Panel for SidebarPanel { @@ -114,20 +263,27 @@ impl Render for SidebarPanel { .gap_2() .child( div() - .text_xs() + .text_sm() .text_color(cx.theme().muted_foreground) .child("Sign in to continue"), ) .child( - Button::new("get-started") - .label("Get started") + Button::new("join") + .label("Join Now") .primary() - .w_full(), + .w_full() + .on_click( + cx.listener(|this, _ev, window, cx| this.open_join_now(window, cx)), + ), ) .child( Button::new("import-identity") .label("Import identity") - .w_full(), + .secondary() + .w_full() + .on_click( + cx.listener(|this, _ev, window, cx| this.open_import(window, cx)), + ), ) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 25cdcac..0dae153 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; use gpui_component::dock::{DockArea, DockItem}; @@ -23,8 +25,8 @@ impl Workspace { dock.update(cx, |dock_area, cx| { dock_area.set_left_dock( - DockItem::tab(sidebar.clone(), &weak_dock, window, cx), - Some(px(240.)), + DockItem::panel(Arc::new(sidebar.clone())), + Some(px(260.)), true, window, cx, diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 8f3bb5c..61c93bf 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -17,7 +17,7 @@ fn main() { signed_state::init(paths::nostr_dir(), cx); // Set up the window bounds - let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx); + let bounds = Bounds::centered(None, size(px(980.0), px(740.0)), cx); // Set up the window options let opts = WindowOptions { -- 2.54.0 From 6d5d1544862db92676e997625e92fe17d2b0e88c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 6 Aug 2026 14:21:03 +0700 Subject: [PATCH 09/64] update sidebar --- crates/workspace/src/views/sidebar.rs | 290 ------------------ .../views/sidebar/import_identity_dialog.rs | 11 + crates/workspace/src/views/sidebar/mod.rs | 170 ++++++++++ .../src/views/sidebar/onboarding_dialog.rs | 138 +++++++++ 4 files changed, 319 insertions(+), 290 deletions(-) delete mode 100644 crates/workspace/src/views/sidebar.rs create mode 100644 crates/workspace/src/views/sidebar/import_identity_dialog.rs create mode 100644 crates/workspace/src/views/sidebar/mod.rs create mode 100644 crates/workspace/src/views/sidebar/onboarding_dialog.rs diff --git a/crates/workspace/src/views/sidebar.rs b/crates/workspace/src/views/sidebar.rs deleted file mode 100644 index 1734e40..0000000 --- a/crates/workspace/src/views/sidebar.rs +++ /dev/null @@ -1,290 +0,0 @@ -use std::sync::Arc; - -use gpui::prelude::*; -use gpui::{ - App, Context, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription, - WeakEntity, Window, div, px, -}; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; -use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; -use gpui_component::form::{field, v_form}; -use gpui_component::input::{Input, InputState}; -use gpui_component::{ActiveTheme, Disableable, WindowExt, v_flex}; -use signed_state::{Backend, BackendEvent}; - -use crate::views::RepoListView; - -/// Shared state for the Join Now dialog, so async results can be rendered. -#[derive(Default)] -struct JoinNowState { - busy: bool, - error: Option, -} - -/// Left-dock panel with navigation entries. Entries open content panels in -/// the dock area. -pub struct SidebarPanel { - focus_handle: FocusHandle, - dock_area: WeakEntity, - explore: Option>, - logged_in: bool, - _subscription: Subscription, -} - -impl SidebarPanel { - pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { - let backend = Backend::global(cx); - let logged_in = backend.read(cx).current_user().is_some(); - - let subscription = cx.subscribe(&backend, |this, backend, event, cx| { - match event { - BackendEvent::SignerChanged => { - this.logged_in = backend.read(cx).current_user().is_some(); - } - BackendEvent::SignerRequired => { - this.logged_in = false; - } - _ => return, - } - cx.notify(); - }); - - Self { - focus_handle: cx.focus_handle(), - dock_area, - explore: None, - logged_in, - _subscription: subscription, - } - } - - /// Open the Explore (repository list) panel in the center of the dock - /// area. No-op if it's already open. - pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { - if self - .explore - .as_ref() - .and_then(WeakEntity::upgrade) - .is_some() - { - return; - } - - let panel = cx.new(|cx| RepoListView::new(window, cx)); - self.explore = Some(panel.downgrade()); - - let _ = self.dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); - }); - } - - /// Show the Join Now dialog. - fn open_join_now(&mut self, window: &mut Window, cx: &mut Context) { - let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name")); - let pass_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Passphrase to protect your keys") - .masked(true) - }); - let repass_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Repeat passphrase") - .masked(true) - }); - let state = cx.new(|_| JoinNowState::default()); - - window.open_dialog(cx, move |dialog, _window, _cx| { - let name_input = name_input.clone(); - let pass_input = pass_input.clone(); - let repass_input = repass_input.clone(); - let state = state.clone(); - - dialog - .width(px(520.)) - .margin_top(px(50.)) - .content(move |content, _window, cx| { - let busy = state.read(cx).busy; - let error = state.read(cx).error.clone(); - - content - .child( - DialogHeader::new() - .child(DialogTitle::new().child("Create identity")) - .child( - DialogDescription::new() - .child("Set up your Signed identity to get started."), - ), - ) - .child( - v_form() - .child( - field() - .label("Name") - .description("Max 255 characters") - .required(true) - .child(Input::new(&name_input)), - ) - .child( - field() - .label("Passphrase") - .required(true) - .child(Input::new(&pass_input)), - ) - .child(field().required(true).child(Input::new(&repass_input))), - ) - .children(error.map(|message| { - div().text_sm().text_color(cx.theme().danger).child(message) - })) - .child( - DialogFooter::new().justify_end().child( - Button::new("continue") - .primary() - .label("Create new identity") - .tooltip("Create identity") - .loading(busy) - .disabled(busy) - .on_click({ - let name_input = name_input.clone(); - let pass_input = pass_input.clone(); - let repass_input = repass_input.clone(); - let state = state.clone(); - - move |_ev, window, cx| { - let name = name_input.read(cx).value().to_string(); - let pass = pass_input.read(cx).value().to_string(); - let repass = repass_input.read(cx).value().to_string(); - - if pass != repass { - state.update(cx, |state, _| { - state.busy = false; - state.error = - Some("Passphrases do not match".into()); - }); - return; - } - - state.update(cx, |state, _| { - state.busy = true; - state.error = None; - }); - - let rx = - Backend::global(cx).update(cx, |backend, cx| { - backend.create_identity(&name, &pass, cx) - }); - - let window_handle = window.window_handle(); - let state = state.clone(); - - cx.spawn(async move |cx| match rx.recv_async().await { - Ok(Ok(_)) => { - cx.update_window( - window_handle, - |_, window, cx| { - window.close_dialog(cx); - }, - ) - .ok(); - } - Ok(Err(e)) => { - cx.update_window( - window_handle, - |_, _window, cx| { - state.update(cx, |state, _| { - state.busy = false; - state.error = - Some(e.to_string().into()); - }); - }, - ) - .ok(); - } - Err(_) => {} - }) - .detach(); - } - }), - ), - ) - }) - }); - } - - /// Show the Import Identity dialog. - fn open_import(&mut self, window: &mut Window, cx: &mut Context) { - window.open_dialog(cx, move |dialog, _window, _cx| { - dialog.title("Import identity").width(px(400.)) - }); - } -} - -impl Panel for SidebarPanel { - fn panel_name(&self) -> &'static str { - "sidebar" - } - - fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - } - - fn closable(&self, _cx: &App) -> bool { - false - } - - fn inner_padding(&self, _cx: &App) -> bool { - false - } -} - -impl EventEmitter for SidebarPanel {} - -impl Focusable for SidebarPanel { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for SidebarPanel { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.logged_in { - v_flex().child( - Button::new("explore") - .label("Explore") - .w_full() - .on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))), - ) - } else { - v_flex() - .p_4() - .size_full() - .items_center() - .justify_center() - .gap_2() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Sign in to continue"), - ) - .child( - Button::new("join") - .label("Join Now") - .primary() - .w_full() - .on_click( - cx.listener(|this, _ev, window, cx| this.open_join_now(window, cx)), - ), - ) - .child( - Button::new("import-identity") - .label("Import identity") - .secondary() - .w_full() - .on_click( - cx.listener(|this, _ev, window, cx| this.open_import(window, cx)), - ), - ) - } - } -} diff --git a/crates/workspace/src/views/sidebar/import_identity_dialog.rs b/crates/workspace/src/views/sidebar/import_identity_dialog.rs new file mode 100644 index 0000000..cfe8c37 --- /dev/null +++ b/crates/workspace/src/views/sidebar/import_identity_dialog.rs @@ -0,0 +1,11 @@ +use gpui::{App, Window, px}; +use gpui_component::WindowExt; + +/// Open the Import Identity dialog. +/// +/// Currently a placeholder — the dialog only shows a title for now. +pub fn open(window: &mut Window, cx: &mut App) { + window.open_dialog(cx, move |dialog, _window, _cx| { + dialog.title("Import identity").width(px(400.)) + }); +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs new file mode 100644 index 0000000..b0b276d --- /dev/null +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -0,0 +1,170 @@ +use std::sync::Arc; + +use gpui::prelude::*; +use gpui::{ + App, Context, EventEmitter, FocusHandle, Focusable, Render, Subscription, WeakEntity, Window, + div, +}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; +use gpui_component::input::InputState; +use gpui_component::{ActiveTheme, v_flex}; +use signed_state::{Backend, BackendEvent}; + +use super::RepoListView; + +mod import_identity_dialog; +mod onboarding_dialog; + +use self::onboarding_dialog::OnboardingState; + +/// Left-dock panel with navigation entries. Entries open content panels in +/// the dock area. +pub struct SidebarPanel { + focus_handle: FocusHandle, + dock_area: WeakEntity, + explore: Option>, + logged_in: bool, + _subscription: Subscription, +} + +impl SidebarPanel { + pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { + let backend = Backend::global(cx); + let logged_in = backend.read(cx).current_user().is_some(); + + let subscription = cx.subscribe(&backend, |this, backend, event, cx| { + match event { + BackendEvent::SignerChanged => { + this.logged_in = backend.read(cx).current_user().is_some(); + } + BackendEvent::SignerRequired => { + this.logged_in = false; + } + _ => return, + } + cx.notify(); + }); + + Self { + focus_handle: cx.focus_handle(), + dock_area, + explore: None, + logged_in, + _subscription: subscription, + } + } + + /// Open the Explore (repository list) panel in the center of the dock + /// area. No-op if it's already open. + pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { + if self + .explore + .as_ref() + .and_then(WeakEntity::upgrade) + .is_some() + { + return; + } + + let panel = cx.new(|cx| RepoListView::new(window, cx)); + self.explore = Some(panel.downgrade()); + + let _ = self.dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); + }); + } + + /// Show the Onboarding dialog. + fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context) { + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name")); + let pass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Passphrase to protect your keys") + .masked(true) + }); + let repass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Repeat passphrase") + .masked(true) + }); + let state = cx.new(|_| OnboardingState::default()); + + onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx); + } + + /// Show the Import Identity dialog. + fn open_import(&mut self, window: &mut Window, cx: &mut Context) { + import_identity_dialog::open(window, cx); + } +} + +impl Panel for SidebarPanel { + fn panel_name(&self) -> &'static str { + "sidebar" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + } + + fn closable(&self, _cx: &App) -> bool { + false + } + + fn inner_padding(&self, _cx: &App) -> bool { + false + } +} + +impl EventEmitter for SidebarPanel {} + +impl Focusable for SidebarPanel { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for SidebarPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.logged_in { + v_flex().child( + Button::new("explore") + .label("Explore") + .w_full() + .on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))), + ) + } else { + v_flex() + .p_4() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Sign in to continue"), + ) + .child( + Button::new("onboarding") + .label("Join now") + .primary() + .w_full() + .on_click( + cx.listener(|this, _ev, window, cx| this.open_onboarding(window, cx)), + ), + ) + .child( + Button::new("import-identity") + .label("Import identity") + .secondary() + .w_full() + .on_click( + cx.listener(|this, _ev, window, cx| this.open_import(window, cx)), + ), + ) + } + } +} diff --git a/crates/workspace/src/views/sidebar/onboarding_dialog.rs b/crates/workspace/src/views/sidebar/onboarding_dialog.rs new file mode 100644 index 0000000..be9f5bb --- /dev/null +++ b/crates/workspace/src/views/sidebar/onboarding_dialog.rs @@ -0,0 +1,138 @@ +use gpui::prelude::*; +use gpui::{App, Entity, SharedString, Window, div, px}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; +use gpui_component::form::{field, v_form}; +use gpui_component::input::{Input, InputState}; +use gpui_component::{ActiveTheme, Disableable, WindowExt}; +use signed_state::Backend; + +/// Shared state for the Onboarding dialog, so async results can be rendered. +#[derive(Default)] +pub struct OnboardingState { + pub busy: bool, + pub error: Option, +} + +/// Open the Onboarding dialog for creating a new identity. +/// +/// The caller is responsible for creating the input and state entities and +/// passing them in. This function only builds the dialog UI and wires up +/// the continue-button handler. +pub fn open( + name_input: Entity, + pass_input: Entity, + repass_input: Entity, + state: Entity, + window: &mut Window, + cx: &mut App, +) { + window.open_dialog(cx, move |dialog, _window, _cx| { + let name_input = name_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + dialog + .width(px(520.)) + .margin_top(px(50.)) + .content(move |content, _window, cx| { + let busy = state.read(cx).busy; + let error = state.read(cx).error.clone(); + + content + .child( + DialogHeader::new() + .child(DialogTitle::new().child("Create identity")) + .child( + DialogDescription::new() + .child("Set up your Signed identity to get started."), + ), + ) + .child( + v_form() + .child( + field() + .label("Name") + .description("Max 255 characters") + .required(true) + .child(Input::new(&name_input)), + ) + .child( + field() + .label("Passphrase") + .required(true) + .child(Input::new(&pass_input)), + ) + .child(field().required(true).child(Input::new(&repass_input))), + ) + .children(error.map(|message| { + div().text_sm().text_color(cx.theme().danger).child(message) + })) + .child( + DialogFooter::new().justify_end().child( + Button::new("continue") + .primary() + .label("Create new identity") + .tooltip("Create identity") + .loading(busy) + .disabled(busy) + .on_click({ + let name_input = name_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + move |_ev, window, cx| { + let backend = Backend::global(cx); + let name = name_input.read(cx).value().to_string(); + let pass = pass_input.read(cx).value().to_string(); + let repass = repass_input.read(cx).value().to_string(); + + if pass != repass { + state.update(cx, |state, _| { + state.busy = false; + state.error = + Some("Passphrases do not match".into()); + }); + return; + } + + state.update(cx, |state, _| { + state.busy = true; + state.error = None; + }); + + let rx = backend.update(cx, |backend, cx| { + backend.create_identity(&name, &pass, cx) + }); + + let handle = window.window_handle(); + let state = state.clone(); + + cx.spawn(async move |cx| match rx.recv_async().await { + Ok(Ok(_)) => { + cx.update_window(handle, |_, window, cx| { + window.close_dialog(cx); + }) + .ok(); + } + Ok(Err(e)) => { + cx.update_window(handle, |_, _window, cx| { + state.update(cx, |state, _| { + state.busy = false; + state.error = Some(e.to_string().into()); + }); + }) + .ok(); + } + Err(_) => {} + }) + .detach(); + } + }), + ), + ) + }) + }); +} -- 2.54.0 From 0c6d7003956f9249c31de41ac9c62d9f6b57c886 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 6 Aug 2026 14:52:43 +0700 Subject: [PATCH 10/64] refactor --- crates/signed_nostr/src/backend.rs | 5 ----- crates/signed_nostr/src/update.rs | 9 ++------- crates/signed_state/src/repo.rs | 27 ++++++++++++++++----------- crates/signed_state/src/repo_list.rs | 9 +++++---- 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index 1a11220..04bf083 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -104,13 +104,8 @@ impl NostrBackend { /// event is immediately visible to [`NostrBackend::query`]. pub async fn send(&self, builder: EventBuilder) -> Result { let event = builder.finalize_async(&self.signer).await?; - let output = self.client.send_event(&event).await?; - // Keep our own events in the local database; the notification pump - // only fires for events received from relays. - self.client.database().save_event(&event).await?; - if output.success.is_empty() && !output.failed.is_empty() { let reasons = output .failed diff --git a/crates/signed_nostr/src/update.rs b/crates/signed_nostr/src/update.rs index 1f2222f..1b3f1de 100644 --- a/crates/signed_nostr/src/update.rs +++ b/crates/signed_nostr/src/update.rs @@ -7,7 +7,7 @@ use nostr_sdk::prelude::*; pub struct Update { pub kind: Kind, /// First `a` tag value of the event, if any (e.g. the repository coordinate). - pub coordinate: Option, + pub coordinate: Option, pub author: PublicKey, pub event_id: EventId, } @@ -15,12 +15,7 @@ pub struct Update { impl Update { /// Build an update from a received event. pub fn from_event(event: &Event) -> Self { - let coordinate = event - .tags - .iter() - .find(|t| t.kind() == "a") - .and_then(|t| t.content()) - .map(str::to_owned); + let coordinate = event.tags.coordinates().nth(0); Self { kind: event.kind, diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 43de9ef..c98fce6 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -9,7 +9,6 @@ use crate::backend::{Backend, BackendEvent}; /// their resolved statuses. Always derived from the local database. pub struct RepoStore { addr: RepoAddr, - addr_string: String, pub announcement: Option, /// `(refname, commit-id)` pairs from the latest state announcement. pub refs: Vec<(String, String)>, @@ -29,20 +28,27 @@ pub struct RepoStore { impl RepoStore { pub fn new(addr: RepoAddr, cx: &mut Context) -> Self { - let addr_string = addr.to_string(); + let backend = Backend::global(cx); - let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| { + let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { BackendEvent::NostrUpdate(update) => { - update.coordinate.as_deref() == Some(this.addr_string.as_str()) - || (update.kind == Kind::GitRepoAnnouncement - && update.author == this.addr.owner) + let coordinate = update.coordinate.as_ref() == Some(&this.addr.coordinate()); + let author = update.author == this.addr.owner; + let kind = update.kind == Kind::GitRepoAnnouncement; + + coordinate || (author && kind) } BackendEvent::Published(event) => { - event.kind == Kind::GitRepoAnnouncement && event.pubkey == this.addr.owner - || event.tags.iter().any(|t| { - t.kind() == "a" && t.content() == Some(this.addr_string.as_str()) - }) + let kind = event.kind == Kind::GitRepoAnnouncement; + let author = event.pubkey == this.addr.owner; + let coordinate = event + .tags + .coordinates() + .into_iter() + .any(|c| c == this.addr.coordinate()); + + coordinate || (kind && author) } _ => false, }; @@ -54,7 +60,6 @@ impl RepoStore { let mut store = Self { addr, - addr_string, announcement: None, refs: Vec::new(), head: None, diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index b0fdd4b..0b61c14 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -21,15 +21,16 @@ impl RepoListStore { /// Create a store. If `author` is `None`, all announcements are listed. pub fn new(author: Option, cx: &mut Context) -> Self { let backend = Backend::global(cx); + let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { + let git_kind = Kind::GitRepoAnnouncement; + let relevant = match event { BackendEvent::NostrUpdate(update) => { - update.kind == Kind::GitRepoAnnouncement - && this.author.is_none_or(|a| a == update.author) + update.kind == git_kind && this.author.is_none_or(|a| a == update.author) } BackendEvent::Published(event) => { - event.kind == Kind::GitRepoAnnouncement - && this.author.is_none_or(|a| a == event.pubkey) + event.kind == git_kind && this.author.is_none_or(|a| a == event.pubkey) } BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, _ => false, -- 2.54.0 From 640549a2c553e512bc562f9a732a54b4ad19f892 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 6 Aug 2026 16:00:00 +0700 Subject: [PATCH 11/64] refactor --- crates/signed_core/src/addr.rs | 49 ++----- crates/signed_core/src/builders.rs | 29 ---- crates/signed_core/src/clone_url.rs | 5 +- crates/signed_core/src/filters.rs | 13 +- crates/signed_core/src/lib.rs | 3 +- crates/signed_core/src/model.rs | 37 +++-- crates/signed_core/src/status.rs | 6 +- crates/signed_git/src/lib.rs | 63 ++++++++- crates/signed_nostr/src/backend.rs | 147 ++++++-------------- crates/signed_nostr/src/lib.rs | 2 +- crates/signed_nostr/src/signer.rs | 2 +- crates/signed_state/src/backend.rs | 103 ++++++++------ crates/signed_state/src/lib.rs | 12 +- crates/signed_state/src/profile.rs | 86 ++++++++---- crates/signed_state/src/repo.rs | 193 ++++++++++++++------------- crates/signed_state/src/repo_list.rs | 114 ++++++++-------- 16 files changed, 426 insertions(+), 438 deletions(-) delete mode 100644 crates/signed_core/src/builders.rs diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index 79df08b..0a7bc51 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -1,44 +1,13 @@ -use std::fmt; -use std::str::FromStr; - use nostr::prelude::*; /// Address of a NIP-34 repository announcement: `30617::`. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct RepoAddr { - pub owner: PublicKey, - pub id: String, -} - -impl RepoAddr { - pub fn new(owner: PublicKey, id: impl Into) -> Self { - Self { - owner, - id: id.into(), - } - } - - /// The NIP-33 coordinate for the announcement event (`a` tag value). - pub fn coordinate(&self) -> Coordinate { - Coordinate::new(Kind::GitRepoAnnouncement, self.owner).identifier(self.id.clone()) - } -} - -impl fmt::Display for RepoAddr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.coordinate()) - } -} - -impl FromStr for RepoAddr { - type Err = nostr::error::Error; - - /// Parse from `::`, `naddr1...` bech32 or `nostr:naddr1...` URI. - fn from_str(s: &str) -> Result { - let coordinate = Coordinate::parse(s)?; - Ok(Self { - owner: coordinate.public_key, - id: coordinate.identifier, - }) - } +/// +/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting +/// and hashing for this; the alias keeps the repository-specific vocabulary +/// while reusing the SDK type. +pub type RepoAddr = Coordinate; + +/// Build the address of a NIP-34 repository announcement. +pub fn repo_addr(owner: PublicKey, id: impl Into) -> RepoAddr { + Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id) } diff --git a/crates/signed_core/src/builders.rs b/crates/signed_core/src/builders.rs deleted file mode 100644 index 5650448..0000000 --- a/crates/signed_core/src/builders.rs +++ /dev/null @@ -1,29 +0,0 @@ -use nostr::prelude::*; - -/// Build a NIP-34 user grasp list (kind `10317`). -pub fn grasp_list(grasp_servers: Vec) -> EventBuilder { - GitUserGraspList { grasp_servers }.into_event_builder() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn grasp_list_tags() { - let servers = vec![ - RelayUrl::parse("wss://gitnostr.com").unwrap(), - RelayUrl::parse("wss://relay.ngit.dev").unwrap(), - ]; - - let builder = grasp_list(servers); - - let urls: Vec<&str> = builder - .tags - .iter() - .filter_map(|t| t.content()) - .collect(); - - assert_eq!(urls, vec!["wss://gitnostr.com", "wss://relay.ngit.dev"]); - } -} diff --git a/crates/signed_core/src/clone_url.rs b/crates/signed_core/src/clone_url.rs index 19772cc..ca5cc94 100644 --- a/crates/signed_core/src/clone_url.rs +++ b/crates/signed_core/src/clone_url.rs @@ -28,10 +28,7 @@ pub fn parse_clone_url(url: &str) -> Option { if first.starts_with("naddr1") { let coordinate = Nip19Coordinate::from_bech32(first).ok()?; - return Some(CloneTarget::Addr(RepoAddr::new( - coordinate.coordinate.public_key, - coordinate.coordinate.identifier, - ))); + return Some(CloneTarget::Addr(coordinate.coordinate)); } let (relay_hint, identifier) = match third { diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 3277462..8bc26c3 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -1,4 +1,3 @@ -use nostr::filter::{Alphabet, SingleLetterTag}; use nostr::prelude::*; use crate::RepoAddr; @@ -19,16 +18,16 @@ pub const ACTIVITY_KINDS: [Kind; 8] = [ pub fn announcement(addr: &RepoAddr) -> Filter { Filter::new() .kind(Kind::GitRepoAnnouncement) - .author(addr.owner) - .identifier(addr.id.clone()) + .author(addr.public_key) + .identifier(addr.identifier.clone()) } /// Latest state event (refs / HEAD) for a repository. pub fn state(addr: &RepoAddr) -> Filter { Filter::new() .kind(Kind::RepoState) - .author(addr.owner) - .identifier(addr.id.clone()) + .author(addr.public_key) + .identifier(addr.identifier.clone()) } /// All NIP-34 activity addressed to a repository (`#a` tag). @@ -36,9 +35,7 @@ pub fn state(addr: &RepoAddr) -> Filter { /// Note: the `a` tag on status events is optional per NIP-34, so statuses /// published without it won't be matched here. pub fn activity(addr: &RepoAddr) -> Filter { - Filter::new() - .kinds(ACTIVITY_KINDS) - .custom_tag(SingleLetterTag::lowercase(Alphabet::A), addr.to_string()) + Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr) } /// Status events (`1630..=1633`) referencing a specific root event (`#e` tag). diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index e103493..19adfec 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -1,11 +1,10 @@ pub mod addr; -pub mod builders; pub mod clone_url; pub mod filters; pub mod model; pub mod status; -pub use addr::RepoAddr; +pub use addr::{RepoAddr, repo_addr}; pub use clone_url::{CloneTarget, parse_clone_url}; pub use model::Announcement; pub use status::{RepoStatus, references_root, resolve_status}; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 199f09a..b1a6ceb 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -40,27 +40,24 @@ impl Announcement { let mut maintainers: Vec = Vec::new(); for tag in event.tags.iter() { - let values: &[String] = tag.as_slice(); - match tag.kind() { - "d" => id = tag.content().map(str::to_owned), - "name" => name = tag.content().map(str::to_owned), - "description" => description = tag.content().map(str::to_owned), - "web" => web.extend(values.iter().skip(1).cloned()), - "clone" => clone.extend(values.iter().skip(1).cloned()), - "relays" => relays.extend(values.iter().skip(1).cloned()), - "r" => { - if values.get(2).map(String::as_str) == Some("euc") { - euc = tag.content().map(str::to_owned); - } + // The `d` tag isn't part of the NIP-34 tag codec; parse it directly. + if tag.kind() == "d" { + id = tag.content().map(str::to_owned); + continue; + } + + match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Name(value)) => name = Some(value), + Ok(Nip34Tag::Description(value)) => description = Some(value), + Ok(Nip34Tag::Web(urls)) => web.extend(urls.into_iter().map(|url| url.to_string())), + Ok(Nip34Tag::Clone(urls)) => { + clone.extend(urls.into_iter().map(|url| url.to_string())) } - "maintainers" => { - maintainers.extend( - values - .iter() - .skip(1) - .filter_map(|v| PublicKey::from_hex(v).ok()), - ); + Ok(Nip34Tag::Relays(urls)) => { + relays.extend(urls.into_iter().map(|url| url.to_string())) } + Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()), + Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys), _ => {} } } @@ -81,6 +78,6 @@ impl Announcement { /// The repository address of this announcement. pub fn addr(&self) -> crate::RepoAddr { - crate::RepoAddr::new(self.owner, self.id.clone()) + crate::repo_addr(self.owner, self.id.clone()) } } diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 5c159e3..1cd7bc9 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -32,11 +32,7 @@ impl RepoStatus { /// Check whether a status event references the given root event via an `e` tag. pub fn references_root(event: &Event, root: &EventId) -> bool { - let root_hex: String = root.to_hex(); - event - .tags - .iter() - .any(|t| t.kind() == "e" && t.content() == Some(root_hex.as_str())) + event.tags.event_ids().any(|id| id == *root) } /// Resolve the status of a root event per NIP-34: diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 0d5bb29..b208eb2 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -25,8 +25,8 @@ impl GitCache { /// Local path of the clone for a repository. pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root - .join(addr.owner.to_hex()) - .join(sanitize_path_component(&addr.id)) + .join(addr.public_key.to_hex()) + .join(sanitize_path_component(&addr.identifier)) } /// Open an existing clone. @@ -116,8 +116,14 @@ fn clone(url: &str, path: &Path) -> Result { Ok(repo) } +/// Map an untrusted repository id to a safe single path component. +/// +/// Replaces everything outside `[A-Za-z0-9._-]` with `_`, and rejects the +/// special components `.` and `..` so the id can't escape the cache root +/// when joined onto the owner directory. fn sanitize_path_component(id: &str) -> String { - id.chars() + let sanitized: String = id + .chars() .map(|c| { if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c @@ -125,5 +131,54 @@ fn sanitize_path_component(id: &str) -> String { '_' } }) - .collect() + .collect(); + + if sanitized == "." || sanitized == ".." { + return "_".to_owned(); + } + + sanitized +} + +#[cfg(test)] +mod tests { + use nostr::prelude::*; + use signed_core::repo_addr; + + use super::*; + + #[test] + fn keeps_plain_ids() { + assert_eq!(sanitize_path_component("my-repo"), "my-repo"); + assert_eq!(sanitize_path_component("repo.v2"), "repo.v2"); + assert_eq!(sanitize_path_component("a_b-c"), "a_b-c"); + } + + #[test] + fn replaces_unsafe_characters() { + assert_eq!(sanitize_path_component("a/b\\c:d"), "a_b_c_d"); + assert_eq!(sanitize_path_component(""), ""); + } + + #[test] + fn blocks_parent_components() { + assert_eq!(sanitize_path_component(".."), "_"); + assert_eq!(sanitize_path_component("."), "_"); + // Separators are neutralized before the check, so these stay safe. + assert_eq!(sanitize_path_component("../.."), ".._.."); + assert_eq!(sanitize_path_component("a/../b"), "a_.._b"); + } + + #[test] + fn repo_path_stays_inside_root() { + let cache = GitCache::new("/cache".into()); + let owner = Keys::generate().public_key(); + + let path = cache.repo_path(&repo_addr(owner, "..")); + assert!(path.starts_with("/cache")); + assert_eq!( + path.file_name().map(|n| n.to_string_lossy().into_owned()), + Some("_".into()) + ); + } } diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index 04bf083..6055a29 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result}; use nostr_gossip_memory::prelude::*; #[cfg(not(target_arch = "wasm32"))] use nostr_lmdb::prelude::*; @@ -10,112 +10,47 @@ use nostr_sdk::prelude::*; use crate::signer::UniversalSigner; -/// Owns the nostr client: relay pool, LMDB database and signer. +/// Open (or create) the LMDB database at `db_path` and build a client +/// configured for Signed, together with a fresh signer. /// -/// The SDK manages its own internal tokio runtime; every method here is a -/// plain async fn that can be driven by GPUI's executors. -#[derive(Clone)] -pub struct NostrBackend { - client: Client, - signer: UniversalSigner, +/// The SDK manages its own internal tokio runtime; the returned client can be +/// driven by GPUI's executors. +#[cfg(not(target_arch = "wasm32"))] +pub async fn new_backend( + db_path: impl AsRef, +) -> Result<(Client, UniversalSigner)> { + let signer = UniversalSigner::new(Keys::generate()); + let database = NostrLmdb::open(db_path) + .await + .context("failed to open nostr database")?; + Ok(with_database(signer, database)) } -impl NostrBackend { - /// Open (or create) the LMDB database at `db_path` and build the client. - #[cfg(not(target_arch = "wasm32"))] - pub async fn new(db_path: impl AsRef) -> Result { - let signer = UniversalSigner::new(Keys::generate()); - let database = NostrLmdb::open(db_path) - .await - .context("failed to open nostr database")?; - Ok(Self::with_database(signer, database)) - } - - /// In-memory database on wasm (no LMDB available). - #[cfg(target_arch = "wasm32")] - pub fn new() -> Result { - let signer = UniversalSigner::new(Keys::generate()); - Ok(Self::with_database(signer, MemoryDatabase::unbounded())) - } - - fn with_database(signer: UniversalSigner, database: D) -> Self - where - D: IntoNostrDatabase, - { - let authenticator = SignerAuthenticator::new(signer.clone()); - - let client = ClientBuilder::default() - .database(database) - .authenticator(authenticator) - .gossip(NostrGossipMemory::unbounded()) - .gossip_config(GossipConfig::default().no_background_refresh()) - .connect_timeout(Duration::from_secs(10)) - .verify_subscriptions(true) - .ban_relay_on_mismatch(true) - .sleep_when_idle(SleepWhenIdle::Enabled { - timeout: Duration::from_secs(600), - }) - .build(); - - Self { client, signer } - } - - pub fn client(&self) -> Client { - self.client.clone() - } - - pub fn signer(&self) -> UniversalSigner { - self.signer.clone() - } - - pub async fn add_relay(&self, url: &str) -> Result<()> { - self.client.add_relay(url).await?; - Ok(()) - } - - /// Add a relay used only for discovery (e.g. NIP-65 indexer relays). - /// No subscriptions or writes are routed through it. - pub async fn add_discovery_relay(&self, url: &str) -> Result<()> { - self.client - .add_relay(url) - .capabilities(RelayCapabilities::DISCOVERY) - .await?; - Ok(()) - } - - pub async fn connect(&self) { - self.client.connect().await; - } - - /// Start a persistent subscription. Received events are stored in the - /// database automatically by the relay pool. - pub async fn subscribe(&self, filter: Filter) -> Result { - let output = self.client.subscribe(filter).await?; - Ok(output.value) - } - - /// Query the local database (the single source of truth for the UI). - pub async fn query(&self, filter: Filter) -> Result> { - let events = self.client.database().query(filter).await?; - Ok(events.into_iter().collect()) - } - - /// Sign with the current signer, broadcast, and save locally so the - /// event is immediately visible to [`NostrBackend::query`]. - pub async fn send(&self, builder: EventBuilder) -> Result { - let event = builder.finalize_async(&self.signer).await?; - let output = self.client.send_event(&event).await?; - - if output.success.is_empty() && !output.failed.is_empty() { - let reasons = output - .failed - .values() - .cloned() - .collect::>() - .join(", "); - return Err(anyhow!("event not accepted by any relay: {reasons}")); - } - - Ok(event) - } +/// In-memory database on wasm (no LMDB available). +#[cfg(target_arch = "wasm32")] +pub fn new_backend() -> Result<(Client, UniversalSigner)> { + let signer = UniversalSigner::new(Keys::generate()); + Ok(with_database(signer, MemoryDatabase::unbounded())) +} + +fn with_database(signer: UniversalSigner, database: D) -> (Client, UniversalSigner) +where + D: IntoNostrDatabase, +{ + let authenticator = SignerAuthenticator::new(signer.clone()); + + let client = ClientBuilder::default() + .database(database) + .authenticator(authenticator) + .gossip(NostrGossipMemory::unbounded()) + .gossip_config(GossipConfig::default().no_background_refresh()) + .connect_timeout(Duration::from_secs(10)) + .verify_subscriptions(true) + .ban_relay_on_mismatch(true) + .sleep_when_idle(SleepWhenIdle::Enabled { + timeout: Duration::from_secs(600), + }) + .build(); + + (client, signer) } diff --git a/crates/signed_nostr/src/lib.rs b/crates/signed_nostr/src/lib.rs index 1328bf4..3c99075 100644 --- a/crates/signed_nostr/src/lib.rs +++ b/crates/signed_nostr/src/lib.rs @@ -2,6 +2,6 @@ mod backend; mod signer; mod update; -pub use backend::NostrBackend; +pub use backend::new_backend; pub use signer::{SignedAuthUrlHandler, UniversalSigner}; pub use update::Update; diff --git a/crates/signed_nostr/src/signer.rs b/crates/signed_nostr/src/signer.rs index 9f676d2..9f8e592 100644 --- a/crates/signed_nostr/src/signer.rs +++ b/crates/signed_nostr/src/signer.rs @@ -194,7 +194,7 @@ impl AuthUrlHandler for SignedAuthUrlHandler { auth_url: Url, ) -> Pin> + Send + '_>> { Box::pin(async move { - webbrowser::open(auth_url.as_str()).unwrap(); + webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?; Ok(()) }) } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index f6121d7..4e43e34 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -6,8 +6,8 @@ use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::{builders, filters}; -use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update}; +use signed_core::filters; +use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; /// Keyring entry holding the user credential (`nsec1...` or `bunker://...` /// with an embedded `?master=` NIP-46 session key). @@ -71,7 +71,8 @@ impl BackendEvent { /// notification pump. Stores subscribe to [`BackendEvent`] and re-query the /// local database when relevant updates arrive. pub struct Backend { - inner: NostrBackend, + client: Client, + signer: UniversalSigner, current_user: Option, connected: bool, sync_progress: Option<(u64, u64)>, @@ -94,11 +95,11 @@ impl Backend { cx.set_global(GlobalBackend(entity)); } - pub(crate) fn new(inner: NostrBackend, cx: &mut Context) -> Self { - let client = inner.client(); + pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context) -> Self { + let pump_client = client.clone(); let pump = cx.spawn(async move |this, cx| { - let mut notifications = client.notifications(); + let mut notifications = pump_client.notifications(); while let Some(notification) = notifications.next().await { let ClientNotification::Event { event, .. } = notification else { @@ -119,7 +120,8 @@ impl Backend { }); let mut this = Self { - inner, + client, + signer, current_user: None, connected: false, sync_progress: None, @@ -133,16 +135,19 @@ impl Backend { /// Bootstrap the client: connect to the default relays (indexers as /// discovery-only) and restore the saved session, if any. fn bootstrap(&mut self, cx: &mut Context) { - let backend = self.inner.clone(); + let client = self.client.clone(); let task = cx.background_spawn(async move { for url in BOOTSTRAP_RELAYS { - backend.add_relay(url).await?; + client.add_relay(url).await?; } for url in INDEXER_RELAYS { - backend.add_discovery_relay(url).await?; + client + .add_relay(url) + .capabilities(RelayCapabilities::DISCOVERY) + .await?; } - backend.connect().await; + client.connect().await; Ok::<(), Error>(()) }); @@ -276,7 +281,7 @@ impl Backend { this.update(cx, |this, cx| { // Become the new identity, so the publishes below are // signed with the new keys. - this.inner.signer().swap_inner(keys); + this.signer.swap_inner(keys); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); cx.emit(BackendEvent::SignerChanged); @@ -317,7 +322,7 @@ impl Backend { .map(|url| RelayUrl::parse(url).expect("valid relay URL")) .collect(); - this.send(builders::grasp_list(grasp_servers), cx); + this.send(GitUserGraspList { grasp_servers }.into_event_builder(), cx); })?; Ok(public_key) @@ -441,7 +446,7 @@ impl Backend { delete.await.ok(); this.update(cx, |this, cx| { - this.inner.signer().swap_inner(Keys::generate()); + this.signer.swap_inner(Keys::generate()); this.current_user = None; cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerRequired); @@ -455,14 +460,11 @@ impl Backend { /// Fetch the user's grasp list (kind `10317`) and add the listed grasp /// servers as relays. fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { - let backend = self.inner.clone(); + let client = self.client.clone(); self.tasks.push(cx.spawn(async move |this, cx| { let result = async { - let events = backend - .client() - .fetch_events(filters::grasp_list(public_key)) - .await?; + let events = client.fetch_events(filters::grasp_list(public_key)).await?; let urls: Vec = events .into_iter() @@ -477,9 +479,9 @@ impl Backend { .unwrap_or_default(); for url in urls { - backend.add_relay(&url).await.ok(); + client.add_relay(&url).await.ok(); } - backend.connect().await; + client.connect().await; Ok::<_, Error>(()) } @@ -495,12 +497,12 @@ impl Backend { /// Get the nostr client. pub fn client(&self) -> Client { - self.inner.client() + self.client.clone() } /// Get the current signer. pub fn signer(&self) -> UniversalSigner { - self.inner.signer() + self.signer.clone() } /// Get the current user's public key. @@ -536,7 +538,7 @@ impl Backend { match new_signer.get_public_key_async().await { Ok(public_key) => { this.update(cx, |this, cx| { - this.inner.signer().swap_inner(new_signer); + this.signer.swap_inner(new_signer); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); cx.emit(BackendEvent::SignerChanged); @@ -557,13 +559,13 @@ impl Backend { /// Add relays and connect to them. pub fn add_relays(&mut self, urls: Vec, cx: &mut Context) { - let backend = self.inner.clone(); + let client = self.client.clone(); let task = cx.background_spawn(async move { for url in urls { - backend.add_relay(&url).await?; + client.add_relay(&url).await?; } - backend.connect().await; + client.connect().await; Ok::<(), Error>(()) }); @@ -587,13 +589,16 @@ impl Backend { /// Add relays used only for discovery (e.g. NIP-65 indexers) and /// connect to them. No subscriptions or writes are routed through them. pub fn add_discovery_relays(&mut self, urls: Vec, cx: &mut Context) { - let backend = self.inner.clone(); + let client = self.client.clone(); let task = cx.background_spawn(async move { for url in urls { - backend.add_discovery_relay(&url).await?; + client + .add_relay(&url) + .capabilities(RelayCapabilities::DISCOVERY) + .await?; } - backend.connect().await; + client.connect().await; Ok::<(), Error>(()) }); @@ -608,9 +613,9 @@ impl Backend { /// Start a persistent subscription. Matching events are stored in the /// database automatically and surface as [`BackendEvent::NostrUpdate`]. pub fn subscribe(&mut self, filter: Filter, cx: &mut Context) { - let backend = self.inner.clone(); + let client = self.client.clone(); - let task = cx.background_spawn(async move { backend.subscribe(filter).await.map(|_| ()) }); + let task = cx.background_spawn(async move { client.subscribe(filter).await.map(|_| ()) }); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = task.await { @@ -625,11 +630,10 @@ impl Backend { /// in the database and surface as [`BackendEvent::NostrUpdate`] while the /// subscription is open. pub fn subscribe_bootstrap(&mut self, filters: Vec, cx: &mut Context) { - let backend = self.inner.clone(); + let client = self.client.clone(); - let task = cx.background_spawn(async move { - subscribe_bootstrap_only(&backend.client(), filters).await - }); + let task = + cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await }); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = task.await { @@ -644,7 +648,7 @@ impl Backend { /// Emits [`BackendEvent::SyncProgress`] while running (throttled to /// whole-percent changes) and [`BackendEvent::Synced`] on completion. pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { - let backend = self.inner.clone(); + let client = self.client.clone(); self.sync_progress = Some((0, 0)); cx.notify(); @@ -681,7 +685,7 @@ impl Backend { let task = cx.background_spawn(async move { let opts = SyncOptions::default().progress(tx); - sync_bootstrap_only(&backend.client(), filter, opts).await + sync_bootstrap_only(&client, filter, opts).await }); self.tasks.push(cx.spawn(async move |this, cx| { @@ -721,8 +725,27 @@ impl Backend { cx: &mut Context, ) -> flume::Receiver> { let (tx, rx) = flume::bounded(1); - let backend = self.inner.clone(); - let task = cx.background_spawn(async move { backend.send(builder).await }); + let client = self.client.clone(); + let signer = self.signer.clone(); + + let task = cx.background_spawn(async move { + // Sign with the current signer, broadcast, and save locally so + // the event is immediately visible to database queries. + let event = builder.finalize_async(&signer).await?; + let output = client.send_event(&event).await?; + + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + return Err(anyhow!("event not accepted by any relay: {reasons}")); + } + + Ok(event) + }); self.tasks.push(cx.spawn(async move |this, cx| { let result = task.await; diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 25fe3ec..8ccf4dc 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -10,7 +10,7 @@ use gpui::{App, AppContext, Entity}; pub use profile::{Profile, ProfileStore, shorten_pubkey}; pub use repo::RepoStore; pub use repo_list::RepoListStore; -use signed_nostr::NostrBackend; +use signed_nostr::new_backend; /// Initialize the backend and stores, and install them as globals. Call once /// at startup, before opening any window that uses the stores. @@ -22,13 +22,13 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { .ok(); let path = db_path.as_ref().to_path_buf(); - let inner = cx.foreground_executor().block_on(async move { - NostrBackend::new(path) + let (client, signer) = cx.foreground_executor().block_on(async move { + new_backend(path) .await .expect("failed to initialize nostr backend") }); - let entity = cx.new(|cx| Backend::new(inner, cx)); + let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(entity.clone(), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx); @@ -39,9 +39,9 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { /// Initialize the backend with an in-memory database on wasm. #[cfg(target_arch = "wasm32")] pub fn init(cx: &mut App) -> Entity { - let inner = NostrBackend::new().expect("failed to initialize nostr backend"); + let (client, signer) = new_backend().expect("failed to initialize nostr backend"); - let entity = cx.new(|cx| Backend::new(inner, cx)); + let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(entity.clone(), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx); diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 9bd05c0..99eb183 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::time::Duration; use anyhow::Error; -use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task}; use nostr_sdk::prelude::*; use crate::backend::{Backend, BackendEvent, sync_bootstrap_only}; @@ -138,47 +138,68 @@ impl ProfileStore { fn load(&mut self, cx: &mut Context) { let client = Backend::global(cx).read(cx).client(); - let task = cx.spawn(async move |this, cx| { + let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).limit(200); let events = client.database().query(filter).await?; - this.update(cx, |this, cx| { - for event in events { + // Parse off the main thread; only plain profiles cross back. + let profiles: Vec = events + .into_iter() + .map(|event| { let metadata = Metadata::from_json(&event.content).unwrap_or_default(); - this.profiles - .insert(event.pubkey, Profile::new(event.pubkey, metadata)); + Profile::new(event.pubkey, metadata) + }) + .collect(); + + Ok::<_, Error>(profiles) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let profiles = work.await?; + + this.update(cx, |this, cx| { + for profile in profiles { + this.profiles.insert(profile.public_key(), profile); } cx.notify(); })?; Ok(()) - }); - - self.tasks.push(task); + })); } /// Re-read the latest metadata of an author from the local database. fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context) { let client = Backend::global(cx).read(cx).client(); - let task = cx.spawn(async move |this, cx| { + let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).author(public_key); let events = client.database().query(filter).await?; - if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) { - let metadata = Metadata::from_json(event.content).unwrap_or_default(); + // Parse off the main thread; only the profile crosses back. + let profile = events + .into_iter() + .max_by_key(|e| e.created_at) + .map(|event| { + let metadata = Metadata::from_json(event.content).unwrap_or_default(); + Profile::new(event.pubkey, metadata) + }); - this.update(cx, |this, cx| { - this.profiles - .insert(public_key, Profile::new(public_key, metadata)); - cx.notify(); - })?; - } - - Ok(()) + Ok::<_, Error>(profile) }); - self.tasks.push(task); + self.tasks.push(cx.spawn(async move |this, cx| { + let profile = work.await?; + + this.update(cx, |this, cx| { + if let Some(profile) = profile { + this.profiles.insert(profile.public_key(), profile); + cx.notify(); + } + })?; + + Ok(()) + })); } /// Re-read the latest metadata of every requested author from the local @@ -191,10 +212,11 @@ impl ProfileStore { let client = Backend::global(cx).read(cx).client(); let authors: Vec = self.seen.iter().copied().collect(); - let task = cx.spawn(async move |this, cx| { + let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).authors(authors); let events = client.database().query(filter).await?; + // Pick the latest metadata per author off the main thread. let mut latest: HashMap = HashMap::new(); for event in events { match latest.get(&event.pubkey) { @@ -211,18 +233,26 @@ impl ProfileStore { } } + let profiles: Vec = latest + .into_iter() + .map(|(public_key, (_, metadata))| Profile::new(public_key, metadata)) + .collect(); + + Ok::<_, Error>(profiles) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let profiles = work.await?; + this.update(cx, |this, cx| { - for (public_key, (_, metadata)) in latest { - this.profiles - .insert(public_key, Profile::new(public_key, metadata)); + for profile in profiles { + this.profiles.insert(profile.public_key(), profile); } cx.notify(); })?; Ok(()) - }); - - self.tasks.push(task); + })); } /// Drain the queue in a batched fetch, debounced to collect requests. diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index c98fce6..22cd35a 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1,5 +1,5 @@ use anyhow::Error; -use gpui::{Context, Subscription, Task}; +use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; @@ -33,20 +33,16 @@ impl RepoStore { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { BackendEvent::NostrUpdate(update) => { - let coordinate = update.coordinate.as_ref() == Some(&this.addr.coordinate()); - let author = update.author == this.addr.owner; + let coordinate = update.coordinate.as_ref() == Some(&this.addr); + let author = update.author == this.addr.public_key; let kind = update.kind == Kind::GitRepoAnnouncement; coordinate || (author && kind) } BackendEvent::Published(event) => { let kind = event.kind == Kind::GitRepoAnnouncement; - let author = event.pubkey == this.addr.owner; - let coordinate = event - .tags - .coordinates() - .into_iter() - .any(|c| c == this.addr.coordinate()); + let author = event.pubkey == this.addr.public_key; + let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); coordinate || (kind && author) } @@ -103,7 +99,8 @@ impl RepoStore { /// Re-query the local database and update all fields. /// /// Debounced: concurrent requests are coalesced into a single re-query - /// after the running one finishes. + /// after the running one finishes. The query and processing run on a + /// background thread; only the results are applied on the main thread. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; @@ -114,84 +111,99 @@ impl RepoStore { let client = Backend::global(cx).read(cx).client(); let addr = self.addr.clone(); - let task = cx.spawn(async move |this, cx| { - loop { - let queries = async { - let db = client.database(); + let work = cx.background_spawn(async move { + let queries = async { + let db = client.database(); - let announcements = db.query(filters::announcement(&addr)).await?; - let states = db.query(filters::state(&addr)).await?; - let activity = db.query(filters::activity(&addr)).await?; + let announcements = db.query(filters::announcement(&addr)).await?; + let states = db.query(filters::state(&addr)).await?; + let activity = db.query(filters::activity(&addr)).await?; - Ok::<_, Error>((announcements, states, activity)) - } - .await; + Ok::<_, Error>((announcements, states, activity)) + } + .await?; - let (announcements, states, activity) = match queries { - Ok(results) => results, - Err(e) => { - return this.update(cx, |this, cx| { - this.refreshing = false; - this.last_error = Some(e.to_string()); - cx.notify(); - }); - } - }; + let (announcements, states, activity) = queries; - let again = this.update(cx, |this, cx| { - this.announcement = latest(announcements) - .as_ref() - .and_then(Announcement::from_event); + // Parse and sort off the main thread; only plain data + // crosses back into the entity. + let announcement = latest(announcements) + .as_ref() + .and_then(Announcement::from_event); - if let Some(state) = latest(states) { - let (refs, head) = parse_state(&state); - this.refs = refs; - this.head = head; - } + let state = latest(states).map(|state| parse_state(&state)); - this.issues.clear(); - this.patches.clear(); - this.pull_requests.clear(); - this.statuses.clear(); + let (mut issues, mut patches, mut pull_requests, mut statuses) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); - for event in activity { - match event.kind { - Kind::GitIssue => this.issues.push(event), - Kind::GitPatch => this.patches.push(event), - Kind::GitPullRequest | Kind::GitPullRequestUpdate => { - this.pull_requests.push(event) - } - kind if RepoStatus::from_kind(kind).is_some() => { - this.statuses.push(event) - } - _ => {} - } - } - - sort_newest_first(&mut this.issues); - sort_newest_first(&mut this.patches); - sort_newest_first(&mut this.pull_requests); - - cx.notify(); - - if this.refresh_dirty { - this.refresh_dirty = false; - true - } else { - this.refreshing = false; - false - } - })?; - - if !again { - break; + for event in activity { + match event.kind { + Kind::GitIssue => issues.push(event), + Kind::GitPatch => patches.push(event), + Kind::GitPullRequest | Kind::GitPullRequestUpdate => pull_requests.push(event), + kind if RepoStatus::from_kind(kind).is_some() => statuses.push(event), + _ => {} } } - Ok(()) + sort_newest_first(&mut issues); + sort_newest_first(&mut patches); + sort_newest_first(&mut pull_requests); + + Ok::<_, Error>(( + announcement, + state, + issues, + patches, + pull_requests, + statuses, + )) }); - self.tasks.push(task); + self.tasks.push(cx.spawn(async move |this, cx| { + let (announcement, state, issues, patches, pull_requests, statuses) = match work.await { + Ok(data) => data, + Err(e) => { + return this.update(cx, |this, cx| { + this.refreshing = false; + this.last_error = Some(e.to_string()); + cx.notify(); + }); + } + }; + + let again = this.update(cx, |this, cx| { + this.announcement = announcement; + + if let Some((refs, head)) = state { + this.refs = refs; + this.head = head; + } + + this.issues = issues; + this.patches = patches; + this.pull_requests = pull_requests; + this.statuses = statuses; + + cx.notify(); + + this.refreshing = false; + if this.refresh_dirty { + this.refresh_dirty = false; + true + } else { + false + } + })?; + + // Requests that arrived while the refresh was running are + // coalesced into one follow-up refresh. + if again { + this.update(cx, |this, cx| this.refresh(cx))?; + } + + Ok(()) + })); } /// Resolve the status of a root event (issue / patch / PR) per NIP-34. @@ -213,7 +225,7 @@ impl RepoStore { /// Open an issue on this repository. pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { let builder = GitIssue { - repository: self.addr.coordinate(), + repository: self.addr.clone(), content, subject, labels: Vec::new(), @@ -230,8 +242,8 @@ impl RepoStore { }; let builder = EventBuilder::new(Kind::GitPatch, patch).tags([ - Tag::coordinate(self.addr.coordinate(), None), - Tag::public_key(self.addr.owner), + Tag::coordinate(self.addr.clone(), None), + Tag::public_key(self.addr.public_key), root_marker, ]); @@ -246,9 +258,9 @@ impl RepoStore { let builder = EventBuilder::new(status.kind(), "").tags([ root_ref, - Tag::public_key(self.addr.owner), + Tag::public_key(self.addr.public_key), Tag::public_key(root.pubkey), - Tag::coordinate(self.addr.coordinate(), None), + Tag::coordinate(self.addr.clone(), None), ]); self.send(builder, cx); @@ -288,16 +300,15 @@ fn parse_state(event: &Event) -> (Vec<(String, String)>, Option) { let mut head = None; for tag in event.tags.iter() { - let kind = tag.kind(); - if kind == "HEAD" { - head = tag - .content() - .and_then(|v| v.strip_prefix("ref: refs/heads/")) - .map(str::to_owned); - } else if kind.starts_with("refs/") - && let Some(commit) = tag.content() - { - refs.push((kind.to_owned(), commit.to_owned())); + match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Head(branch)) => head = Some(branch), + Ok(Nip34Tag::RefHead { branch, commit }) => { + refs.push((format!("refs/heads/{branch}"), commit.to_string())); + } + Ok(Nip34Tag::RefTag { name, commit }) => { + refs.push((format!("refs/tags/{name}"), commit.to_string())); + } + _ => {} } } diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index 0b61c14..bf2a2b3 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; use anyhow::Error; -use gpui::{Context, Subscription, Task}; +use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, filters}; +use signed_core::{Announcement, RepoAddr, filters}; use crate::backend::{Backend, BackendEvent}; @@ -79,7 +79,8 @@ impl RepoListStore { /// Re-query the local database. Latest announcement per repository wins. /// /// Debounced: concurrent requests are coalesced into a single re-query - /// after the running one finishes. + /// after the running one finishes. The query and processing run on a + /// background thread; only the results are applied on the main thread. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; @@ -90,63 +91,70 @@ impl RepoListStore { let client = Backend::global(cx).read(cx).client(); let author = self.author; - let task = cx.spawn(async move |this, cx| { - loop { - let filter = match author { - Some(a) => filters::announcements_by(a), - None => filters::all_announcements(), + let work = cx.background_spawn(async move { + let filter = match author { + Some(a) => filters::announcements_by(a), + None => filters::all_announcements(), + }; + + let events = client.database().query(filter).await?; + + // Dedup and sort off the main thread; only the final list + // crosses back into the entity. + let mut by_repo: HashMap = HashMap::new(); + + for event in events { + let Some(announcement) = Announcement::from_event(&event) else { + continue; }; - let events = match client.database().query(filter).await { - Ok(events) => events, - Err(_) => { - return this.update(cx, |this, _cx| { - this.refreshing = false; - }); + let addr = announcement.addr(); + + match by_repo.get(&addr) { + Some(existing) if existing.created_at >= announcement.created_at => {} + _ => { + by_repo.insert(addr, announcement); } - }; - - let again = this.update(cx, |this, cx| { - let mut by_repo: HashMap<(String, String), Announcement> = HashMap::new(); - - for event in events { - let Some(announcement) = Announcement::from_event(&event) else { - continue; - }; - - let key = (announcement.owner.to_hex(), announcement.id.clone()); - - match by_repo.get(&key) { - Some(existing) if existing.created_at >= announcement.created_at => {} - _ => { - by_repo.insert(key, announcement); - } - } - } - - let mut announcements: Vec = by_repo.into_values().collect(); - announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); - - this.announcements = announcements; - cx.notify(); - - if this.refresh_dirty { - this.refresh_dirty = false; - true - } else { - this.refreshing = false; - false - } - })?; - - if !again { - break; } } - Ok(()) + let mut announcements: Vec = by_repo.into_values().collect(); + announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); + + Ok::<_, Error>(announcements) }); - self.tasks.push(task); + self.tasks.push(cx.spawn(async move |this, cx| { + let announcements = match work.await { + Ok(announcements) => announcements, + // Database errors are transient; keep the last list. + Err(_) => { + return this.update(cx, |this, _cx| { + this.refreshing = false; + }); + } + }; + + let again = this.update(cx, |this, cx| { + this.announcements = announcements; + cx.notify(); + + this.refreshing = false; + if this.refresh_dirty { + this.refresh_dirty = false; + true + } else { + false + } + })?; + + // Requests that arrived while the refresh was running are + // coalesced into one follow-up refresh. + if again { + this.update(cx, |this, cx| this.refresh(cx))?; + } + + Ok(()) + })); } } -- 2.54.0 From 63f2de70e1bbfbfa8ff83f889207debc57fddff3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 6 Aug 2026 16:26:37 +0700 Subject: [PATCH 12/64] refactor 2 --- Cargo.toml | 2 +- crates/signed_core/src/lib.rs | 2 + crates/signed_core/src/model.rs | 106 +++++++++++++++++++++ crates/signed_core/src/state.rs | 95 +++++++++++++++++++ crates/signed_core/src/status.rs | 134 +++++++++++++++++++++++++++ crates/signed_state/src/repo.rs | 56 ++++++----- crates/signed_state/src/repo_list.rs | 42 +++++++-- 7 files changed, 406 insertions(+), 31 deletions(-) create mode 100644 crates/signed_core/src/state.rs diff --git a/Cargo.toml b/Cargo.toml index 0b8f27f..2281829 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } gpui-component = { git = "https://github.com/longbridge/gpui-component" } -nostr = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215", features = ["nip59", "nip49", "nip44"] } +nostr = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } nostr-memory = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 19adfec..e80f267 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -2,9 +2,11 @@ pub mod addr; pub mod clone_url; pub mod filters; pub mod model; +pub mod state; pub mod status; pub use addr::{RepoAddr, repo_addr}; pub use clone_url::{CloneTarget, parse_clone_url}; pub use model::Announcement; +pub use state::parse_state; pub use status::{RepoStatus, references_root, resolve_status}; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index b1a6ceb..796282c 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -81,3 +81,109 @@ impl Announcement { crate::repo_addr(self.owner, self.id.clone()) } } + +#[cfg(test)] +mod tests { + use super::*; + + const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"; + + fn keys() -> Keys { + Keys::new( + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .expect("valid secret key"), + ) + } + + /// Build a signed kind `30617` event from raw tag values. + fn announcement_event(tags: &[&[&str]]) -> Event { + let tags: Vec = tags + .iter() + .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) + .collect(); + + EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(tags) + .finalize(&keys()) + .expect("signed event") + } + + #[test] + fn parses_full_announcement() { + let event = announcement_event(&[ + &["d", "my-repo"], + &["name", "My Repo"], + &["description", "A test repository"], + &["web", "https://example.com/repo"], + &["clone", "https://example.com/repo.git"], + &["relays", "wss://relay.example.com"], + &["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"], + &["maintainers", MAINTAINER_HEX], + ]); + + let announcement = Announcement::from_event(&event).expect("parses"); + + assert_eq!(announcement.owner, keys().public_key()); + assert_eq!(announcement.id, "my-repo"); + assert_eq!(announcement.name.as_deref(), Some("My Repo")); + assert_eq!( + announcement.description.as_deref(), + Some("A test repository") + ); + assert_eq!(announcement.web, vec!["https://example.com/repo"]); + assert_eq!(announcement.clone, vec!["https://example.com/repo.git"]); + assert_eq!(announcement.relays, vec!["wss://relay.example.com"]); + assert_eq!( + announcement.euc.as_deref(), + Some("aa231c4c6a5777dc89b42207b499891a344add5c") + ); + assert_eq!( + announcement.maintainers, + vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")] + ); + } + + #[test] + fn requires_d_tag() { + let event = announcement_event(&[&["name", "No id"]]); + + assert!(Announcement::from_event(&event).is_none()); + } + + #[test] + fn ignores_other_kinds() { + let event = EventBuilder::new(Kind::GitIssue, "") + .finalize(&keys()) + .expect("signed event"); + + assert!(Announcement::from_event(&event).is_none()); + } + + #[test] + fn drops_malformed_values() { + let event = announcement_event(&[ + &["d", "my-repo"], + &["clone", "not a url"], + &["relays", "wss://good.example.com"], + &["maintainers", "not-a-pubkey"], + ]); + + let announcement = Announcement::from_event(&event).expect("parses"); + + // An invalid URL keeps the whole clone tag from being parsed. + assert!(announcement.clone.is_empty()); + assert_eq!(announcement.relays, vec!["wss://good.example.com"]); + assert!(announcement.maintainers.is_empty()); + } + + #[test] + fn ignores_unknown_tags() { + let event = announcement_event(&[&["d", "my-repo"], &["t", "label"], &["subject", "n/a"]]); + + let announcement = Announcement::from_event(&event).expect("parses"); + + assert_eq!(announcement.id, "my-repo"); + assert!(announcement.name.is_none()); + assert!(announcement.web.is_empty()); + } +} diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs new file mode 100644 index 0000000..dff0fcb --- /dev/null +++ b/crates/signed_core/src/state.rs @@ -0,0 +1,95 @@ +use nostr::prelude::*; + +/// Parse a kind `30618` repository state event into refs and HEAD. +/// +/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to +/// by the `HEAD` tag, if any. +pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option) { + let mut refs = Vec::new(); + let mut head = None; + + for tag in event.tags.iter() { + match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Head(branch)) => head = Some(branch), + Ok(Nip34Tag::RefHead { branch, commit }) => { + refs.push((format!("refs/heads/{branch}"), commit.to_string())); + } + Ok(Nip34Tag::RefTag { name, commit }) => { + refs.push((format!("refs/tags/{name}"), commit.to_string())); + } + _ => {} + } + } + + (refs, head) +} + +#[cfg(test)] +mod tests { + use super::*; + + const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c"; + const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca"; + + fn keys() -> Keys { + Keys::new( + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .expect("valid secret key"), + ) + } + + /// Build a signed kind `30618` event from raw tag values. + fn state_event(tags: &[&[&str]]) -> Event { + let tags: Vec = tags + .iter() + .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) + .collect(); + + EventBuilder::new(Kind::RepoState, "") + .tags(tags) + .finalize(&keys()) + .expect("signed event") + } + + #[test] + fn parses_heads_and_tags() { + let event = state_event(&[ + &["HEAD", "ref: refs/heads/main"], + &["refs/heads/main", COMMIT_A], + &["refs/heads/dev", COMMIT_B], + &["refs/tags/v1.0", COMMIT_A], + ]); + + let (refs, head) = parse_state(&event); + + assert_eq!(head.as_deref(), Some("main")); + assert_eq!( + refs, + vec![ + ("refs/heads/main".to_owned(), COMMIT_A.to_owned()), + ("refs/heads/dev".to_owned(), COMMIT_B.to_owned()), + ("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()), + ] + ); + } + + #[test] + fn head_without_prefix_is_ignored() { + let event = state_event(&[&["HEAD", "main"]]); + + let (refs, head) = parse_state(&event); + + assert!(refs.is_empty()); + assert!(head.is_none()); + } + + #[test] + fn ignores_non_state_tags() { + let event = state_event(&[&["d", "my-repo"], &["name", "ignored"]]); + + let (refs, head) = parse_state(&event); + + assert!(refs.is_empty()); + assert!(head.is_none()); + } +} diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 1cd7bc9..41b87ed 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -54,3 +54,137 @@ where .and_then(|e| RepoStatus::from_kind(e.kind)) .unwrap_or(RepoStatus::Open) } + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + + fn keys_from_hex(hex: &str) -> Keys { + Keys::new(SecretKey::from_hex(hex).expect("valid secret key")) + } + + fn root_event_id() -> EventId { + EventId::from_hex(ROOT_ID_HEX).expect("valid event id") + } + + /// Build a signed status event with a controlled `created_at`. + fn status_event(author: &Keys, kind: Kind, root: EventId, created_at: u64) -> Event { + EventBuilder::new(kind, "") + .tags([Tag::event(root)]) + .custom_created_at(Timestamp::from(created_at)) + .finalize(author) + .expect("signed event") + } + + #[test] + fn references_root_matches_e_tag() { + let root = root_event_id(); + let event = EventBuilder::new(Kind::GitStatusOpen, "") + .tags([Tag::event(root)]) + .finalize(&keys_from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + )) + .expect("signed event"); + + assert!(references_root(&event, &root)); + assert!(!references_root( + &event, + &EventId::from_hex(OTHER_ID_HEX).expect("valid id") + )); + } + + #[test] + fn references_root_false_without_e_tags() { + let event = EventBuilder::new(Kind::GitStatusOpen, "") + .finalize(&keys_from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + )) + .expect("signed event"); + + assert!(!references_root(&event, &root_event_id())); + } + + #[test] + fn defaults_to_open_without_status_events() { + let owner = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); + let maintainer = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002"); + + let statuses: Vec = Vec::new(); + + assert_eq!( + resolve_status( + statuses.iter(), + &owner.public_key(), + &[maintainer.public_key()] + ), + RepoStatus::Open + ); + } + + #[test] + fn latest_status_wins() { + let owner = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); + let maintainer = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002"); + let root = root_event_id(); + + let statuses = [ + status_event(&maintainer, Kind::GitStatusClosed, root, 100), + status_event(&owner, Kind::GitStatusOpen, root, 200), + ]; + + assert_eq!( + resolve_status( + statuses.iter(), + &owner.public_key(), + &[maintainer.public_key()] + ), + RepoStatus::Open + ); + } + + #[test] + fn ignores_statuses_from_others() { + let owner = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); + let maintainer = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002"); + let stranger = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003"); + let root = root_event_id(); + + let statuses = [ + status_event(&stranger, Kind::GitStatusClosed, root, 300), + status_event(&maintainer, Kind::GitStatusDraft, root, 100), + ]; + + assert_eq!( + resolve_status( + statuses.iter(), + &owner.public_key(), + &[maintainer.public_key()] + ), + RepoStatus::Draft + ); + } + + #[test] + fn ignores_non_status_kinds() { + let owner = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); + let root = root_event_id(); + + let statuses = [status_event(&owner, Kind::GitIssue, root, 100)]; + + assert_eq!( + resolve_status(statuses.iter(), &owner.public_key(), &[]), + RepoStatus::Open + ); + } +} diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 22cd35a..74bd0d2 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1,10 +1,16 @@ +use std::time::Duration; + use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; +use signed_core::{Announcement, RepoAddr, RepoStatus, filters, parse_state}; use crate::backend::{Backend, BackendEvent}; +/// Delay between a refresh request and the actual re-query, so bursts of +/// events (e.g. per-event `NostrUpdate`s) collapse into one query. +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); + /// Per-repository store: announcement, state, issues, patches, PRs and /// their resolved statuses. Always derived from the local database. pub struct RepoStore { @@ -22,6 +28,8 @@ pub struct RepoStore { pub last_error: Option, refreshing: bool, refresh_dirty: bool, + /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. + debouncing: bool, tasks: Vec>>, _subscription: Subscription, } @@ -66,6 +74,7 @@ impl RepoStore { last_error: None, refreshing: false, refresh_dirty: false, + debouncing: false, _subscription: subscription, tasks: Vec::new(), }; @@ -98,14 +107,34 @@ impl RepoStore { /// Re-query the local database and update all fields. /// - /// Debounced: concurrent requests are coalesced into a single re-query - /// after the running one finishes. The query and processing run on a + /// Debounced: a short delay collapses bursts of requests (e.g. per-event + /// `NostrUpdate`s), and requests that arrive while a query is running are + /// folded into one follow-up query. The query and processing run on a /// background thread; only the results are applied on the main thread. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; return; } + if self.debouncing { + return; + } + self.debouncing = true; + + let task = cx.spawn(async move |this, cx| { + cx.background_executor().timer(REFRESH_DEBOUNCE).await; + + this.update(cx, |this, cx| { + this.debouncing = false; + this.run_refresh(cx); + }) + }); + + self.tasks.push(task); + } + + /// One query + apply cycle (debounced entry point). + fn run_refresh(&mut self, cx: &mut Context) { self.refreshing = true; let client = Backend::global(cx).read(cx).client(); @@ -293,24 +322,3 @@ fn latest(events: Events) -> Option { fn sort_newest_first(events: &mut [Event]) { events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); } - -/// Parse a kind `30618` state event into refs and HEAD. -fn parse_state(event: &Event) -> (Vec<(String, String)>, Option) { - let mut refs = Vec::new(); - let mut head = None; - - for tag in event.tags.iter() { - match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Head(branch)) => head = Some(branch), - Ok(Nip34Tag::RefHead { branch, commit }) => { - refs.push((format!("refs/heads/{branch}"), commit.to_string())); - } - Ok(Nip34Tag::RefTag { name, commit }) => { - refs.push((format!("refs/tags/{name}"), commit.to_string())); - } - _ => {} - } - } - - (refs, head) -} diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index bf2a2b3..b09cdd4 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; @@ -7,12 +9,19 @@ use signed_core::{Announcement, RepoAddr, filters}; use crate::backend::{Backend, BackendEvent}; +/// Delay between a refresh request and the actual re-query, so bursts of +/// events (e.g. sync progress ticks) collapse into one query. +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); + /// Store listing repository announcements (global discovery or per-author). pub struct RepoListStore { - pub announcements: Vec, + /// Shared so views can clone the list per frame without a deep copy. + pub announcements: Arc>, author: Option, refreshing: bool, refresh_dirty: bool, + /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. + debouncing: bool, tasks: Vec>>, _subscription: Subscription, } @@ -42,10 +51,11 @@ impl RepoListStore { }); let mut store = Self { - announcements: Vec::new(), + announcements: Arc::new(Vec::new()), author, refreshing: false, refresh_dirty: false, + debouncing: false, _subscription: subscription, tasks: Vec::new(), }; @@ -78,14 +88,34 @@ impl RepoListStore { /// Re-query the local database. Latest announcement per repository wins. /// - /// Debounced: concurrent requests are coalesced into a single re-query - /// after the running one finishes. The query and processing run on a - /// background thread; only the results are applied on the main thread. + /// Debounced: a short delay collapses bursts of requests (e.g. sync + /// progress ticks), and requests that arrive while a query is running + /// are folded into one follow-up query. The query and processing run on + /// a background thread; only the results are applied on the main thread. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; return; } + if self.debouncing { + return; + } + self.debouncing = true; + + let task = cx.spawn(async move |this, cx| { + cx.background_executor().timer(REFRESH_DEBOUNCE).await; + + this.update(cx, |this, cx| { + this.debouncing = false; + this.run_refresh(cx); + }) + }); + + self.tasks.push(task); + } + + /// One query + apply cycle (debounced entry point). + fn run_refresh(&mut self, cx: &mut Context) { self.refreshing = true; let client = Backend::global(cx).read(cx).client(); @@ -136,7 +166,7 @@ impl RepoListStore { }; let again = this.update(cx, |this, cx| { - this.announcements = announcements; + this.announcements = Arc::new(announcements); cx.notify(); this.refreshing = false; -- 2.54.0 From e2ec35a673de0ccc1eef7c636211dc0582a34496 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 6 Aug 2026 16:33:42 +0700 Subject: [PATCH 13/64] update nostr sdk --- Cargo.lock | 199 ++++++++++++++++++-------------- Cargo.toml | 14 +-- crates/signed_state/src/repo.rs | 5 +- 3 files changed, 122 insertions(+), 96 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e14dad8..102d8d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,9 +155,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -188,9 +188,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -492,9 +492,9 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa9ef232c1731ddbe4a306b5b5dcf9e50a924d6bd086d4784939d14f8e78daa" +checksum = "2c713e1f14c7b82e32ea159af1c6e2f070cfadbdf23fb2512acce9af0a26f1a2" dependencies = [ "futures", "futures-util", @@ -690,7 +690,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "serde", "unicode-normalization", ] @@ -765,6 +765,17 @@ checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", "hex-conservative 0.2.2", +] + +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals", + "hex-conservative 1.2.0", "serde", ] @@ -1222,7 +1233,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "gpui_util", "indexmap", @@ -1505,9 +1516,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] @@ -1665,7 +1676,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "proc-macro2", "quote", @@ -1833,9 +1844,9 @@ dependencies = [ [[package]] name = "encoding_rs_io" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" dependencies = [ "encoding_rs", ] @@ -3285,9 +3296,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -3377,7 +3388,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "accesskit", "anyhow", @@ -3444,6 +3455,7 @@ dependencies = [ "sum_tree", "taffy", "thiserror 2.0.19", + "tracing", "ttf-parser", "url", "usvg 0.46.0", @@ -3454,12 +3466,13 @@ dependencies = [ "windows 0.61.3", "zed-font-kit", "zed-scap", + "ztracing", ] [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#88f102d13654fe25aa2fede076274b6b751a3704" +source = "git+https://github.com/longbridge/gpui-component#3067deae83a237a4485e79bd449acd84ca890c23" dependencies = [ "aho-corasick", "anyhow", @@ -3507,7 +3520,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#88f102d13654fe25aa2fede076274b6b751a3704" +source = "git+https://github.com/longbridge/gpui-component#3067deae83a237a4485e79bd449acd84ca890c23" dependencies = [ "anyhow", "gpui", @@ -3521,7 +3534,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#88f102d13654fe25aa2fede076274b6b751a3704" +source = "git+https://github.com/longbridge/gpui-component#3067deae83a237a4485e79bd449acd84ca890c23" dependencies = [ "proc-macro2", "quote", @@ -3531,7 +3544,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "accesskit", "accesskit_unix", @@ -3583,7 +3596,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "accesskit", "accesskit_macos", @@ -3632,7 +3645,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3643,7 +3656,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "console_error_panic_hook", "gpui", @@ -3656,7 +3669,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "schemars", "serde", @@ -3666,7 +3679,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "anyhow", "log", @@ -3676,7 +3689,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3699,7 +3712,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "anyhow", "bytemuck", @@ -3729,7 +3742,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "accesskit", "accesskit_windows", @@ -4048,7 +4061,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "anyhow", "async-compression", @@ -4068,7 +4081,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -4278,9 +4291,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.31" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -4673,9 +4686,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -5043,7 +5056,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "anyhow", "bindgen", @@ -5258,14 +5271,14 @@ dependencies = [ [[package]] name = "nostr" -version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +version = "0.45.0" +source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" dependencies = [ "aes", "base64", "bech32", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 1.2.0", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -5279,12 +5292,13 @@ dependencies = [ "unicode-normalization", "universal-time", "url", + "zeroize", ] [[package]] name = "nostr-connect" -version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +version = "0.45.0" +source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" dependencies = [ "async-utility", "futures-core", @@ -5297,19 +5311,17 @@ dependencies = [ [[package]] name = "nostr-database" -version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +version = "0.45.0" +source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" dependencies = [ - "btreecap", - "flatbuffers", "nostr", "opaquerr", ] [[package]] name = "nostr-gossip" -version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +version = "0.45.0" +source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" dependencies = [ "nostr", "opaquerr", @@ -5317,8 +5329,8 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" -version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +version = "0.45.0" +source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" dependencies = [ "indexmap", "lru", @@ -5329,10 +5341,11 @@ dependencies = [ [[package]] name = "nostr-lmdb" -version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +version = "0.45.0" +source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" dependencies = [ "async-utility", + "flatbuffers", "flume 0.12.0", "heed", "nostr", @@ -5343,8 +5356,8 @@ dependencies = [ [[package]] name = "nostr-memory" -version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +version = "0.45.0" +source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" dependencies = [ "btreecap", "nostr", @@ -5354,8 +5367,8 @@ dependencies = [ [[package]] name = "nostr-sdk" -version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +version = "0.45.0" +source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" dependencies = [ "async-utility", "async-wsocket", @@ -5940,15 +5953,15 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "opaquerr" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55a7b7f0accc9fdd8e514f122a697adf824d55de481ca26c31ff8f9a8e813c5" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" [[package]] name = "open" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" dependencies = [ "is-wsl", "libc", @@ -6091,7 +6104,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "collections", "serde", @@ -6874,7 +6887,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "derive_refineable", ] @@ -6893,9 +6906,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6957,7 +6970,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "anyhow", "bytes", @@ -7358,7 +7371,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "async-task", "backtrace", @@ -7455,7 +7468,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "rand 0.8.7", "secp256k1-sys", ] @@ -8070,7 +8083,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "heapless 0.9.3", "log", @@ -8081,15 +8094,15 @@ dependencies = [ [[package]] name = "sval" -version = "2.21.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a370f3cd0422964fd9a19b6516f048527738e6ec50b1b0ff79b460b468390" +checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d" [[package]] name = "sval_buffer" -version = "2.21.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111e6e2dab25782acb41b281263f5f60d6f56a2ba0b3b076187ad23a1552544d" +checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032" dependencies = [ "sval", "sval_ref", @@ -8098,18 +8111,18 @@ dependencies = [ [[package]] name = "sval_dynamic" -version = "2.21.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590ac4ebd299740eac453162302a494e6d5b3be243feab8bf07d0d4331b6b2b3" +checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.21.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b964d6d917267355d7f343c515b908586e5b4aeeada328738f703452f9412d6" +checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243" dependencies = [ "itoa", "ryu", @@ -8118,9 +8131,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.21.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea35e4d6b997acc7dc4786ab782f6a6c5000efe4ae93a2db89cf350775fe5fe" +checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95" dependencies = [ "itoa", "ryu", @@ -8129,9 +8142,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.21.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a73195ebd4b3e5866db2e1b75f3f383657ad5abc600c646d69b4f064fee89154" +checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f" dependencies = [ "sval", "sval_buffer", @@ -8140,18 +8153,18 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.21.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ef28df9d586bfd15115286651337cb5645f8c203160d22d2d1a20cf13c428c" +checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.21.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d72fbe6537e88878f10237bde71ae4a1b65b2bf54bdf274abc566fc696334c92" +checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b" dependencies = [ "serde_core", "sval", @@ -9059,7 +9072,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "perf", "quote", @@ -9397,9 +9410,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6eb50cc7b133f0c7720a64661fbd8e520b882f8ab9015deea5e00d5cd809c4" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ "jni 0.22.4", "log", @@ -9510,6 +9523,7 @@ dependencies = [ "thiserror 2.0.19", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -9534,6 +9548,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "29.0.4" @@ -10812,7 +10835,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "anyhow", "chrono", @@ -10829,7 +10852,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" dependencies = [ "tracing", "tracing-subscriber", @@ -10840,7 +10863,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" +source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 2281829..7b1755b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,13 +20,13 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } gpui-component = { git = "https://github.com/longbridge/gpui-component" } -nostr = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215", features = ["nip59", "nip49", "nip44", "os-rng"] } -nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } -nostr-memory = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } -nostr-blossom = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } -nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } -nostr-connect = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } -nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } +nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } +nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } +nostr-memory = { git = "https://github.com/rust-nostr/nostr" } +nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } +nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } +nostr-connect = { git = "https://github.com/rust-nostr/nostr" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } gix = { version = "0.86", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 74bd0d2..5d189c8 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -315,7 +315,10 @@ impl RepoStore { } } -fn latest(events: Events) -> Option { +fn latest(events: I) -> Option +where + I: IntoIterator, +{ events.into_iter().max_by_key(|e| e.created_at) } -- 2.54.0 From b137f54a664e922fa27a98219fad9ccc6adefc9d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 7 Aug 2026 07:24:33 +0700 Subject: [PATCH 14/64] add passphrase dialog --- crates/signed_state/src/backend.rs | 78 +++++++++- crates/workspace/src/views/mod.rs | 2 +- ...rt_identity_dialog.rs => import_dialog.rs} | 0 crates/workspace/src/views/sidebar/mod.rs | 5 +- .../src/views/sidebar/passphrase_dialog.rs | 143 ++++++++++++++++++ crates/workspace/src/workspace.rs | 20 +++ 6 files changed, 241 insertions(+), 7 deletions(-) rename crates/workspace/src/views/sidebar/{import_identity_dialog.rs => import_dialog.rs} (100%) create mode 100644 crates/workspace/src/views/sidebar/passphrase_dialog.rs diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 4e43e34..f5172dd 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -34,6 +34,9 @@ pub const INDEXER_RELAYS: [&str; 3] = [ pub enum BackendEvent { /// User has no signer configured. SignerRequired, + /// The stored identity is NIP-49 encrypted (`ncryptsec1...`); a + /// passphrase is required to decrypt it before the session can resume. + PassphraseRequired, /// The signer has changed (login/logout/account switch). SignerChanged, /// Relay bootstrap finished. @@ -76,6 +79,9 @@ pub struct Backend { current_user: Option, connected: bool, sync_progress: Option<(u64, u64)>, + /// Whether the stored credential is NIP-49 encrypted and a passphrase + /// is still needed to resume the session. + passphrase_required: bool, tasks: Vec>>, } @@ -125,6 +131,7 @@ impl Backend { current_user: None, connected: false, sync_progress: None, + passphrase_required: false, tasks: vec![pump], }; @@ -171,7 +178,9 @@ impl Backend { } /// Restore the saved session from the keyring. Emits - /// [`BackendEvent::SignerRequired`] if no credential is stored. + /// [`BackendEvent::SignerRequired`] if no credential is stored, or + /// [`BackendEvent::PassphraseRequired`] if the stored identity is + /// NIP-49 encrypted. pub fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { cx.emit(BackendEvent::SignerRequired); @@ -206,9 +215,12 @@ impl Backend { this.update(cx, |this, cx| this.set_signer(signer, cx))?; } else if content.starts_with("ncryptsec1") { // Encrypted identity: a passphrase is required to - // decrypt it, which is not implemented yet. - log::warn!("stored identity is ncryptsec-encrypted; passphrase restore is not implemented"); - this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; + // decrypt it before the session can resume. + log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase"); + this.update(cx, |this, cx| { + this.passphrase_required = true; + cx.emit(BackendEvent::PassphraseRequired); + })?; } else { this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; } @@ -228,6 +240,56 @@ impl Backend { })); } + /// Decrypt the NIP-49 encrypted credential stored in the keyring with + /// the given passphrase and resume the session. + /// + /// The scrypt decryption runs off the UI thread. The returned receiver + /// yields the public key on success, or the failure reason (e.g. wrong + /// passphrase), so callers can render inline errors. + pub fn restore_with_passphrase( + &mut self, + password: &str, + cx: &mut Context, + ) -> flume::Receiver> { + let (tx, rx) = flume::bounded(1); + + let password = password.to_owned(); + let user = cx.read_credentials(USER_KEYRING); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = async { + let content = user + .await? + .map(|(_username, secret)| String::from_utf8(secret)) + .transpose()? + .ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?; + + if !content.starts_with("ncryptsec1") { + Err(anyhow!("stored credential is not passphrase-encrypted"))?; + } + + let decrypt_task = cx.background_spawn(async move { + let encrypted = EncryptedSecretKey::from_bech32(&content)?; + let secret = encrypted.decrypt(&password)?; + Ok::<_, Error>(Keys::new(secret)) + }); + + let keys = decrypt_task.await?; + let public_key = keys.public_key(); + + this.update(cx, |this, cx| this.set_signer(keys, cx))?; + + Ok::<_, Error>(public_key) + } + .await; + + tx.send_async(result).await.ok(); + Ok(()) + })); + + rx + } + /// Create a new identity: generate keys, encrypt the secret key with the /// passphrase (NIP-49) and persist it in the keyring, then publish the /// user's NIP-65 relay list, metadata and grasp list. @@ -448,6 +510,7 @@ impl Backend { this.update(cx, |this, cx| { this.signer.swap_inner(Keys::generate()); this.current_user = None; + this.passphrase_required = false; cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerRequired); cx.notify(); @@ -510,6 +573,12 @@ impl Backend { self.current_user } + /// Whether the stored credential is NIP-49 encrypted and a passphrase + /// is still needed to resume the session. + pub fn passphrase_required(&self) -> bool { + self.passphrase_required + } + /// Surface an error message through [`BackendEvent::Error`]. pub fn emit_error(&mut self, message: impl Into, cx: &mut Context) { cx.emit(BackendEvent::error(message)); @@ -540,6 +609,7 @@ impl Backend { this.update(cx, |this, cx| { this.signer.swap_inner(new_signer); this.current_user = Some(public_key); + this.passphrase_required = false; this.bootstrap_user(public_key, cx); cx.emit(BackendEvent::SignerChanged); cx.notify(); diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 3a96c03..4ae1b91 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,5 +1,5 @@ mod repo_list; -mod sidebar; +pub(crate) mod sidebar; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/sidebar/import_identity_dialog.rs b/crates/workspace/src/views/sidebar/import_dialog.rs similarity index 100% rename from crates/workspace/src/views/sidebar/import_identity_dialog.rs rename to crates/workspace/src/views/sidebar/import_dialog.rs diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index b0b276d..081c1ea 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -13,8 +13,9 @@ use signed_state::{Backend, BackendEvent}; use super::RepoListView; -mod import_identity_dialog; +mod import_dialog; mod onboarding_dialog; +pub(crate) mod passphrase_dialog; use self::onboarding_dialog::OnboardingState; @@ -95,7 +96,7 @@ impl SidebarPanel { /// Show the Import Identity dialog. fn open_import(&mut self, window: &mut Window, cx: &mut Context) { - import_identity_dialog::open(window, cx); + import_dialog::open(window, cx); } } diff --git a/crates/workspace/src/views/sidebar/passphrase_dialog.rs b/crates/workspace/src/views/sidebar/passphrase_dialog.rs new file mode 100644 index 0000000..8738b96 --- /dev/null +++ b/crates/workspace/src/views/sidebar/passphrase_dialog.rs @@ -0,0 +1,143 @@ +use gpui::prelude::*; +use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div, px}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; +use gpui_component::form::{field, v_form}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::{ActiveTheme, Disableable, WindowExt}; +use signed_state::Backend; + +/// Shared state for the passphrase dialog, so async results can be rendered. +#[derive(Default)] +pub struct PassphraseState { + pub busy: bool, + pub error: Option, + /// Keeps the Enter-to-submit subscription alive while the dialog is open. + _enter_subscription: Option, +} + +/// Open the dialog asking for the passphrase that protects the stored +/// NIP-49 encrypted identity (`ncryptsec1...`). +/// +/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`]. +pub fn open(window: &mut Window, cx: &mut App) { + let pass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Passphrase to unlock your identity") + .masked(true) + }); + + let handle = window.window_handle(); + let state = cx.new(|_| PassphraseState::default()); + + // Enter in the passphrase field submits, same as the Unlock button. + let enter_pass_input = pass_input.clone(); + let enter_state = state.clone(); + let enter_subscription = cx.subscribe(&pass_input, move |_input, event, cx| { + if matches!(event, InputEvent::PressEnter { .. }) { + unlock(&enter_pass_input, &enter_state, &handle, cx); + } + }); + + state.update(cx, |state, _| { + state._enter_subscription = Some(enter_subscription) + }); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let pass_input = pass_input.clone(); + let state = state.clone(); + + dialog + .width(px(420.)) + .margin_top(px(50.)) + .content(move |content, _window, cx| { + let busy = state.read(cx).busy; + let error = state.read(cx).error.clone(); + + content + .child( + DialogHeader::new() + .child(DialogTitle::new().child("Unlock your identity")) + .child( + DialogDescription::new() + .child("Enter the passphrase used to encrypt this identity."), + ), + ) + .child( + v_form().child( + field() + .label("Passphrase") + .required(true) + .child(Input::new(&pass_input)), + ), + ) + .children(error.map(|message| { + div().text_sm().text_color(cx.theme().danger).child(message) + })) + .child( + DialogFooter::new().justify_end().child( + Button::new("unlock") + .primary() + .label("Unlock") + .loading(busy) + .disabled(busy) + .on_click({ + let pass_input = pass_input.clone(); + let state = state.clone(); + + move |_ev, _window, cx| { + unlock(&pass_input, &state, &handle, cx); + } + }), + ), + ) + }) + }); +} + +/// Submit the passphrase to the backend. On success the dialog is closed; +/// on failure the error is rendered inline and the dialog stays open. +fn unlock( + pass_input: &Entity, + state: &Entity, + handle: &AnyWindowHandle, + cx: &mut App, +) { + let backend = Backend::global(cx); + let pass = pass_input.read(cx).value().to_string(); + + if pass.is_empty() { + state.update(cx, |state, _| { + state.error = Some("Passphrase must not be empty".into()); + }); + return; + } + + state.update(cx, |state, _| { + state.busy = true; + state.error = None; + }); + + let rx = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx)); + + let handle = *handle; + let state = state.clone(); + + cx.spawn(async move |cx| match rx.recv_async().await { + Ok(Ok(_)) => { + cx.update_window(handle, |_, window, cx| window.close_dialog(cx)) + .ok(); + } + Ok(Err(e)) => { + cx.update_window(handle, |_, _window, cx| { + state.update(cx, |state, _| { + state.busy = false; + state.error = Some(e.to_string().into()); + }); + }) + .ok(); + } + Err(_) => {} + }) + .detach(); +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0dae153..42d54d4 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7,12 +7,14 @@ use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex}; use signed_state::{Backend, BackendEvent}; use crate::views::SidebarPanel; +use crate::views::sidebar::passphrase_dialog; /// Root view of the app: title bar, dock area, status bar. pub struct Workspace { dock: Entity, status: SharedString, _subscription: Subscription, + _passphrase_subscription: Subscription, } impl Workspace { @@ -59,10 +61,28 @@ impl Workspace { cx.notify(); }); + // Ask for the passphrase when the stored identity is NIP-49 + // encrypted. Subscribed via the window, since opening a dialog + // needs one. + let passphrase_subscription = + window.subscribe(&backend, cx, |_backend, event, window, cx| { + if matches!(event, BackendEvent::PassphraseRequired) { + passphrase_dialog::open(window, cx); + } + }); + + // The event may have fired before this window existed (the backend + // is initialized before the first window opens); fall back to the + // backend state in that case. + if backend.read(cx).passphrase_required() { + passphrase_dialog::open(window, cx); + } + Self { dock, status, _subscription: subscription, + _passphrase_subscription: passphrase_subscription, } } } -- 2.54.0 From a7341418379dc1246fd864092fa273a726e2ff67 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 7 Aug 2026 08:11:54 +0700 Subject: [PATCH 15/64] add icons --- Cargo.lock | 11 ++ crates/assets/Cargo.toml | 11 ++ crates/assets/assets/.keep | 0 crates/assets/assets/icons/README.md | 115 ++++++++++++++++++ crates/assets/assets/icons/a-large-small.svg | 1 + crates/assets/assets/icons/arrow-down.svg | 1 + crates/assets/assets/icons/arrow-left.svg | 1 + crates/assets/assets/icons/arrow-right.svg | 1 + crates/assets/assets/icons/arrow-up.svg | 1 + crates/assets/assets/icons/asterisk.svg | 1 + .../assets/assets/icons/battery-charging.svg | 1 + crates/assets/assets/icons/battery-full.svg | 14 +++ crates/assets/assets/icons/battery-low.svg | 1 + crates/assets/assets/icons/battery-medium.svg | 18 +++ .../assets/assets/icons/battery-warning.svg | 16 +++ crates/assets/assets/icons/battery.svg | 1 + crates/assets/assets/icons/bell.svg | 1 + crates/assets/assets/icons/book-open.svg | 1 + crates/assets/assets/icons/bot.svg | 1 + crates/assets/assets/icons/building-2.svg | 1 + crates/assets/assets/icons/calendar.svg | 1 + crates/assets/assets/icons/case-sensitive.svg | 1 + crates/assets/assets/icons/chart-pie.svg | 1 + crates/assets/assets/icons/check.svg | 1 + crates/assets/assets/icons/chevron-down.svg | 1 + crates/assets/assets/icons/chevron-left.svg | 1 + crates/assets/assets/icons/chevron-right.svg | 1 + crates/assets/assets/icons/chevron-up.svg | 1 + .../assets/assets/icons/chevrons-up-down.svg | 1 + crates/assets/assets/icons/circle-check.svg | 1 + crates/assets/assets/icons/circle-user.svg | 1 + crates/assets/assets/icons/circle-x.svg | 1 + crates/assets/assets/icons/close.svg | 1 + crates/assets/assets/icons/copy.svg | 1 + crates/assets/assets/icons/cpu.svg | 1 + crates/assets/assets/icons/dash.svg | 1 + crates/assets/assets/icons/delete.svg | 1 + .../assets/assets/icons/ellipsis-vertical.svg | 1 + crates/assets/assets/icons/ellipsis.svg | 1 + crates/assets/assets/icons/external-link.svg | 1 + crates/assets/assets/icons/eye-off.svg | 1 + crates/assets/assets/icons/eye.svg | 1 + crates/assets/assets/icons/file.svg | 1 + crates/assets/assets/icons/folder-closed.svg | 1 + crates/assets/assets/icons/folder-open.svg | 1 + crates/assets/assets/icons/folder.svg | 1 + crates/assets/assets/icons/frame.svg | 22 ++++ .../assets/icons/gallery-vertical-end.svg | 18 +++ crates/assets/assets/icons/github.svg | 1 + crates/assets/assets/icons/globe.svg | 1 + crates/assets/assets/icons/hard-drive.svg | 1 + crates/assets/assets/icons/heart-off.svg | 1 + crates/assets/assets/icons/heart.svg | 1 + crates/assets/assets/icons/inbox.svg | 1 + crates/assets/assets/icons/info.svg | 1 + crates/assets/assets/icons/inspector.svg | 18 +++ .../assets/assets/icons/layout-dashboard.svg | 1 + crates/assets/assets/icons/loader-circle.svg | 1 + crates/assets/assets/icons/loader.svg | 1 + crates/assets/assets/icons/map.svg | 1 + crates/assets/assets/icons/maximize.svg | 1 + crates/assets/assets/icons/memory-stick.svg | 22 ++++ crates/assets/assets/icons/menu.svg | 1 + crates/assets/assets/icons/minimize.svg | 1 + crates/assets/assets/icons/minus.svg | 1 + crates/assets/assets/icons/moon.svg | 1 + crates/assets/assets/icons/network.svg | 1 + crates/assets/assets/icons/palette.svg | 1 + .../assets/assets/icons/panel-bottom-open.svg | 1 + crates/assets/assets/icons/panel-bottom.svg | 1 + .../assets/assets/icons/panel-left-close.svg | 14 +++ .../assets/assets/icons/panel-left-open.svg | 1 + crates/assets/assets/icons/panel-left.svg | 1 + .../assets/assets/icons/panel-right-close.svg | 14 +++ .../assets/assets/icons/panel-right-open.svg | 1 + crates/assets/assets/icons/panel-right.svg | 1 + crates/assets/assets/icons/pause.svg | 1 + crates/assets/assets/icons/play.svg | 1 + crates/assets/assets/icons/plus.svg | 1 + crates/assets/assets/icons/redo-2.svg | 1 + crates/assets/assets/icons/redo.svg | 1 + crates/assets/assets/icons/replace.svg | 1 + crates/assets/assets/icons/resize-corner.svg | 1 + crates/assets/assets/icons/search.svg | 1 + crates/assets/assets/icons/settings-2.svg | 1 + crates/assets/assets/icons/settings.svg | 1 + crates/assets/assets/icons/sort-ascending.svg | 1 + .../assets/assets/icons/sort-descending.svg | 1 + .../assets/assets/icons/square-terminal.svg | 1 + crates/assets/assets/icons/star-fill.svg | 1 + crates/assets/assets/icons/star-off.svg | 1 + crates/assets/assets/icons/star.svg | 1 + crates/assets/assets/icons/sun.svg | 1 + crates/assets/assets/icons/thumbs-down.svg | 1 + crates/assets/assets/icons/thumbs-up.svg | 1 + crates/assets/assets/icons/triangle-alert.svg | 1 + crates/assets/assets/icons/undo-2.svg | 1 + crates/assets/assets/icons/undo.svg | 1 + crates/assets/assets/icons/user.svg | 1 + crates/assets/assets/icons/window-close.svg | 1 + .../assets/assets/icons/window-maximize.svg | 1 + .../assets/assets/icons/window-minimize.svg | 1 + crates/assets/assets/icons/window-restore.svg | 1 + crates/assets/src/lib.rs | 47 +++++++ crates/signed_nostr/src/backend.rs | 6 +- desktop/Cargo.toml | 1 + desktop/src/main.rs | 6 + 107 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 crates/assets/Cargo.toml create mode 100644 crates/assets/assets/.keep create mode 100644 crates/assets/assets/icons/README.md create mode 100644 crates/assets/assets/icons/a-large-small.svg create mode 100644 crates/assets/assets/icons/arrow-down.svg create mode 100644 crates/assets/assets/icons/arrow-left.svg create mode 100644 crates/assets/assets/icons/arrow-right.svg create mode 100644 crates/assets/assets/icons/arrow-up.svg create mode 100644 crates/assets/assets/icons/asterisk.svg create mode 100644 crates/assets/assets/icons/battery-charging.svg create mode 100644 crates/assets/assets/icons/battery-full.svg create mode 100644 crates/assets/assets/icons/battery-low.svg create mode 100644 crates/assets/assets/icons/battery-medium.svg create mode 100644 crates/assets/assets/icons/battery-warning.svg create mode 100644 crates/assets/assets/icons/battery.svg create mode 100644 crates/assets/assets/icons/bell.svg create mode 100644 crates/assets/assets/icons/book-open.svg create mode 100644 crates/assets/assets/icons/bot.svg create mode 100644 crates/assets/assets/icons/building-2.svg create mode 100644 crates/assets/assets/icons/calendar.svg create mode 100644 crates/assets/assets/icons/case-sensitive.svg create mode 100644 crates/assets/assets/icons/chart-pie.svg create mode 100644 crates/assets/assets/icons/check.svg create mode 100644 crates/assets/assets/icons/chevron-down.svg create mode 100644 crates/assets/assets/icons/chevron-left.svg create mode 100644 crates/assets/assets/icons/chevron-right.svg create mode 100644 crates/assets/assets/icons/chevron-up.svg create mode 100644 crates/assets/assets/icons/chevrons-up-down.svg create mode 100644 crates/assets/assets/icons/circle-check.svg create mode 100644 crates/assets/assets/icons/circle-user.svg create mode 100644 crates/assets/assets/icons/circle-x.svg create mode 100644 crates/assets/assets/icons/close.svg create mode 100644 crates/assets/assets/icons/copy.svg create mode 100644 crates/assets/assets/icons/cpu.svg create mode 100644 crates/assets/assets/icons/dash.svg create mode 100644 crates/assets/assets/icons/delete.svg create mode 100644 crates/assets/assets/icons/ellipsis-vertical.svg create mode 100644 crates/assets/assets/icons/ellipsis.svg create mode 100644 crates/assets/assets/icons/external-link.svg create mode 100644 crates/assets/assets/icons/eye-off.svg create mode 100644 crates/assets/assets/icons/eye.svg create mode 100644 crates/assets/assets/icons/file.svg create mode 100644 crates/assets/assets/icons/folder-closed.svg create mode 100644 crates/assets/assets/icons/folder-open.svg create mode 100644 crates/assets/assets/icons/folder.svg create mode 100644 crates/assets/assets/icons/frame.svg create mode 100644 crates/assets/assets/icons/gallery-vertical-end.svg create mode 100644 crates/assets/assets/icons/github.svg create mode 100644 crates/assets/assets/icons/globe.svg create mode 100644 crates/assets/assets/icons/hard-drive.svg create mode 100644 crates/assets/assets/icons/heart-off.svg create mode 100644 crates/assets/assets/icons/heart.svg create mode 100644 crates/assets/assets/icons/inbox.svg create mode 100644 crates/assets/assets/icons/info.svg create mode 100644 crates/assets/assets/icons/inspector.svg create mode 100644 crates/assets/assets/icons/layout-dashboard.svg create mode 100644 crates/assets/assets/icons/loader-circle.svg create mode 100644 crates/assets/assets/icons/loader.svg create mode 100644 crates/assets/assets/icons/map.svg create mode 100644 crates/assets/assets/icons/maximize.svg create mode 100644 crates/assets/assets/icons/memory-stick.svg create mode 100644 crates/assets/assets/icons/menu.svg create mode 100644 crates/assets/assets/icons/minimize.svg create mode 100644 crates/assets/assets/icons/minus.svg create mode 100644 crates/assets/assets/icons/moon.svg create mode 100644 crates/assets/assets/icons/network.svg create mode 100644 crates/assets/assets/icons/palette.svg create mode 100644 crates/assets/assets/icons/panel-bottom-open.svg create mode 100644 crates/assets/assets/icons/panel-bottom.svg create mode 100644 crates/assets/assets/icons/panel-left-close.svg create mode 100644 crates/assets/assets/icons/panel-left-open.svg create mode 100644 crates/assets/assets/icons/panel-left.svg create mode 100644 crates/assets/assets/icons/panel-right-close.svg create mode 100644 crates/assets/assets/icons/panel-right-open.svg create mode 100644 crates/assets/assets/icons/panel-right.svg create mode 100644 crates/assets/assets/icons/pause.svg create mode 100644 crates/assets/assets/icons/play.svg create mode 100644 crates/assets/assets/icons/plus.svg create mode 100644 crates/assets/assets/icons/redo-2.svg create mode 100644 crates/assets/assets/icons/redo.svg create mode 100644 crates/assets/assets/icons/replace.svg create mode 100644 crates/assets/assets/icons/resize-corner.svg create mode 100644 crates/assets/assets/icons/search.svg create mode 100644 crates/assets/assets/icons/settings-2.svg create mode 100644 crates/assets/assets/icons/settings.svg create mode 100644 crates/assets/assets/icons/sort-ascending.svg create mode 100644 crates/assets/assets/icons/sort-descending.svg create mode 100644 crates/assets/assets/icons/square-terminal.svg create mode 100644 crates/assets/assets/icons/star-fill.svg create mode 100644 crates/assets/assets/icons/star-off.svg create mode 100644 crates/assets/assets/icons/star.svg create mode 100644 crates/assets/assets/icons/sun.svg create mode 100644 crates/assets/assets/icons/thumbs-down.svg create mode 100644 crates/assets/assets/icons/thumbs-up.svg create mode 100644 crates/assets/assets/icons/triangle-alert.svg create mode 100644 crates/assets/assets/icons/undo-2.svg create mode 100644 crates/assets/assets/icons/undo.svg create mode 100644 crates/assets/assets/icons/user.svg create mode 100644 crates/assets/assets/icons/window-close.svg create mode 100644 crates/assets/assets/icons/window-maximize.svg create mode 100644 crates/assets/assets/icons/window-minimize.svg create mode 100644 crates/assets/assets/icons/window-restore.svg create mode 100644 crates/assets/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 102d8d9..71b2813 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "assets" +version = "1.0.0" +dependencies = [ + "anyhow", + "gpui", + "log", + "rust-embed", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -7754,6 +7764,7 @@ dependencies = [ name = "signed" version = "1.0.0" dependencies = [ + "assets", "gpui", "gpui-component", "gpui_linux", diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml new file mode 100644 index 0000000..3e3d11f --- /dev/null +++ b/crates/assets/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "assets" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +gpui.workspace = true +anyhow.workspace = true +log.workspace = true +rust-embed.workspace = true diff --git a/crates/assets/assets/.keep b/crates/assets/assets/.keep new file mode 100644 index 0000000..e69de29 diff --git a/crates/assets/assets/icons/README.md b/crates/assets/assets/icons/README.md new file mode 100644 index 0000000..62d9f35 --- /dev/null +++ b/crates/assets/assets/icons/README.md @@ -0,0 +1,115 @@ +# Icons + +Icon set for the Signed app. Each file mirrors the name of the equivalent +[gpui-component](https://github.com/longbridge/gpui-component/tree/main/crates/assets/assets/icons) +icon, but the artwork comes from +[Remix Icon](https://remixicon.com/) v4.9.1 (Apache-2.0) unless noted. + +All SVGs are 24×24 (`viewBox="0 0 24 24"`) and use `fill="currentColor"`, so +gpui renders them with the requested color. + +## Mapping (gpui-component icon → Remix Icon) + +| File | Remix Icon | +| --- | --- | +| `a-large-small.svg` | `font-size` | +| `arrow-down.svg` | `arrow-down-line` | +| `arrow-left.svg` | `arrow-left-line` | +| `arrow-right.svg` | `arrow-right-line` | +| `arrow-up.svg` | `arrow-up-line` | +| `asterisk.svg` | `asterisk` | +| `battery.svg` | `battery-line` | +| `battery-charging.svg` | `battery-charge-line` | +| `battery-low.svg` | `battery-low-line` | +| `bell.svg` | `bell-line` | +| `book-open.svg` | `book-open-line` | +| `bot.svg` | `robot-line` | +| `building-2.svg` | `building-2-line` | +| `calendar.svg` | `calendar-line` | +| `chart-pie.svg` | `pie-chart-2-line` | +| `check.svg` | `check-line` | +| `chevron-down.svg` | `arrow-down-s-line` | +| `chevron-left.svg` | `arrow-left-s-line` | +| `chevron-right.svg` | `arrow-right-s-line` | +| `chevron-up.svg` | `arrow-up-s-line` | +| `chevrons-up-down.svg` | `expand-up-down-line` | +| `circle-check.svg` | `checkbox-circle-line` | +| `circle-user.svg` | `account-circle-line` | +| `circle-x.svg` | `close-circle-line` | +| `close.svg` | `close-line` | +| `copy.svg` | `file-copy-line` | +| `cpu.svg` | `cpu-line` | +| `dash.svg` | `subtract-line` | +| `delete.svg` | `delete-bin-line` | +| `ellipsis.svg` | `more-line` | +| `ellipsis-vertical.svg` | `more-2-line` | +| `external-link.svg` | `external-link-line` | +| `eye.svg` | `eye-line` | +| `eye-off.svg` | `eye-off-line` | +| `file.svg` | `file-line` | +| `folder.svg` | `folder-line` | +| `folder-closed.svg` | `folder-2-line` | +| `folder-open.svg` | `folder-open-line` | +| `github.svg` | `github-line` | +| `globe.svg` | `global-line` | +| `hard-drive.svg` | `hard-drive-2-line` | +| `heart.svg` | `heart-line` | +| `inbox.svg` | `inbox-line` | +| `info.svg` | `information-line` | +| `layout-dashboard.svg` | `dashboard-line` | +| `loader.svg` | `loader-line` | +| `loader-circle.svg` | `loader-4-line` | +| `map.svg` | `map-2-line` | +| `maximize.svg` | `fullscreen-line` | +| `menu.svg` | `menu-line` | +| `minimize.svg` | `fullscreen-exit-line` | +| `minus.svg` | `subtract-line` | +| `moon.svg` | `moon-line` | +| `network.svg` | `share-line` | +| `palette.svg` | `palette-line` | +| `panel-bottom.svg` | `layout-bottom-line` | +| `panel-left.svg` | `layout-left-line` | +| `panel-right.svg` | `layout-right-line` | +| `pause.svg` | `pause-line` | +| `play.svg` | `play-line` | +| `plus.svg` | `add-line` | +| `redo.svg` | `corner-up-right-line` | +| `redo-2.svg` | `arrow-go-forward-line` | +| `replace.svg` | `swap-box-line` | +| `resize-corner.svg` | `corner-right-down-line` | +| `search.svg` | `search-line` | +| `settings.svg` | `settings-line` | +| `settings-2.svg` | `equalizer-line` | +| `sort-ascending.svg` | `sort-asc` | +| `sort-descending.svg` | `sort-desc` | +| `square-terminal.svg` | `terminal-box-line` | +| `star.svg` | `star-line` | +| `star-fill.svg` | `star-fill` | +| `star-off.svg` | `star-off-line` | +| `sun.svg` | `sun-line` | +| `thumbs-down.svg` | `thumb-down-line` | +| `thumbs-up.svg` | `thumb-up-line` | +| `triangle-alert.svg` | `alert-line` | +| `undo.svg` | `corner-up-left-line` | +| `undo-2.svg` | `arrow-go-back-line` | +| `user.svg` | `user-line` | +| `window-close.svg` | `close-line` | +| `window-maximize.svg` | `checkbox-blank-line` | +| `window-minimize.svg` | `subtract-line` | +| `window-restore.svg` | `picture-in-picture-line` | + +## Kept as original (no Remix equivalent) + +These have no close Remix Icon counterpart and keep the original lucide +artwork from gpui-component: + +`battery-full.svg`, `battery-medium.svg`, `battery-warning.svg`, +`case-sensitive.svg`, `frame.svg`, `gallery-vertical-end.svg`, +`heart-off.svg`, `inspector.svg`, `memory-stick.svg`, +`panel-bottom-open.svg`, `panel-left-close.svg`, `panel-left-open.svg`, +`panel-right-close.svg`, `panel-right-open.svg` + +## Licensing + +- Remix Icon SVGs: [Apache-2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License) +- Lucide SVGs (kept originals): [ISC](https://github.com/lucide-icons/lucide/blob/main/LICENSE) diff --git a/crates/assets/assets/icons/a-large-small.svg b/crates/assets/assets/icons/a-large-small.svg new file mode 100644 index 0000000..17d60fc --- /dev/null +++ b/crates/assets/assets/icons/a-large-small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/arrow-down.svg b/crates/assets/assets/icons/arrow-down.svg new file mode 100644 index 0000000..3246720 --- /dev/null +++ b/crates/assets/assets/icons/arrow-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/arrow-left.svg b/crates/assets/assets/icons/arrow-left.svg new file mode 100644 index 0000000..81cf0eb --- /dev/null +++ b/crates/assets/assets/icons/arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/arrow-right.svg b/crates/assets/assets/icons/arrow-right.svg new file mode 100644 index 0000000..d859f2d --- /dev/null +++ b/crates/assets/assets/icons/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/arrow-up.svg b/crates/assets/assets/icons/arrow-up.svg new file mode 100644 index 0000000..241454c --- /dev/null +++ b/crates/assets/assets/icons/arrow-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/asterisk.svg b/crates/assets/assets/icons/asterisk.svg new file mode 100644 index 0000000..77417cd --- /dev/null +++ b/crates/assets/assets/icons/asterisk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/battery-charging.svg b/crates/assets/assets/icons/battery-charging.svg new file mode 100644 index 0000000..06d1b41 --- /dev/null +++ b/crates/assets/assets/icons/battery-charging.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/battery-full.svg b/crates/assets/assets/icons/battery-full.svg new file mode 100644 index 0000000..4279ecf --- /dev/null +++ b/crates/assets/assets/icons/battery-full.svg @@ -0,0 +1,14 @@ + diff --git a/crates/assets/assets/icons/battery-low.svg b/crates/assets/assets/icons/battery-low.svg new file mode 100644 index 0000000..963008b --- /dev/null +++ b/crates/assets/assets/icons/battery-low.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/battery-medium.svg b/crates/assets/assets/icons/battery-medium.svg new file mode 100644 index 0000000..65d6ac1 --- /dev/null +++ b/crates/assets/assets/icons/battery-medium.svg @@ -0,0 +1,18 @@ + diff --git a/crates/assets/assets/icons/battery-warning.svg b/crates/assets/assets/icons/battery-warning.svg new file mode 100644 index 0000000..aa35819 --- /dev/null +++ b/crates/assets/assets/icons/battery-warning.svg @@ -0,0 +1,16 @@ + diff --git a/crates/assets/assets/icons/battery.svg b/crates/assets/assets/icons/battery.svg new file mode 100644 index 0000000..d83ac0b --- /dev/null +++ b/crates/assets/assets/icons/battery.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/bell.svg b/crates/assets/assets/icons/bell.svg new file mode 100644 index 0000000..eadf6f6 --- /dev/null +++ b/crates/assets/assets/icons/bell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/book-open.svg b/crates/assets/assets/icons/book-open.svg new file mode 100644 index 0000000..5e07c2b --- /dev/null +++ b/crates/assets/assets/icons/book-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/bot.svg b/crates/assets/assets/icons/bot.svg new file mode 100644 index 0000000..9d3b6e0 --- /dev/null +++ b/crates/assets/assets/icons/bot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/building-2.svg b/crates/assets/assets/icons/building-2.svg new file mode 100644 index 0000000..7cc62c4 --- /dev/null +++ b/crates/assets/assets/icons/building-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/calendar.svg b/crates/assets/assets/icons/calendar.svg new file mode 100644 index 0000000..930d804 --- /dev/null +++ b/crates/assets/assets/icons/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/case-sensitive.svg b/crates/assets/assets/icons/case-sensitive.svg new file mode 100644 index 0000000..648134f --- /dev/null +++ b/crates/assets/assets/icons/case-sensitive.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/assets/icons/chart-pie.svg b/crates/assets/assets/icons/chart-pie.svg new file mode 100644 index 0000000..6eb096d --- /dev/null +++ b/crates/assets/assets/icons/chart-pie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/check.svg b/crates/assets/assets/icons/check.svg new file mode 100644 index 0000000..c0272c0 --- /dev/null +++ b/crates/assets/assets/icons/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/chevron-down.svg b/crates/assets/assets/icons/chevron-down.svg new file mode 100644 index 0000000..e1bc908 --- /dev/null +++ b/crates/assets/assets/icons/chevron-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/chevron-left.svg b/crates/assets/assets/icons/chevron-left.svg new file mode 100644 index 0000000..7f92ade --- /dev/null +++ b/crates/assets/assets/icons/chevron-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/chevron-right.svg b/crates/assets/assets/icons/chevron-right.svg new file mode 100644 index 0000000..94f2d24 --- /dev/null +++ b/crates/assets/assets/icons/chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/chevron-up.svg b/crates/assets/assets/icons/chevron-up.svg new file mode 100644 index 0000000..3a424df --- /dev/null +++ b/crates/assets/assets/icons/chevron-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/chevrons-up-down.svg b/crates/assets/assets/icons/chevrons-up-down.svg new file mode 100644 index 0000000..879deb1 --- /dev/null +++ b/crates/assets/assets/icons/chevrons-up-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/circle-check.svg b/crates/assets/assets/icons/circle-check.svg new file mode 100644 index 0000000..eaffe5e --- /dev/null +++ b/crates/assets/assets/icons/circle-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/circle-user.svg b/crates/assets/assets/icons/circle-user.svg new file mode 100644 index 0000000..b96c221 --- /dev/null +++ b/crates/assets/assets/icons/circle-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/circle-x.svg b/crates/assets/assets/icons/circle-x.svg new file mode 100644 index 0000000..2230aa0 --- /dev/null +++ b/crates/assets/assets/icons/circle-x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/close.svg b/crates/assets/assets/icons/close.svg new file mode 100644 index 0000000..4ee8e56 --- /dev/null +++ b/crates/assets/assets/icons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/copy.svg b/crates/assets/assets/icons/copy.svg new file mode 100644 index 0000000..84dfcd9 --- /dev/null +++ b/crates/assets/assets/icons/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/cpu.svg b/crates/assets/assets/icons/cpu.svg new file mode 100644 index 0000000..a69c58c --- /dev/null +++ b/crates/assets/assets/icons/cpu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/dash.svg b/crates/assets/assets/icons/dash.svg new file mode 100644 index 0000000..fcc1e94 --- /dev/null +++ b/crates/assets/assets/icons/dash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/delete.svg b/crates/assets/assets/icons/delete.svg new file mode 100644 index 0000000..e074d3a --- /dev/null +++ b/crates/assets/assets/icons/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/ellipsis-vertical.svg b/crates/assets/assets/icons/ellipsis-vertical.svg new file mode 100644 index 0000000..c48b99d --- /dev/null +++ b/crates/assets/assets/icons/ellipsis-vertical.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/ellipsis.svg b/crates/assets/assets/icons/ellipsis.svg new file mode 100644 index 0000000..52c69ea --- /dev/null +++ b/crates/assets/assets/icons/ellipsis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/external-link.svg b/crates/assets/assets/icons/external-link.svg new file mode 100644 index 0000000..20c991f --- /dev/null +++ b/crates/assets/assets/icons/external-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/eye-off.svg b/crates/assets/assets/icons/eye-off.svg new file mode 100644 index 0000000..0b53e64 --- /dev/null +++ b/crates/assets/assets/icons/eye-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/eye.svg b/crates/assets/assets/icons/eye.svg new file mode 100644 index 0000000..0100f61 --- /dev/null +++ b/crates/assets/assets/icons/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/file.svg b/crates/assets/assets/icons/file.svg new file mode 100644 index 0000000..7ed925e --- /dev/null +++ b/crates/assets/assets/icons/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/folder-closed.svg b/crates/assets/assets/icons/folder-closed.svg new file mode 100644 index 0000000..0c9430b --- /dev/null +++ b/crates/assets/assets/icons/folder-closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/folder-open.svg b/crates/assets/assets/icons/folder-open.svg new file mode 100644 index 0000000..f950fa2 --- /dev/null +++ b/crates/assets/assets/icons/folder-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/folder.svg b/crates/assets/assets/icons/folder.svg new file mode 100644 index 0000000..55c4342 --- /dev/null +++ b/crates/assets/assets/icons/folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/frame.svg b/crates/assets/assets/icons/frame.svg new file mode 100644 index 0000000..6511851 --- /dev/null +++ b/crates/assets/assets/icons/frame.svg @@ -0,0 +1,22 @@ + diff --git a/crates/assets/assets/icons/gallery-vertical-end.svg b/crates/assets/assets/icons/gallery-vertical-end.svg new file mode 100644 index 0000000..39b1f80 --- /dev/null +++ b/crates/assets/assets/icons/gallery-vertical-end.svg @@ -0,0 +1,18 @@ + diff --git a/crates/assets/assets/icons/github.svg b/crates/assets/assets/icons/github.svg new file mode 100644 index 0000000..f5587c7 --- /dev/null +++ b/crates/assets/assets/icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/globe.svg b/crates/assets/assets/icons/globe.svg new file mode 100644 index 0000000..e2147e9 --- /dev/null +++ b/crates/assets/assets/icons/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/hard-drive.svg b/crates/assets/assets/icons/hard-drive.svg new file mode 100644 index 0000000..d0d0cec --- /dev/null +++ b/crates/assets/assets/icons/hard-drive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/heart-off.svg b/crates/assets/assets/icons/heart-off.svg new file mode 100644 index 0000000..68cda02 --- /dev/null +++ b/crates/assets/assets/icons/heart-off.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/assets/icons/heart.svg b/crates/assets/assets/icons/heart.svg new file mode 100644 index 0000000..43be902 --- /dev/null +++ b/crates/assets/assets/icons/heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/inbox.svg b/crates/assets/assets/icons/inbox.svg new file mode 100644 index 0000000..456b522 --- /dev/null +++ b/crates/assets/assets/icons/inbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/info.svg b/crates/assets/assets/icons/info.svg new file mode 100644 index 0000000..8c02c93 --- /dev/null +++ b/crates/assets/assets/icons/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/inspector.svg b/crates/assets/assets/icons/inspector.svg new file mode 100644 index 0000000..c3517a4 --- /dev/null +++ b/crates/assets/assets/icons/inspector.svg @@ -0,0 +1,18 @@ + diff --git a/crates/assets/assets/icons/layout-dashboard.svg b/crates/assets/assets/icons/layout-dashboard.svg new file mode 100644 index 0000000..ad64197 --- /dev/null +++ b/crates/assets/assets/icons/layout-dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/loader-circle.svg b/crates/assets/assets/icons/loader-circle.svg new file mode 100644 index 0000000..712d0a8 --- /dev/null +++ b/crates/assets/assets/icons/loader-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/loader.svg b/crates/assets/assets/icons/loader.svg new file mode 100644 index 0000000..2dbd761 --- /dev/null +++ b/crates/assets/assets/icons/loader.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/map.svg b/crates/assets/assets/icons/map.svg new file mode 100644 index 0000000..508c658 --- /dev/null +++ b/crates/assets/assets/icons/map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/maximize.svg b/crates/assets/assets/icons/maximize.svg new file mode 100644 index 0000000..6f84e6f --- /dev/null +++ b/crates/assets/assets/icons/maximize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/memory-stick.svg b/crates/assets/assets/icons/memory-stick.svg new file mode 100644 index 0000000..7d12b57 --- /dev/null +++ b/crates/assets/assets/icons/memory-stick.svg @@ -0,0 +1,22 @@ + diff --git a/crates/assets/assets/icons/menu.svg b/crates/assets/assets/icons/menu.svg new file mode 100644 index 0000000..771d875 --- /dev/null +++ b/crates/assets/assets/icons/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/minimize.svg b/crates/assets/assets/icons/minimize.svg new file mode 100644 index 0000000..7e9cb74 --- /dev/null +++ b/crates/assets/assets/icons/minimize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/minus.svg b/crates/assets/assets/icons/minus.svg new file mode 100644 index 0000000..fcc1e94 --- /dev/null +++ b/crates/assets/assets/icons/minus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/moon.svg b/crates/assets/assets/icons/moon.svg new file mode 100644 index 0000000..aa64d7f --- /dev/null +++ b/crates/assets/assets/icons/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/network.svg b/crates/assets/assets/icons/network.svg new file mode 100644 index 0000000..c6b0fbd --- /dev/null +++ b/crates/assets/assets/icons/network.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/palette.svg b/crates/assets/assets/icons/palette.svg new file mode 100644 index 0000000..119e1a4 --- /dev/null +++ b/crates/assets/assets/icons/palette.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/panel-bottom-open.svg b/crates/assets/assets/icons/panel-bottom-open.svg new file mode 100644 index 0000000..df77e5b --- /dev/null +++ b/crates/assets/assets/icons/panel-bottom-open.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/assets/icons/panel-bottom.svg b/crates/assets/assets/icons/panel-bottom.svg new file mode 100644 index 0000000..d70a752 --- /dev/null +++ b/crates/assets/assets/icons/panel-bottom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/panel-left-close.svg b/crates/assets/assets/icons/panel-left-close.svg new file mode 100644 index 0000000..5f26cc6 --- /dev/null +++ b/crates/assets/assets/icons/panel-left-close.svg @@ -0,0 +1,14 @@ + diff --git a/crates/assets/assets/icons/panel-left-open.svg b/crates/assets/assets/icons/panel-left-open.svg new file mode 100644 index 0000000..579e458 --- /dev/null +++ b/crates/assets/assets/icons/panel-left-open.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/assets/icons/panel-left.svg b/crates/assets/assets/icons/panel-left.svg new file mode 100644 index 0000000..3dac79e --- /dev/null +++ b/crates/assets/assets/icons/panel-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/panel-right-close.svg b/crates/assets/assets/icons/panel-right-close.svg new file mode 100644 index 0000000..5fe9dcd --- /dev/null +++ b/crates/assets/assets/icons/panel-right-close.svg @@ -0,0 +1,14 @@ + diff --git a/crates/assets/assets/icons/panel-right-open.svg b/crates/assets/assets/icons/panel-right-open.svg new file mode 100644 index 0000000..3b5ff0b --- /dev/null +++ b/crates/assets/assets/icons/panel-right-open.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/assets/icons/panel-right.svg b/crates/assets/assets/icons/panel-right.svg new file mode 100644 index 0000000..d37f5e7 --- /dev/null +++ b/crates/assets/assets/icons/panel-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/pause.svg b/crates/assets/assets/icons/pause.svg new file mode 100644 index 0000000..3ac2f47 --- /dev/null +++ b/crates/assets/assets/icons/pause.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/play.svg b/crates/assets/assets/icons/play.svg new file mode 100644 index 0000000..4b95321 --- /dev/null +++ b/crates/assets/assets/icons/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/plus.svg b/crates/assets/assets/icons/plus.svg new file mode 100644 index 0000000..15eb956 --- /dev/null +++ b/crates/assets/assets/icons/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/redo-2.svg b/crates/assets/assets/icons/redo-2.svg new file mode 100644 index 0000000..abf1431 --- /dev/null +++ b/crates/assets/assets/icons/redo-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/redo.svg b/crates/assets/assets/icons/redo.svg new file mode 100644 index 0000000..06b39d0 --- /dev/null +++ b/crates/assets/assets/icons/redo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/replace.svg b/crates/assets/assets/icons/replace.svg new file mode 100644 index 0000000..d1fd435 --- /dev/null +++ b/crates/assets/assets/icons/replace.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/resize-corner.svg b/crates/assets/assets/icons/resize-corner.svg new file mode 100644 index 0000000..07f7aa6 --- /dev/null +++ b/crates/assets/assets/icons/resize-corner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/search.svg b/crates/assets/assets/icons/search.svg new file mode 100644 index 0000000..4273096 --- /dev/null +++ b/crates/assets/assets/icons/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/settings-2.svg b/crates/assets/assets/icons/settings-2.svg new file mode 100644 index 0000000..de5d127 --- /dev/null +++ b/crates/assets/assets/icons/settings-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/settings.svg b/crates/assets/assets/icons/settings.svg new file mode 100644 index 0000000..7703c63 --- /dev/null +++ b/crates/assets/assets/icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/sort-ascending.svg b/crates/assets/assets/icons/sort-ascending.svg new file mode 100644 index 0000000..b615797 --- /dev/null +++ b/crates/assets/assets/icons/sort-ascending.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/sort-descending.svg b/crates/assets/assets/icons/sort-descending.svg new file mode 100644 index 0000000..95bdec9 --- /dev/null +++ b/crates/assets/assets/icons/sort-descending.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/square-terminal.svg b/crates/assets/assets/icons/square-terminal.svg new file mode 100644 index 0000000..d818b08 --- /dev/null +++ b/crates/assets/assets/icons/square-terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/star-fill.svg b/crates/assets/assets/icons/star-fill.svg new file mode 100644 index 0000000..e177475 --- /dev/null +++ b/crates/assets/assets/icons/star-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/star-off.svg b/crates/assets/assets/icons/star-off.svg new file mode 100644 index 0000000..3f682ff --- /dev/null +++ b/crates/assets/assets/icons/star-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/star.svg b/crates/assets/assets/icons/star.svg new file mode 100644 index 0000000..8879428 --- /dev/null +++ b/crates/assets/assets/icons/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/sun.svg b/crates/assets/assets/icons/sun.svg new file mode 100644 index 0000000..1242b0a --- /dev/null +++ b/crates/assets/assets/icons/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/thumbs-down.svg b/crates/assets/assets/icons/thumbs-down.svg new file mode 100644 index 0000000..f067dfd --- /dev/null +++ b/crates/assets/assets/icons/thumbs-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/thumbs-up.svg b/crates/assets/assets/icons/thumbs-up.svg new file mode 100644 index 0000000..a46d747 --- /dev/null +++ b/crates/assets/assets/icons/thumbs-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/triangle-alert.svg b/crates/assets/assets/icons/triangle-alert.svg new file mode 100644 index 0000000..a476591 --- /dev/null +++ b/crates/assets/assets/icons/triangle-alert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/undo-2.svg b/crates/assets/assets/icons/undo-2.svg new file mode 100644 index 0000000..c72e0ec --- /dev/null +++ b/crates/assets/assets/icons/undo-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/undo.svg b/crates/assets/assets/icons/undo.svg new file mode 100644 index 0000000..a29b7c6 --- /dev/null +++ b/crates/assets/assets/icons/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/user.svg b/crates/assets/assets/icons/user.svg new file mode 100644 index 0000000..c4292eb --- /dev/null +++ b/crates/assets/assets/icons/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/window-close.svg b/crates/assets/assets/icons/window-close.svg new file mode 100644 index 0000000..4ee8e56 --- /dev/null +++ b/crates/assets/assets/icons/window-close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/window-maximize.svg b/crates/assets/assets/icons/window-maximize.svg new file mode 100644 index 0000000..b5a3265 --- /dev/null +++ b/crates/assets/assets/icons/window-maximize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/window-minimize.svg b/crates/assets/assets/icons/window-minimize.svg new file mode 100644 index 0000000..fcc1e94 --- /dev/null +++ b/crates/assets/assets/icons/window-minimize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/assets/icons/window-restore.svg b/crates/assets/assets/icons/window-restore.svg new file mode 100644 index 0000000..f55fa10 --- /dev/null +++ b/crates/assets/assets/icons/window-restore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs new file mode 100644 index 0000000..4f06a44 --- /dev/null +++ b/crates/assets/src/lib.rs @@ -0,0 +1,47 @@ +use anyhow::Context; +use gpui::{App, AssetSource, Result, SharedString}; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "assets"] +#[include = "icons/**/*.svg"] +#[exclude = "*.DS_Store"] +pub struct Assets; + +impl AssetSource for Assets { + fn load(&self, path: &str) -> Result>> { + Self::get(path) + .map(|f| Some(f.data)) + .with_context(|| format!("loading asset at path {path:?}")) + } + + fn list(&self, path: &str) -> Result> { + Ok(Self::iter() + .filter_map(|p| { + if p.starts_with(path) { + Some(p.into()) + } else { + None + } + }) + .collect()) + } +} + +impl Assets { + pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> { + let font_paths = self.list("fonts")?; + let mut embedded_fonts = Vec::new(); + for font_path in font_paths { + if font_path.ends_with(".ttf") { + let font_bytes = cx + .asset_source() + .load(&font_path)? + .expect("Assets should never return None"); + embedded_fonts.push(font_bytes); + } + } + + cx.text_system().add_fonts(embedded_fonts) + } +} diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index 6055a29..ff86625 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -1,3 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] +use std::path::Path; use std::time::Duration; use anyhow::{Context, Result}; @@ -16,9 +18,7 @@ use crate::signer::UniversalSigner; /// The SDK manages its own internal tokio runtime; the returned client can be /// driven by GPUI's executors. #[cfg(not(target_arch = "wasm32"))] -pub async fn new_backend( - db_path: impl AsRef, -) -> Result<(Client, UniversalSigner)> { +pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, UniversalSigner)> { let signer = UniversalSigner::new(Keys::generate()); let database = NostrLmdb::open(db_path) .await diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 6551e17..53d5083 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -9,6 +9,7 @@ name = "signed" path = "src/main.rs" [dependencies] +assets = { path = "../crates/assets" } paths = { path = "../crates/paths" } signed_state = { path = "../crates/signed_state" } workspace = { path = "../crates/workspace" } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 61c93bf..b14c693 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use assets::Assets; use gpui::*; use gpui_platform::application; @@ -8,8 +9,13 @@ fn main() { tracing_subscriber::fmt::init(); application() + .with_assets(Assets) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new())) .run(move |cx| { + // Set app identity + cx.set_app_identity("su.reya.signed", "Signed"); + + // Initialize components gpui_component::init(cx); // Initialize backend and stores (connects relays, restores session) -- 2.54.0 From 161c066ca8fb73784841139200598bd4ae3dfd73 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 7 Aug 2026 19:22:21 +0700 Subject: [PATCH 16/64] update sidebar --- crates/signed_state/src/profile.rs | 140 +++++++++++-------- crates/workspace/src/views/repo_list.rs | 3 +- crates/workspace/src/views/sidebar/mod.rs | 163 ++++++++++++++++++++-- 3 files changed, 235 insertions(+), 71 deletions(-) diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 99eb183..52dc737 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -1,7 +1,9 @@ use std::collections::{HashMap, HashSet}; -use std::time::Duration; +use std::sync::RwLock; +use std::time::{Duration, Instant}; use anyhow::Error; +use flume::{Receiver, RecvTimeoutError, Sender}; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task}; use nostr_sdk::prelude::*; @@ -63,15 +65,23 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String { format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..]) } +/// Message from the fetch task to the main thread. +enum Dispatch { + /// A batched sync finished; re-read seen profiles from the database. + Synced, +} + +/// How long to wait for more requests before firing a batched sync. +const BATCH_TIMEOUT: Duration = Duration::from_millis(500); + /// Global profile cache. Profiles are fetched in batches and kept as plain /// data; the whole store notifies on change. pub struct ProfileStore { profiles: HashMap, /// Public keys we've already requested this session. - seen: HashSet, - /// Public keys queued for the next batched fetch. - queued: HashSet, - fetching: bool, + seen: RwLock>, + /// Sender for queuing fetch requests, batched by a background task. + sender: Sender, tasks: Vec>>, _subscription: Subscription, } @@ -106,12 +116,31 @@ impl ProfileStore { _ => {} }); + // Fetch requests are queued on a channel and synced in batches by a + // background task. + let client = backend.read(cx).client(); + let (sender, receiver) = flume::unbounded::(); + let (dispatch_tx, dispatch_rx) = flume::unbounded::(); + + let mut tasks = Vec::new(); + + tasks.push(cx.background_spawn(async move { + Self::handle_requests(&client, &dispatch_tx, &receiver).await + })); + + // Re-read seen profiles from the database after each batch sync. + tasks.push(cx.spawn(async move |this, cx| { + while let Ok(Dispatch::Synced) = dispatch_rx.recv_async().await { + this.update(cx, |this, cx| this.apply_seen(cx)).ok(); + } + Ok(()) + })); + let mut store = Self { profiles: HashMap::new(), - seen: HashSet::new(), - queued: HashSet::new(), - fetching: false, - tasks: Vec::new(), + seen: RwLock::new(HashSet::new()), + sender, + tasks, _subscription: subscription, }; @@ -121,14 +150,17 @@ impl ProfileStore { /// Get a profile. Returns a placeholder (default metadata) and queues a /// fetch if the profile isn't cached yet. - pub fn get(&mut self, public_key: PublicKey, cx: &mut Context) -> Profile { - if let Some(profile) = self.profiles.get(&public_key) { + pub fn get(&self, public_key: &PublicKey) -> Profile { + if let Some(profile) = self.profiles.get(public_key) { return profile.clone(); } - if self.seen.insert(public_key) { - self.queued.insert(public_key); - self.queue_fetch(cx); + let public_key = *public_key; + + if self.seen.write().unwrap().insert(public_key) + && let Err(e) = self.sender.send(public_key) + { + log::warn!("failed to queue profile fetch: {e}"); } Profile::new(public_key, Metadata::default()) @@ -205,12 +237,12 @@ impl ProfileStore { /// Re-read the latest metadata of every requested author from the local /// database (used after a sync, which produces no NostrUpdate events). fn apply_seen(&mut self, cx: &mut Context) { - if self.seen.is_empty() { + let authors: Vec = self.seen.read().unwrap().iter().copied().collect(); + if authors.is_empty() { return; } let client = Backend::global(cx).read(cx).client(); - let authors: Vec = self.seen.iter().copied().collect(); let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).authors(authors); @@ -255,51 +287,47 @@ impl ProfileStore { })); } - /// Drain the queue in a batched fetch, debounced to collect requests. - fn queue_fetch(&mut self, cx: &mut Context) { - if self.fetching { - return; - } - self.fetching = true; + /// Sync metadata for requested authors in batches, debounced to collect + /// requests. Runs on a background thread; results are dispatched to the + /// main thread, which re-reads the database. + async fn handle_requests( + client: &Client, + dispatch: &Sender, + receiver: &Receiver, + ) -> Result<(), Error> { + let mut batch: HashSet = HashSet::new(); - let client = Backend::global(cx).read(cx).client(); - - let task = cx.spawn(async move |this, cx| { - loop { - // Collect more requests before firing the batch. - cx.background_executor() - .timer(Duration::from_millis(500)) - .await; - - let batch = this.update(cx, |this, _cx| std::mem::take(&mut this.queued))?; - - if batch.is_empty() { - this.update(cx, |this, _cx| { - this.fetching = false; - })?; - break; + loop { + // Wait for the first request of a batch. + match receiver.recv_timeout(BATCH_TIMEOUT) { + Ok(public_key) => { + batch.insert(public_key); } + Err(RecvTimeoutError::Disconnected) => return Ok(()), + Err(RecvTimeoutError::Timeout) => continue, + }; - let filter = Filter::new() - .kind(Kind::Metadata) - .authors(batch.into_iter().collect::>()); - - // Negentropy-sync with the bootstrap relays. Synced events - // are written to the database directly (no NostrUpdate), so - // re-apply from the database afterwards. - match sync_bootstrap_only(&client, filter, SyncOptions::default()).await { - Ok(_) => { - this.update(cx, |this, cx| this.apply_seen(cx))?; - } - Err(e) => { - log::warn!("profile sync failed: {e}"); - } - } + // Collect everything that arrives within the debounce window. + let deadline = Instant::now() + BATCH_TIMEOUT; + while let Ok(public_key) = receiver.recv_deadline(deadline) { + batch.insert(public_key); } - Ok(()) - }); + let filter = Filter::new() + .kind(Kind::Metadata) + .authors(batch.drain().collect::>()); - self.tasks.push(task); + // Negentropy-sync with the bootstrap relays. Synced events are + // written to the database directly (no NostrUpdate), so re-apply + // from the database afterwards. + match sync_bootstrap_only(client, filter, SyncOptions::default()).await { + Ok(_) => { + if dispatch.send(Dispatch::Synced).is_err() { + log::warn!("profile dispatch channel closed, dropping sync result"); + } + } + Err(e) => log::warn!("profile sync failed: {e}"), + } + } } } diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 1e3f05e..224258a 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -34,7 +34,8 @@ impl RepoListView { .unwrap_or_else(|| announcement.id.clone()); let owner = ProfileStore::global(cx) - .update(cx, |store, cx| store.get(announcement.owner, cx)) + .read(cx) + .get(&announcement.owner) .name(); let description = announcement.description.clone().unwrap_or_default(); diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 081c1ea..55bc342 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -2,14 +2,15 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - App, Context, EventEmitter, FocusHandle, Focusable, Render, Subscription, WeakEntity, Window, - div, + App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render, + SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, }; +use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; use gpui_component::input::InputState; -use gpui_component::{ActiveTheme, v_flex}; -use signed_state::{Backend, BackendEvent}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; +use signed_state::{Backend, BackendEvent, ProfileStore}; use super::RepoListView; @@ -128,15 +129,8 @@ impl Focusable for SidebarPanel { impl Render for SidebarPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.logged_in { - v_flex().child( - Button::new("explore") - .label("Explore") - .w_full() - .on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))), - ) - } else { - v_flex() + if !self.logged_in { + return v_flex() .p_4() .size_full() .items_center() @@ -165,7 +159,148 @@ impl Render for SidebarPanel { .on_click( cx.listener(|this, _ev, window, cx| this.open_import(window, cx)), ), - ) + ); } + + let backend = Backend::global(cx); + let profile_store = ProfileStore::global(cx); + + let profile = backend + .read(cx) + .current_user() + .map(|public_key| profile_store.read(cx).get(&public_key)); + + v_flex() + .size_full() + .justify_between() + .bg(cx.theme().sidebar) + .text_color(cx.theme().sidebar_foreground) + .child( + div() + .flex_1() + .when_some(profile.as_ref(), |this, profile| { + let name = profile.name(); + let picture = profile.picture(); + + this.child( + h_flex() + .h_12() + .px_3() + .gap_2() + .child( + Avatar::new() + .name(name.clone()) + .when_some(picture, |this, url| this.src(url)) + .small() + .border_0(), + ) + .child(div().text_sm().child(name)), + ) + }) + .child( + v_flex() + .px_2() + .gap_1() + .items_start() + .justify_start() + .child(NavItem::new("inbox", "Inbox", IconName::Inbox).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + )) + .child(NavItem::new("explore", "Browse", IconName::Globe).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + )) + .child( + v_flex().w_full().child( + h_flex() + .h_10() + .w_full() + .justify_between() + .items_center() + .child( + h_flex() + .px_2() + .gap_2() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(IconName::Folder).small()) + .child( + div() + .text_xs() + .font_semibold() + .child("All Repositories"), + ), + ) + .child( + Button::new("add").icon(IconName::Plus).small().ghost(), + ), + ), + ), + ), + ) + .child( + v_flex() + .p_2() + .flex_shrink_0() + .gap_1() + .items_start() + .justify_start() + .child(NavItem::new("guide", "Guide", IconName::Info).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + )) + .child( + NavItem::new("settings", "Settings", IconName::Settings).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + ), + ), + ) + } +} + +/// A single navigation entry in the sidebar: an icon and label with a hover +/// highlight and an optional click handler. +#[allow(clippy::type_complexity)] +#[derive(IntoElement)] +struct NavItem { + id: ElementId, + style: StyleRefinement, + icon: IconName, + label: SharedString, + on_click: Option>, +} + +impl NavItem { + fn new(id: I, label: L, icon: IconName) -> Self + where + I: Into, + L: Into, + { + Self { + id: id.into(), + icon, + label: label.into(), + style: StyleRefinement::default(), + on_click: None, + } + } + + fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { + self.on_click = Some(Box::new(listener)); + self + } +} + +impl RenderOnce for NavItem { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .id(self.id) + .refine_style(&self.style) + .px_2() + .py_1() + .w_full() + .gap_2() + .rounded(cx.theme().radius) + .child(Icon::new(self.icon).small()) + .child(div().text_sm().child(self.label)) + .hover(|this| this.bg(cx.theme().list_hover)) + .when_some(self.on_click, |this, listener| this.on_click(listener)) } } -- 2.54.0 From de9673e8fb5ad4072751b8ceb84f234f5df334a5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 8 Aug 2026 08:08:24 +0700 Subject: [PATCH 17/64] update --- Cargo.lock | 126 +++++++++++----------- crates/assets/Cargo.toml | 1 + crates/assets/assets/icons/filter.svg | 1 + crates/assets/assets/icons/global-off.svg | 1 + crates/assets/assets/icons/global-on.svg | 1 + crates/assets/src/lib.rs | 18 ++++ crates/workspace/Cargo.toml | 1 + crates/workspace/src/views/sidebar/mod.rs | 3 +- crates/workspace/src/workspace.rs | 31 ++++-- 9 files changed, 110 insertions(+), 73 deletions(-) create mode 100644 crates/assets/assets/icons/filter.svg create mode 100644 crates/assets/assets/icons/global-off.svg create mode 100644 crates/assets/assets/icons/global-on.svg diff --git a/Cargo.lock b/Cargo.lock index 71b2813..9e56d81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -319,6 +319,7 @@ version = "1.0.0" dependencies = [ "anyhow", "gpui", + "gpui-component", "log", "rust-embed", ] @@ -620,9 +621,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -630,9 +631,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -927,13 +928,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1017,9 +1018,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" dependencies = [ "find-msvc-tools", "jobserver", @@ -1243,7 +1244,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "gpui_util", "indexmap", @@ -1686,7 +1687,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "proc-macro2", "quote", @@ -2079,9 +2080,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "fixedbitset" @@ -3398,7 +3399,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "accesskit", "anyhow", @@ -3482,7 +3483,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#3067deae83a237a4485e79bd449acd84ca890c23" +source = "git+https://github.com/longbridge/gpui-component#f96f1576ee704b48476f6782962690e0feb7985b" dependencies = [ "aho-corasick", "anyhow", @@ -3530,7 +3531,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#3067deae83a237a4485e79bd449acd84ca890c23" +source = "git+https://github.com/longbridge/gpui-component#f96f1576ee704b48476f6782962690e0feb7985b" dependencies = [ "anyhow", "gpui", @@ -3544,7 +3545,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#3067deae83a237a4485e79bd449acd84ca890c23" +source = "git+https://github.com/longbridge/gpui-component#f96f1576ee704b48476f6782962690e0feb7985b" dependencies = [ "proc-macro2", "quote", @@ -3554,7 +3555,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "accesskit", "accesskit_unix", @@ -3606,7 +3607,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "accesskit", "accesskit_macos", @@ -3655,7 +3656,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3666,7 +3667,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "console_error_panic_hook", "gpui", @@ -3679,7 +3680,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "schemars", "serde", @@ -3689,7 +3690,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "anyhow", "log", @@ -3699,7 +3700,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3722,7 +3723,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "anyhow", "bytemuck", @@ -3752,7 +3753,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "accesskit", "accesskit_windows", @@ -4071,7 +4072,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "anyhow", "async-compression", @@ -4091,7 +4092,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -4334,7 +4335,7 @@ dependencies = [ "ravif", "rayon", "tiff", - "zune-core 0.5.1", + "zune-core 0.5.3", "zune-jpeg 0.5.15", ] @@ -5066,7 +5067,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "anyhow", "bindgen", @@ -5281,8 +5282,8 @@ dependencies = [ [[package]] name = "nostr" -version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" +version = "0.45.1" +source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" dependencies = [ "aes", "base64", @@ -5307,8 +5308,8 @@ dependencies = [ [[package]] name = "nostr-connect" -version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" +version = "0.45.1" +source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" dependencies = [ "async-utility", "futures-core", @@ -5321,8 +5322,8 @@ dependencies = [ [[package]] name = "nostr-database" -version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" +version = "0.45.1" +source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" dependencies = [ "nostr", "opaquerr", @@ -5331,7 +5332,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" +source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" dependencies = [ "nostr", "opaquerr", @@ -5340,7 +5341,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" +source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" dependencies = [ "indexmap", "lru", @@ -5351,8 +5352,8 @@ dependencies = [ [[package]] name = "nostr-lmdb" -version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" +version = "0.45.1" +source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" dependencies = [ "async-utility", "flatbuffers", @@ -5366,8 +5367,8 @@ dependencies = [ [[package]] name = "nostr-memory" -version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" +version = "0.45.1" +source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" dependencies = [ "btreecap", "nostr", @@ -5377,8 +5378,8 @@ dependencies = [ [[package]] name = "nostr-sdk" -version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#9ad7b273e7f4c460d07feea0077b3a4491dcbc6a" +version = "0.45.1" +source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" dependencies = [ "async-utility", "async-wsocket", @@ -6114,7 +6115,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "collections", "serde", @@ -6897,7 +6898,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "derive_refineable", ] @@ -6980,7 +6981,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "anyhow", "bytes", @@ -7381,7 +7382,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "async-task", "backtrace", @@ -8094,7 +8095,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "heapless 0.9.3", "log", @@ -9083,7 +9084,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "perf", "quote", @@ -10342,6 +10343,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" name = "workspace" version = "1.0.0" dependencies = [ + "assets", "gpui", "gpui-component", "signed_core", @@ -10454,9 +10456,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xml5ever" @@ -10745,18 +10747,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -10846,7 +10848,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "anyhow", "chrono", @@ -10863,7 +10865,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" dependencies = [ "tracing", "tracing-subscriber", @@ -10874,7 +10876,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d61e80b85debf0c56ecdcadf635ffa2660ddfd37" +source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" [[package]] name = "zune-core" @@ -10884,9 +10886,9 @@ checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-inflate" @@ -10912,7 +10914,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.1", + "zune-core 0.5.3", ] [[package]] diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index 3e3d11f..136a026 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -6,6 +6,7 @@ publish.workspace = true [dependencies] gpui.workspace = true +gpui-component.workspace = true anyhow.workspace = true log.workspace = true rust-embed.workspace = true diff --git a/crates/assets/assets/icons/filter.svg b/crates/assets/assets/icons/filter.svg new file mode 100644 index 0000000..3f685b6 --- /dev/null +++ b/crates/assets/assets/icons/filter.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/assets/icons/global-off.svg b/crates/assets/assets/icons/global-off.svg new file mode 100644 index 0000000..bd90538 --- /dev/null +++ b/crates/assets/assets/icons/global-off.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/assets/icons/global-on.svg b/crates/assets/assets/icons/global-on.svg new file mode 100644 index 0000000..3b00421 --- /dev/null +++ b/crates/assets/assets/icons/global-on.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 4f06a44..66ea625 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -1,5 +1,6 @@ use anyhow::Context; use gpui::{App, AssetSource, Result, SharedString}; +use gpui_component::IconNamed; use rust_embed::RustEmbed; #[derive(RustEmbed)] @@ -45,3 +46,20 @@ impl Assets { cx.text_system().add_fonts(embedded_fonts) } } + +pub enum CustomIconName { + Filter, + GlobalOn, + GlobalOff, +} + +impl IconNamed for CustomIconName { + fn path(self) -> gpui::SharedString { + match self { + CustomIconName::Filter => "icons/filter.svg", + CustomIconName::GlobalOn => "icons/global-on.svg", + CustomIconName::GlobalOff => "icons/global-off.svg", + } + .into() + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 7df7966..17f1d16 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true publish.workspace = true [dependencies] +assets = { path = "../assets" } signed_core = { path = "../signed_core" } signed_state = { path = "../signed_state" } diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 55bc342..f104ba5 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use assets::CustomIconName; use gpui::prelude::*; use gpui::{ App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render, @@ -221,7 +222,7 @@ impl Render for SidebarPanel { .px_2() .gap_2() .text_color(cx.theme().muted_foreground) - .child(Icon::new(IconName::Folder).small()) + .child(Icon::new(CustomIconName::Filter).small()) .child( div() .text_xs() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 42d54d4..b979783 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,9 +1,11 @@ use std::sync::Arc; +use assets::CustomIconName; use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dock::{DockArea, DockItem}; -use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, TitleBar, h_flex, v_flex}; use signed_state::{Backend, BackendEvent}; use crate::views::SidebarPanel; @@ -50,11 +52,11 @@ impl Workspace { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { match event { - BackendEvent::Connected => this.status = "Connected".into(), BackendEvent::SyncProgress { total, current } => { this.status = format!("Syncing repositories... {current}/{total}").into() } BackendEvent::Synced => this.status = "Connected".into(), + BackendEvent::Connected => this.status = "Connected".into(), BackendEvent::Error(error) => this.status = error.clone().into(), _ => return, } @@ -105,14 +107,23 @@ impl Render for Workspace { // Left .child(div()) // Right - .child( - h_flex().px_2().child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.status.clone()), - ), - ), + .child(h_flex().px_2().map(|this| { + if self.status == "Connected" { + this.child( + Button::new("relay") + .icon(CustomIconName::GlobalOn) + .small() + .ghost(), + ) + } else { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.status.clone()), + ) + } + })), ) // Dock Area .child(self.dock.clone()), -- 2.54.0 From 322f6f60bc0982be430401e8996729f19fdfc8b4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 8 Aug 2026 08:44:02 +0700 Subject: [PATCH 18/64] update repo list --- Cargo.lock | 9 +++ crates/signed_core/src/model.rs | 15 ++++- crates/signed_state/Cargo.toml | 1 + crates/signed_state/src/lib.rs | 4 +- crates/signed_state/src/profile.rs | 7 +-- crates/signed_state/src/repo_list.rs | 79 ++++++++++++++++++++++--- crates/utils/Cargo.toml | 8 +++ crates/utils/src/lib.rs | 5 ++ crates/utils/src/pubkey.rs | 7 +++ crates/utils/src/time.rs | 43 ++++++++++++++ crates/workspace/Cargo.toml | 1 + crates/workspace/src/views/repo_list.rs | 67 +++++++++++++++++++-- 12 files changed, 226 insertions(+), 20 deletions(-) create mode 100644 crates/utils/Cargo.toml create mode 100644 crates/utils/src/lib.rs create mode 100644 crates/utils/src/pubkey.rs create mode 100644 crates/utils/src/time.rs diff --git a/Cargo.lock b/Cargo.lock index 9e56d81..8015da2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7826,6 +7826,7 @@ dependencies = [ "rustls", "signed_core", "signed_nostr", + "utils", ] [[package]] @@ -9091,6 +9092,13 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "utils" +version = "1.0.0" +dependencies = [ + "nostr", +] + [[package]] name = "uuid" version = "1.24.0" @@ -10348,6 +10356,7 @@ dependencies = [ "gpui-component", "signed_core", "signed_state", + "utils", ] [[package]] diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 796282c..6d104f3 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -21,6 +21,8 @@ pub struct Announcement { pub euc: Option, /// Other recognized maintainers. pub maintainers: Vec, + /// Hashtags labelling the repository (`t` tags). + pub hashtags: Vec, } impl Announcement { @@ -38,13 +40,20 @@ impl Announcement { let mut relays: Vec = Vec::new(); let mut euc: Option = None; let mut maintainers: Vec = Vec::new(); + let mut hashtags: Vec = Vec::new(); for tag in event.tags.iter() { - // The `d` tag isn't part of the NIP-34 tag codec; parse it directly. + // The `d` and `t` tags aren't part of the NIP-34 tag codec; parse them directly. if tag.kind() == "d" { id = tag.content().map(str::to_owned); continue; } + if tag.kind() == "t" { + if let Some(value) = tag.content() { + hashtags.push(value.to_owned()); + } + continue; + } match Nip34Tag::parse(tag.as_slice()) { Ok(Nip34Tag::Name(value)) => name = Some(value), @@ -73,6 +82,7 @@ impl Announcement { relays, euc, maintainers, + hashtags, }) } @@ -119,6 +129,8 @@ mod tests { &["relays", "wss://relay.example.com"], &["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"], &["maintainers", MAINTAINER_HEX], + &["t", "rust"], + &["t", "nostr"], ]); let announcement = Announcement::from_event(&event).expect("parses"); @@ -141,6 +153,7 @@ mod tests { announcement.maintainers, vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")] ); + assert_eq!(announcement.hashtags, vec!["rust", "nostr"]); } #[test] diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index cea9b9a..21ddabf 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] signed_core = { path = "../signed_core" } signed_nostr = { path = "../signed_nostr" } +utils = { path = "../utils" } nostr.workspace = true nostr-sdk.workspace = true diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 8ccf4dc..7536559 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -7,7 +7,9 @@ use std::path::Path; pub use backend::{Backend, BackendEvent}; use gpui::{App, AppContext, Entity}; -pub use profile::{Profile, ProfileStore, shorten_pubkey}; +pub use nostr_sdk::prelude::Timestamp; +pub use profile::{Profile, ProfileStore}; +pub use utils::shorten_pubkey; pub use repo::RepoStore; pub use repo_list::RepoListStore; use signed_nostr::new_backend; diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 52dc737..cc2e714 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -6,6 +6,7 @@ use anyhow::Error; use flume::{Receiver, RecvTimeoutError, Sender}; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task}; use nostr_sdk::prelude::*; +use utils::shorten_pubkey; use crate::backend::{Backend, BackendEvent, sync_bootstrap_only}; @@ -59,12 +60,6 @@ impl Profile { } } -/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form. -pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String { - let npub = public_key.to_bech32().unwrap(); - format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..]) -} - /// Message from the fetch task to the main thread. enum Dispatch { /// A batched sync finished; re-read seen profiles from the database. diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index b09cdd4..bb37d6e 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, filters}; +use signed_core::{Announcement, RepoAddr, filters, repo_addr}; use crate::backend::{Backend, BackendEvent}; @@ -13,10 +13,16 @@ use crate::backend::{Backend, BackendEvent}; /// events (e.g. sync progress ticks) collapse into one query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); +/// How far back activity events count toward a repository's last activity. +const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400); + /// Store listing repository announcements (global discovery or per-author). pub struct RepoListStore { /// Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, + /// Latest known activity timestamp per repository + /// (announcements, state updates, patches, PRs, issues, statuses). + pub last_activity: Arc>, author: Option, refreshing: bool, refresh_dirty: bool, @@ -32,14 +38,22 @@ impl RepoListStore { let backend = Backend::global(cx); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { - let git_kind = Kind::GitRepoAnnouncement; - let relevant = match event { BackendEvent::NostrUpdate(update) => { - update.kind == git_kind && this.author.is_none_or(|a| a == update.author) + // Activity (patches, issues, ...) is addressed to repos via + // `a` tags, so its author isn't the repo owner; always refresh. + if filters::ACTIVITY_KINDS.contains(&update.kind) { + true + } else { + let is_announcement = update.kind == Kind::GitRepoAnnouncement; + let is_repo_state = update.kind == Kind::RepoState; + let tracked = is_announcement || is_repo_state; + tracked && this.author.is_none_or(|a| a == update.author) + } } BackendEvent::Published(event) => { - event.kind == git_kind && this.author.is_none_or(|a| a == event.pubkey) + event.kind == Kind::GitRepoAnnouncement + && this.author.is_none_or(|a| a == event.pubkey) } BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, _ => false, @@ -52,6 +66,7 @@ impl RepoListStore { let mut store = Self { announcements: Arc::new(Vec::new()), + last_activity: Arc::new(HashMap::new()), author, refreshing: false, refresh_dirty: false, @@ -151,12 +166,59 @@ impl RepoListStore { let mut announcements: Vec = by_repo.into_values().collect(); announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); - Ok::<_, Error>(announcements) + // Last activity per repository: state updates plus all NIP-34 + // activity events (patches, PRs, issues, statuses). + let mut last_activity: HashMap = announcements + .iter() + .map(|a| (a.addr(), a.created_at)) + .collect(); + + let state_filter = Filter::new().kind(Kind::RepoState); + for event in client.database().query(state_filter).await? { + let Some(id) = event.tags.identifier() else { + continue; + }; + let addr = repo_addr(event.pubkey, id); + let Some(entry) = last_activity.get_mut(&addr) else { + continue; + }; + *entry = (*entry).max(event.created_at); + } + + // Bound the activity query to a recent window; older repos fall + // back to their announcement / state timestamps. + let activity_filter = Filter::new() + .kinds(filters::ACTIVITY_KINDS) + .since(Timestamp::now() - ACTIVITY_WINDOW); + for event in client.database().query(activity_filter).await? { + for tag in event.tags.iter() { + if tag.kind() != "a" { + continue; + } + let Some(content) = tag.content() else { + continue; + }; + let Ok(addr) = Coordinate::parse(content) else { + continue; + }; + if addr.kind != Kind::GitRepoAnnouncement { + continue; + } + // Skip events for repos we don't list, so the map can't + // grow beyond the number of announcements. + let Some(entry) = last_activity.get_mut(&addr) else { + continue; + }; + *entry = (*entry).max(event.created_at); + } + } + + Ok::<_, Error>((announcements, last_activity)) }); self.tasks.push(cx.spawn(async move |this, cx| { - let announcements = match work.await { - Ok(announcements) => announcements, + let (announcements, last_activity) = match work.await { + Ok(results) => results, // Database errors are transient; keep the last list. Err(_) => { return this.update(cx, |this, _cx| { @@ -167,6 +229,7 @@ impl RepoListStore { let again = this.update(cx, |this, cx| { this.announcements = Arc::new(announcements); + this.last_activity = Arc::new(last_activity); cx.notify(); this.refreshing = false; diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml new file mode 100644 index 0000000..da7dbad --- /dev/null +++ b/crates/utils/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "utils" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +nostr.workspace = true diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs new file mode 100644 index 0000000..5ce1f4c --- /dev/null +++ b/crates/utils/src/lib.rs @@ -0,0 +1,5 @@ +mod pubkey; +mod time; + +pub use pubkey::shorten_pubkey; +pub use time::relative_time; diff --git a/crates/utils/src/pubkey.rs b/crates/utils/src/pubkey.rs new file mode 100644 index 0000000..3b2d588 --- /dev/null +++ b/crates/utils/src/pubkey.rs @@ -0,0 +1,7 @@ +use nostr::prelude::*; + +/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form. +pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String { + let npub = public_key.to_bech32().unwrap(); + format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..]) +} diff --git a/crates/utils/src/time.rs b/crates/utils/src/time.rs new file mode 100644 index 0000000..3c350d9 --- /dev/null +++ b/crates/utils/src/time.rs @@ -0,0 +1,43 @@ +use nostr::prelude::*; + +/// Format a timestamp as a short relative time (e.g. "3h ago"). +pub fn relative_time(timestamp: Timestamp) -> String { + let now = Timestamp::now().as_secs(); + let secs = now.saturating_sub(timestamp.as_secs()); + + if secs < 60 { + "just now".to_string() + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86_400 { + format!("{}h ago", secs / 3600) + } else if secs < 30 * 86_400 { + format!("{}d ago", secs / 86_400) + } else if secs < 365 * 86_400 { + format!("{}mo ago", secs / (30 * 86_400)) + } else { + format!("{}y ago", secs / (365 * 86_400)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_relative_time() { + let now = Timestamp::now(); + + assert_eq!(relative_time(now), "just now"); + assert_eq!(relative_time(now - 300), "5m ago"); + assert_eq!(relative_time(now - 7_200), "2h ago"); + assert_eq!(relative_time(now - 3 * 86_400), "3d ago"); + assert_eq!(relative_time(now - 60 * 86_400), "2mo ago"); + assert_eq!(relative_time(now - 800 * 86_400), "2y ago"); + } + + #[test] + fn clamps_future_timestamps() { + assert_eq!(relative_time(Timestamp::now() + 600), "just now"); + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 17f1d16..a817502 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true assets = { path = "../assets" } signed_core = { path = "../signed_core" } signed_state = { path = "../signed_state" } +utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 224258a..b15ee8d 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -6,7 +6,8 @@ use gpui::{ use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::{ActiveTheme, StyledExt}; use signed_core::Announcement; -use signed_state::{ProfileStore, RepoListStore}; +use signed_state::{ProfileStore, RepoListStore, Timestamp}; +use utils::relative_time; /// Browse all announced repositories (works anonymously). pub struct RepoListView { @@ -27,7 +28,12 @@ impl RepoListView { } } - fn render_card(&self, announcement: &Announcement, cx: &mut App) -> AnyElement { + fn render_card( + &self, + announcement: &Announcement, + last_activity: Option, + cx: &mut App, + ) -> AnyElement { let name = announcement .name .clone() @@ -40,9 +46,41 @@ impl RepoListView { let description = announcement.description.clone().unwrap_or_default(); + let mut hashtags: Vec = announcement + .hashtags + .iter() + .take(3) + .map(|tag| { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(SharedString::from(format!("#{tag}"))) + .into_any_element() + }) + .collect(); + + let remaining = announcement.hashtags.len().saturating_sub(3); + + if remaining > 0 { + hashtags.push( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(SharedString::from(format!("+{remaining}"))) + .into_any_element(), + ); + } + + let activity = last_activity + .map(relative_time) + .map(|label| SharedString::from(format!("Updated {label}"))) + .unwrap_or_default(); + div() .v_flex() - .h(px(60.)) + .h(px(78.)) .w_full() .justify_center() .gap_1() @@ -78,6 +116,24 @@ impl RepoListView { .text_ellipsis() .child(description), ) + .child( + div() + .h_flex() + .gap_2() + .items_center() + .overflow_hidden() + .children(hashtags) + .child( + div() + .flex_1() + .h_flex() + .justify_end() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(activity), + ), + ) .into_any_element() } } @@ -103,6 +159,7 @@ impl Focusable for RepoListView { impl Render for RepoListView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let announcements = self.store.read(cx).announcements.clone(); + let last_activity = self.store.read(cx).last_activity.clone(); let has_announcements = !announcements.is_empty(); let count = announcements.len(); @@ -147,7 +204,9 @@ impl Render for RepoListView { let mut items = vec![]; for ix in range { - items.push(this.render_card(&announcements[ix], cx)); + let announcement: &Announcement = &announcements[ix]; + let activity = last_activity.get(&announcement.addr()).copied(); + items.push(this.render_card(announcement, activity, cx)); } items -- 2.54.0 From c9b8edff8714247b05326492686091bf0d08e5b9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 8 Aug 2026 12:48:44 +0700 Subject: [PATCH 19/64] update repo list --- crates/workspace/src/views/repo_list.rs | 174 ++++++++++++------------ crates/workspace/src/workspace.rs | 16 ++- desktop/src/main.rs | 4 + 3 files changed, 100 insertions(+), 94 deletions(-) diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index b15ee8d..251dab1 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -1,29 +1,48 @@ +use std::rc::Rc; + use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - Subscription, Window, div, px, uniform_list, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Subscription, Window, div, px, size, }; +use gpui_component::avatar::Avatar; use gpui_component::dock::{Panel, PanelEvent}; -use gpui_component::{ActiveTheme, StyledExt}; +use gpui_component::scroll::Scrollbar; +use gpui_component::{ + ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, +}; use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore, Timestamp}; use utils::relative_time; +const CARD_HEIGHT: f32 = 160.; + /// Browse all announced repositories (works anonymously). pub struct RepoListView { store: Entity, focus_handle: FocusHandle, + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, _subscription: Subscription, } impl RepoListView { pub fn new(_window: &mut Window, cx: &mut Context) -> Self { let store = cx.new(|cx| RepoListStore::new(None, cx)); - let subscription = cx.observe(&store, |_this, _store, cx| cx.notify()); + + let subscription = cx.observe(&store, |this, store, cx| { + let count = store.read(cx).announcements.len(); + + if this.item_sizes.len() != count { + this.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); count]); + } + }); Self { store, focus_handle: cx.focus_handle(), + scroll_handle: VirtualListScrollHandle::new(), + item_sizes: Rc::new(vec![]), _subscription: subscription, } } @@ -34,99 +53,71 @@ impl RepoListView { last_activity: Option, cx: &mut App, ) -> AnyElement { + let owner = ProfileStore::global(cx).read(cx).get(&announcement.owner); + let name = announcement .name .clone() - .unwrap_or_else(|| announcement.id.clone()); - - let owner = ProfileStore::global(cx) - .read(cx) - .get(&announcement.owner) - .name(); + .map(|s| SharedString::from(s.trim())) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())); let description = announcement.description.clone().unwrap_or_default(); - let mut hashtags: Vec = announcement - .hashtags - .iter() - .take(3) - .map(|tag| { - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .child(SharedString::from(format!("#{tag}"))) - .into_any_element() - }) - .collect(); - - let remaining = announcement.hashtags.len().saturating_sub(3); - - if remaining > 0 { - hashtags.push( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .child(SharedString::from(format!("+{remaining}"))) - .into_any_element(), - ); - } - let activity = last_activity .map(relative_time) .map(|label| SharedString::from(format!("Updated {label}"))) .unwrap_or_default(); - div() - .v_flex() - .h(px(78.)) - .w_full() - .justify_center() - .gap_1() + v_flex() .px_4() + .w_full() .border_b(px(1.)) .border_color(cx.theme().border) .child( - div() - .h_flex() - .gap_2() - .items_center() - .child( - div() - .text_sm() - .font_semibold() - .whitespace_nowrap() - .text_ellipsis() - .child(name), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .child(owner), - ), + h_flex() + .h_12() + .text_sm() + .font_semibold() + .whitespace_nowrap() + .text_ellipsis() + .child(name), ) .child( div() - .text_xs() + .h_16() + .text_sm() .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .text_ellipsis() + .line_clamp(2) .child(description), ) .child( - div() - .h_flex() + h_flex() + .h_12() .gap_2() .items_center() .overflow_hidden() - .children(hashtags) .child( - div() + h_flex() + .gap_2() + .items_center() + .child( + Avatar::new() + .name(owner.name()) + .when_some(owner.picture(), |this, url| this.src(url)) + .small() + .border_0(), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(owner.name()), + ), + ) + .child( + h_flex() .flex_1() - .h_flex() .justify_end() .text_xs() .text_color(cx.theme().muted_foreground) @@ -163,12 +154,11 @@ impl Render for RepoListView { let has_announcements = !announcements.is_empty(); let count = announcements.len(); - div() - .v_flex() + v_flex() + .relative() .size_full() .child( - div() - .h_flex() + h_flex() .px_4() .py_2() .items_center() @@ -182,25 +172,21 @@ impl Render for RepoListView { ) .when(!has_announcements, |this| { this.child( - div() - .size_full() - .v_flex() - .items_center() - .justify_center() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("No repositories found. Waiting for relays..."), - ), + v_flex().size_full().items_center().justify_center().child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No repositories found. Waiting for relays..."), + ), ) }) .when(has_announcements, |this| { this.child( - uniform_list( + v_virtual_list( + cx.entity().clone(), "repos", - count, - cx.processor(move |this, range, _window, cx| { + self.item_sizes.clone(), + move |this, range, _window, cx| { let mut items = vec![]; for ix in range { @@ -210,10 +196,20 @@ impl Render for RepoListView { } items - }), + }, ) + .track_scroll(&self.scroll_handle) .size_full(), ) }) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&self.scroll_handle)), + ) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b979783..1a109c6 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -5,7 +5,7 @@ use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dock::{DockArea, DockItem}; -use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, TitleBar, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex}; use signed_state::{Backend, BackendEvent}; use crate::views::SidebarPanel; @@ -15,7 +15,7 @@ use crate::views::sidebar::passphrase_dialog; pub struct Workspace { dock: Entity, status: SharedString, - _subscription: Subscription, + _subscriptions: Vec, _passphrase_subscription: Subscription, } @@ -50,7 +50,13 @@ impl Workspace { "Connecting...".into() }; - let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { + let mut subscriptions = vec![]; + + subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| { + Theme::sync_system_appearance(Some(window), cx); + })); + + subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { match event { BackendEvent::SyncProgress { total, current } => { this.status = format!("Syncing repositories... {current}/{total}").into() @@ -61,7 +67,7 @@ impl Workspace { _ => return, } cx.notify(); - }); + })); // Ask for the passphrase when the stored identity is NIP-49 // encrypted. Subscribed via the window, since opening a dialog @@ -83,7 +89,7 @@ impl Workspace { Self { dock, status, - _subscription: subscription, + _subscriptions: subscriptions, _passphrase_subscription: passphrase_subscription, } } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index b14c693..52e6a01 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use assets::Assets; use gpui::*; +use gpui_component::theme; use gpui_platform::application; fn main() { @@ -18,6 +19,9 @@ fn main() { // Initialize components gpui_component::init(cx); + // Initialize theme + theme::init(cx); + // Initialize backend and stores (connects relays, restores session) std::fs::create_dir_all(paths::nostr_dir()).ok(); signed_state::init(paths::nostr_dir(), cx); -- 2.54.0 From b3b0824e835747a42915075925f2d946b5b0f181 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 8 Aug 2026 13:13:30 +0700 Subject: [PATCH 20/64] add support deletion --- crates/signed_core/src/deletions.rs | 79 ++++++++++++++++++++++++++++ crates/signed_core/src/filters.rs | 20 +++++++ crates/signed_core/src/lib.rs | 2 + crates/signed_state/src/repo.rs | 43 +++++++++------ crates/signed_state/src/repo_list.rs | 37 ++++++++----- 5 files changed, 151 insertions(+), 30 deletions(-) create mode 100644 crates/signed_core/src/deletions.rs diff --git a/crates/signed_core/src/deletions.rs b/crates/signed_core/src/deletions.rs new file mode 100644 index 0000000..131c1b0 --- /dev/null +++ b/crates/signed_core/src/deletions.rs @@ -0,0 +1,79 @@ +use std::collections::HashSet; + +use nostr::prelude::*; + +/// NIP-09 deletion requests and NIP-62 vanish requests, used to hide +/// deleted events before they reach the UI. +/// +/// Built from the kind-5 / kind-62 events stored in the local database; +/// pass any event through [`Deletions::is_deleted`] before displaying it. +pub struct Deletions { + /// `(deleted event id, expected author)` from `e` tags of kind-5 events. + ids: HashSet<(EventId, PublicKey)>, + /// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events. + /// All versions of the addressable event up to `cutoff` are deleted. + coords: Vec<(Coordinate, PublicKey, Timestamp)>, + /// `(author, cutoff)` from kind-62 vanish requests. + vanished: Vec<(PublicKey, Timestamp)>, +} + +impl Deletions { + /// Build the deletion index from raw kind-5 and kind-62 events. + pub fn from_events(events: impl IntoIterator) -> Self { + let mut ids = HashSet::new(); + let mut coords = Vec::new(); + let mut vanished = Vec::new(); + + for event in events { + if event.kind == Kind::EventDeletion { + ids.extend(event.tags.event_ids().map(|id| (id, event.pubkey))); + coords.extend( + event + .tags + .coordinates() + .map(|c| (c, event.pubkey, event.created_at)), + ); + } else if event.kind == Kind::RequestToVanish { + // Client-side we can't verify which relay the request targeted, + // so any vanish request is honored for the author's events. + vanished.push((event.pubkey, event.created_at)); + } + } + + Self { + ids, + coords, + vanished, + } + } + + /// Whether the event is covered by a valid deletion or vanish request. + /// + /// A request is only valid when its author matches the deleted event's + /// author (NIP-09); addressable events are deleted up to the request's + /// `created_at`. + pub fn is_deleted(&self, event: &Event) -> bool { + if self + .vanished + .iter() + .any(|(pk, cutoff)| *pk == event.pubkey && event.created_at <= *cutoff) + { + return true; + } + + if self.ids.contains(&(event.id, event.pubkey)) { + return true; + } + + if event.kind.is_addressable() + && let Some(identifier) = event.tags.identifier() + { + let coordinate = Coordinate::new(event.kind, event.pubkey).identifier(identifier); + return self.coords.iter().any(|(c, pk, cutoff)| { + *c == coordinate && *pk == event.pubkey && event.created_at <= *cutoff + }); + } + + false + } +} diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 8bc26c3..6d2875b 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -72,3 +72,23 @@ pub fn announcements_by(public_key: PublicKey) -> Filter { pub fn all_announcements() -> Filter { Filter::new().kind(Kind::GitRepoAnnouncement) } + +/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`). +/// +/// Unbounded, like [`all_announcements`]: deletion requests must be known +/// before any other event can be shown. +pub fn deletions() -> Filter { + Filter::new().kinds([Kind::EventDeletion, Kind::RequestToVanish]) +} + +/// Deletion events relevant to a single repository: requests authored by +/// the repository owner and requests addressed to the repository +/// coordinate (`#a` tag). +pub fn deletions_for_repo(addr: &RepoAddr) -> Vec { + vec![ + Filter::new() + .kinds([Kind::EventDeletion, Kind::RequestToVanish]) + .author(addr.public_key), + Filter::new().kind(Kind::EventDeletion).coordinate(addr), + ] +} diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index e80f267..6736a64 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -1,5 +1,6 @@ pub mod addr; pub mod clone_url; +pub mod deletions; pub mod filters; pub mod model; pub mod state; @@ -7,6 +8,7 @@ pub mod status; pub use addr::{RepoAddr, repo_addr}; pub use clone_url::{CloneTarget, parse_clone_url}; +pub use deletions::Deletions; pub use model::Announcement; pub use state::parse_state; pub use status::{RepoStatus, references_root, resolve_status}; diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 5d189c8..6c85cec 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, RepoStatus, filters, parse_state}; +use signed_core::{Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state}; use crate::backend::{Backend, BackendEvent}; @@ -41,11 +41,14 @@ impl RepoStore { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { BackendEvent::NostrUpdate(update) => { + // Deletions may target any event of this repository. + let deletion = + update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; let coordinate = update.coordinate.as_ref() == Some(&this.addr); let author = update.author == this.addr.public_key; let kind = update.kind == Kind::GitRepoAnnouncement; - coordinate || (author && kind) + deletion || coordinate || (author && kind) } BackendEvent::Published(event) => { let kind = event.kind == Kind::GitRepoAnnouncement; @@ -94,14 +97,15 @@ impl RepoStore { let addr = self.addr.clone(); Backend::global(cx).update(cx, |backend, cx| { - backend.subscribe_bootstrap( - vec![ - filters::announcement(&addr), - filters::state(&addr), - filters::activity(&addr), - ], - cx, - ); + let mut repo_filters = vec![ + filters::announcement(&addr), + filters::state(&addr), + filters::activity(&addr), + ]; + // Deletion requests (NIP-09/62) must be known before any + // event of this repository can be shown. + repo_filters.extend(filters::deletions_for_repo(&addr)); + backend.subscribe_bootstrap(repo_filters, cx); }); } @@ -141,31 +145,38 @@ impl RepoStore { let addr = self.addr.clone(); let work = cx.background_spawn(async move { - let queries = async { + let (announcements, states, activity, deletion_events) = async { let db = client.database(); - let announcements = db.query(filters::announcement(&addr)).await?; let states = db.query(filters::state(&addr)).await?; let activity = db.query(filters::activity(&addr)).await?; + let deletion_events = db.query(filters::deletions()).await?; - Ok::<_, Error>((announcements, states, activity)) + Ok::<_, Error>((announcements, states, activity, deletion_events)) } .await?; - let (announcements, states, activity) = queries; + let deletions = Deletions::from_events(deletion_events); // Parse and sort off the main thread; only plain data // crosses back into the entity. - let announcement = latest(announcements) + let all_announcements = announcements + .into_iter() + .filter(|e| !deletions.is_deleted(e)); + let announcement = latest(all_announcements) .as_ref() .and_then(Announcement::from_event); - let state = latest(states).map(|state| parse_state(&state)); + let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); + let state = latest(all_states).map(|state| parse_state(&state)); let (mut issues, mut patches, mut pull_requests, mut statuses) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); for event in activity { + if deletions.is_deleted(&event) { + continue; + } match event.kind { Kind::GitIssue => issues.push(event), Kind::GitPatch => patches.push(event), diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index bb37d6e..5c93224 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, filters, repo_addr}; +use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr}; use crate::backend::{Backend, BackendEvent}; @@ -40,9 +40,12 @@ impl RepoListStore { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { BackendEvent::NostrUpdate(update) => { - // Activity (patches, issues, ...) is addressed to repos via - // `a` tags, so its author isn't the repo owner; always refresh. - if filters::ACTIVITY_KINDS.contains(&update.kind) { + // Deletions may target anything we list; always refresh. + if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish { + true + } else if filters::ACTIVITY_KINDS.contains(&update.kind) { + // Activity (patches, issues, ...) is addressed to repos via + // `a` tags, so its author isn't the repo owner; always refresh. true } else { let is_announcement = update.kind == Kind::GitRepoAnnouncement; @@ -98,6 +101,9 @@ impl RepoListStore { None => filters::all_announcements(), }; backend.sync_bootstrap(filter, cx); + // Deletion requests (NIP-09/62) must be known before any + // announcement can be shown. + backend.sync_bootstrap(filters::deletions(), cx); }); } @@ -143,12 +149,18 @@ impl RepoListStore { }; let events = client.database().query(filter).await?; + let deletion_events = client.database().query(filters::deletions()).await?; + let deletions = Deletions::from_events(deletion_events); // Dedup and sort off the main thread; only the final list // crosses back into the entity. let mut by_repo: HashMap = HashMap::new(); for event in events { + if deletions.is_deleted(&event) { + continue; + } + let Some(announcement) = Announcement::from_event(&event) else { continue; }; @@ -175,6 +187,9 @@ impl RepoListStore { let state_filter = Filter::new().kind(Kind::RepoState); for event in client.database().query(state_filter).await? { + if deletions.is_deleted(&event) { + continue; + } let Some(id) = event.tags.identifier() else { continue; }; @@ -191,16 +206,10 @@ impl RepoListStore { .kinds(filters::ACTIVITY_KINDS) .since(Timestamp::now() - ACTIVITY_WINDOW); for event in client.database().query(activity_filter).await? { - for tag in event.tags.iter() { - if tag.kind() != "a" { - continue; - } - let Some(content) = tag.content() else { - continue; - }; - let Ok(addr) = Coordinate::parse(content) else { - continue; - }; + if deletions.is_deleted(&event) { + continue; + } + for addr in event.tags.coordinates() { if addr.kind != Kind::GitRepoAnnouncement { continue; } -- 2.54.0 From 5ba437ed48303374ba636add8dfb965c543f5ec5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 8 Aug 2026 16:38:25 +0700 Subject: [PATCH 21/64] update --- Cargo.lock | 1 + crates/signed_core/Cargo.toml | 1 + crates/signed_core/src/model.rs | 31 +- crates/signed_state/src/backend.rs | 276 +++++++++--------- crates/signed_state/src/profile.rs | 13 +- crates/signed_state/src/repo.rs | 15 +- crates/workspace/src/views/repo_list.rs | 1 - .../src/views/sidebar/onboarding_dialog.rs | 10 +- .../src/views/sidebar/passphrase_dialog.rs | 18 +- 9 files changed, 180 insertions(+), 186 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8015da2..c8a5c98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7784,6 +7784,7 @@ dependencies = [ name = "signed_core" version = "1.0.0" dependencies = [ + "gpui", "nostr", ] diff --git a/crates/signed_core/Cargo.toml b/crates/signed_core/Cargo.toml index 5b1e5ca..bd77e36 100644 --- a/crates/signed_core/Cargo.toml +++ b/crates/signed_core/Cargo.toml @@ -5,4 +5,5 @@ edition.workspace = true publish.workspace = true [dependencies] +gpui.workspace = true nostr.workspace = true diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 6d104f3..fbb7261 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -1,3 +1,4 @@ +use gpui::SharedString; use nostr::prelude::*; /// Parsed NIP-34 repository announcement (plain data, ready for the UI). @@ -9,14 +10,14 @@ pub struct Announcement { pub created_at: Timestamp, /// Repository ID (`d` tag). pub id: String, - pub name: Option, - pub description: Option, + pub name: Option, + pub description: Option, /// Webpage URLs for browsing. pub web: Vec, /// URLs for `git clone`. pub clone: Vec, /// Relays the repository monitors for patches and issues. - pub relays: Vec, + pub relays: Vec, /// Earliest unique commit ID (`r` tag with `euc` marker). pub euc: Option, /// Other recognized maintainers. @@ -33,11 +34,11 @@ impl Announcement { } let mut id: Option = None; - let mut name: Option = None; - let mut description: Option = None; + let mut name: Option = None; + let mut description: Option = None; let mut web: Vec = Vec::new(); let mut clone: Vec = Vec::new(); - let mut relays: Vec = Vec::new(); + let mut relays: Vec = Vec::new(); let mut euc: Option = None; let mut maintainers: Vec = Vec::new(); let mut hashtags: Vec = Vec::new(); @@ -56,15 +57,13 @@ impl Announcement { } match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Name(value)) => name = Some(value), - Ok(Nip34Tag::Description(value)) => description = Some(value), + Ok(Nip34Tag::Name(value)) => name = Some(value.into()), + Ok(Nip34Tag::Description(value)) => description = Some(value.into()), Ok(Nip34Tag::Web(urls)) => web.extend(urls.into_iter().map(|url| url.to_string())), Ok(Nip34Tag::Clone(urls)) => { clone.extend(urls.into_iter().map(|url| url.to_string())) } - Ok(Nip34Tag::Relays(urls)) => { - relays.extend(urls.into_iter().map(|url| url.to_string())) - } + Ok(Nip34Tag::Relays(urls)) => relays.extend(urls), Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()), Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys), _ => {} @@ -144,7 +143,10 @@ mod tests { ); assert_eq!(announcement.web, vec!["https://example.com/repo"]); assert_eq!(announcement.clone, vec!["https://example.com/repo.git"]); - assert_eq!(announcement.relays, vec!["wss://relay.example.com"]); + assert_eq!( + announcement.relays, + vec![RelayUrl::parse("wss://relay.example.com").unwrap()] + ); assert_eq!( announcement.euc.as_deref(), Some("aa231c4c6a5777dc89b42207b499891a344add5c") @@ -185,7 +187,10 @@ mod tests { // An invalid URL keeps the whole clone tag from being parsed. assert!(announcement.clone.is_empty()); - assert_eq!(announcement.relays, vec!["wss://good.example.com"]); + assert_eq!( + announcement.relays, + vec![RelayUrl::parse("wss://good.example.com").unwrap()] + ); assert!(announcement.maintainers.is_empty()); } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index f5172dd..82128b0 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -243,160 +243,135 @@ impl Backend { /// Decrypt the NIP-49 encrypted credential stored in the keyring with /// the given passphrase and resume the session. /// - /// The scrypt decryption runs off the UI thread. The returned receiver + /// The scrypt decryption runs off the UI thread. The returned task /// yields the public key on success, or the failure reason (e.g. wrong /// passphrase), so callers can render inline errors. pub fn restore_with_passphrase( &mut self, password: &str, cx: &mut Context, - ) -> flume::Receiver> { - let (tx, rx) = flume::bounded(1); - + ) -> Task> { let password = password.to_owned(); let user = cx.read_credentials(USER_KEYRING); - self.tasks.push(cx.spawn(async move |this, cx| { - let result = async { - let content = user - .await? - .map(|(_username, secret)| String::from_utf8(secret)) - .transpose()? - .ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?; + cx.spawn(async move |this, cx| { + let content = user + .await? + .map(|(_username, secret)| String::from_utf8(secret)) + .transpose()? + .ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?; - if !content.starts_with("ncryptsec1") { - Err(anyhow!("stored credential is not passphrase-encrypted"))?; - } - - let decrypt_task = cx.background_spawn(async move { - let encrypted = EncryptedSecretKey::from_bech32(&content)?; - let secret = encrypted.decrypt(&password)?; - Ok::<_, Error>(Keys::new(secret)) - }); - - let keys = decrypt_task.await?; - let public_key = keys.public_key(); - - this.update(cx, |this, cx| this.set_signer(keys, cx))?; - - Ok::<_, Error>(public_key) + if !content.starts_with("ncryptsec1") { + return Err(anyhow!("stored credential is not passphrase-encrypted")); } - .await; - tx.send_async(result).await.ok(); - Ok(()) - })); + let decrypt_task = cx.background_spawn(async move { + let encrypted = EncryptedSecretKey::from_bech32(&content)?; + let secret = encrypted.decrypt(&password)?; + Ok::<_, Error>(Keys::new(secret)) + }); - rx + let keys = decrypt_task.await?; + let public_key = keys.public_key(); + + this.update(cx, |this, cx| this.set_signer(keys, cx))?; + + Ok(public_key) + }) } /// Create a new identity: generate keys, encrypt the secret key with the /// passphrase (NIP-49) and persist it in the keyring, then publish the /// user's NIP-65 relay list, metadata and grasp list. /// - /// The heavy encryption runs off the UI thread. The returned receiver - /// yields the new public key on success, or the failure reason, so - /// callers can render progress and inline errors. + /// The heavy encryption runs off the UI thread. The returned task yields + /// the new public key on success, or the failure reason, so callers can + /// render progress and inline errors. pub fn create_identity( &mut self, name: &str, password: &str, cx: &mut Context, - ) -> flume::Receiver> { - let (tx, rx) = flume::bounded(1); - + ) -> Task> { let name = name.trim().to_owned(); let password = password.to_owned(); - let validation_error = if name.is_empty() || name.len() > 255 { - Some("Name must be 1-255 characters") - } else if password.is_empty() { - Some("Passphrase must not be empty") - } else { - None - }; - - if let Some(message) = validation_error { - tx.try_send(Err(anyhow!(message))).ok(); - return rx; + if name.is_empty() || name.len() > 255 { + return Task::ready(Err(anyhow!("Name must be 1-255 characters"))); + } + if password.is_empty() { + return Task::ready(Err(anyhow!("Passphrase must not be empty"))); } - let job = cx.background_spawn(async move { - let keys = Keys::generate(); - let encrypted = - EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?; - let ncryptsec = encrypted.to_bech32()?; - Ok::<_, Error>((keys, ncryptsec)) - }); + cx.spawn(async move |this, cx| { + let job = cx.background_spawn(async move { + let keys = Keys::generate(); + let encrypted = + EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?; + let ncryptsec = encrypted.to_bech32()?; + Ok::<_, Error>((keys, ncryptsec)) + }); - self.tasks.push(cx.spawn(async move |this, cx| { - let result = async { - let (keys, ncryptsec) = job.await?; - let public_key = keys.public_key(); + let (keys, ncryptsec) = job.await?; + let public_key = keys.public_key(); - // Persist the encrypted credential. - let write = cx.update(|cx| { - cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes()) - }); - write.await?; + // Persist the encrypted credential. + let write = cx.update(|cx| { + cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes()) + }); + write.await?; - this.update(cx, |this, cx| { - // Become the new identity, so the publishes below are - // signed with the new keys. - this.signer.swap_inner(keys); - this.current_user = Some(public_key); - this.bootstrap_user(public_key, cx); - cx.emit(BackendEvent::SignerChanged); - cx.notify(); + this.update(cx, |this, cx| { + // Become the new identity, so the publishes below are + // signed with the new keys. + this.signer.swap_inner(keys); + this.current_user = Some(public_key); + this.bootstrap_user(public_key, cx); + cx.emit(BackendEvent::SignerChanged); + cx.notify(); - let relays: Vec<(RelayUrl, Option)> = [ - ( - RelayUrl::parse("wss://relay.primal.net").unwrap(), - Some(RelayMetadata::Read), - ), - ( - RelayUrl::parse("wss://relay.ditto.pub").unwrap(), - Some(RelayMetadata::Read), - ), - ( - RelayUrl::parse("wss://relay.nostr.net").unwrap(), - Some(RelayMetadata::Write), - ), - ( - RelayUrl::parse("wss://nos.lol").unwrap(), - Some(RelayMetadata::Write), - ), - ] - .to_vec(); + let relays: Vec<(RelayUrl, Option)> = [ + ( + RelayUrl::parse("wss://relay.primal.net").unwrap(), + Some(RelayMetadata::Read), + ), + ( + RelayUrl::parse("wss://relay.ditto.pub").unwrap(), + Some(RelayMetadata::Read), + ), + ( + RelayUrl::parse("wss://relay.nostr.net").unwrap(), + Some(RelayMetadata::Write), + ), + ( + RelayUrl::parse("wss://nos.lol").unwrap(), + Some(RelayMetadata::Write), + ), + ] + .to_vec(); - this.send(RelayList::new(relays).into_event_builder(), cx); + this.send_fire_and_forget(RelayList::new(relays).into_event_builder(), cx); - let metadata = Metadata::new() - .name(&name) - .display_name(&name) - .into_event_builder(); + let metadata = Metadata::new() + .name(&name) + .display_name(&name) + .into_event_builder(); - this.send(metadata, cx); + this.send_fire_and_forget(metadata, cx); - let grasp_servers: Vec = - ["wss://gitnostr.com", "wss://relay.ngit.dev"] - .into_iter() - .map(|url| RelayUrl::parse(url).expect("valid relay URL")) - .collect(); + let grasp_servers: Vec = ["wss://gitnostr.com", "wss://relay.ngit.dev"] + .into_iter() + .map(|url| RelayUrl::parse(url).expect("valid relay URL")) + .collect(); - this.send(GitUserGraspList { grasp_servers }.into_event_builder(), cx); - })?; + this.send_fire_and_forget( + GitUserGraspList { grasp_servers }.into_event_builder(), + cx, + ); + })?; - Ok(public_key) - } - .await; - - tx.send_async(result).await.ok(); - - Ok(()) - })); - - rx + Ok(public_key) + }) } /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on @@ -786,59 +761,74 @@ impl Backend { /// Sign, broadcast and locally store an event. Emits /// [`BackendEvent::Published`] on success so stores can refresh. /// - /// The returned receiver yields the outcome of this specific action, - /// so callers can show inline progress/errors instead of relying on - /// the global [`BackendEvent::Error`]. + /// The returned task yields the outcome of this specific action, so + /// callers can show inline progress/errors instead of relying on + /// the global [`BackendEvent::Error`]. The task is owned by the caller; + /// dropping it cancels the publish. pub fn send( &mut self, builder: EventBuilder, cx: &mut Context, - ) -> flume::Receiver> { - let (tx, rx) = flume::bounded(1); + ) -> Task> { let client = self.client.clone(); let signer = self.signer.clone(); - let task = cx.background_spawn(async move { + cx.spawn(async move |this, cx| { // Sign with the current signer, broadcast, and save locally so // the event is immediately visible to database queries. - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).await?; + let work = cx.background_spawn(async move { + let event = builder.finalize_async(&signer).await?; + let output = client.send_event(&event).await?; - if output.success.is_empty() && !output.failed.is_empty() { - let reasons = output - .failed - .values() - .cloned() - .collect::>() - .join(", "); - return Err(anyhow!("event not accepted by any relay: {reasons}")); - } + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + return Err(anyhow!("event not accepted by any relay: {reasons}")); + } - Ok(event) - }); + Ok(event) + }); - self.tasks.push(cx.spawn(async move |this, cx| { - let result = task.await; + let result = work.await; match &result { Ok(event) => { this.update(cx, |_this, cx| { cx.emit(BackendEvent::Published(Box::new(event.clone()))); - })?; + }) + .ok(); } Err(e) => { this.update(cx, |_this, cx| { cx.emit(BackendEvent::error(e.to_string())); - })?; + }) + .ok(); } } - tx.send_async(result) - .await - .map_err(|_| anyhow!("action result receiver dropped")) - })); + result + }) + } - rx + /// Sign, broadcast and store an event without awaiting the result; + /// failures surface through [`BackendEvent::Error`]. The spawned task is + /// owned by the backend, so it is cancelled when the backend is dropped. + fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context) { + let task = self.send(builder, cx); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(e) = task.await { + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); + }) + .ok(); + } + Ok(()) + })); } } diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index cc2e714..00fa896 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -1,5 +1,5 @@ +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; -use std::sync::RwLock; use std::time::{Duration, Instant}; use anyhow::Error; @@ -73,8 +73,8 @@ const BATCH_TIMEOUT: Duration = Duration::from_millis(500); /// data; the whole store notifies on change. pub struct ProfileStore { profiles: HashMap, - /// Public keys we've already requested this session. - seen: RwLock>, + /// Public keys we've already requested this session (main thread only). + seen: RefCell>, /// Sender for queuing fetch requests, batched by a background task. sender: Sender, tasks: Vec>>, @@ -133,7 +133,7 @@ impl ProfileStore { let mut store = Self { profiles: HashMap::new(), - seen: RwLock::new(HashSet::new()), + seen: RefCell::new(HashSet::new()), sender, tasks, _subscription: subscription, @@ -152,7 +152,7 @@ impl ProfileStore { let public_key = *public_key; - if self.seen.write().unwrap().insert(public_key) + if self.seen.borrow_mut().insert(public_key) && let Err(e) = self.sender.send(public_key) { log::warn!("failed to queue profile fetch: {e}"); @@ -232,7 +232,8 @@ impl ProfileStore { /// Re-read the latest metadata of every requested author from the local /// database (used after a sync, which produces no NostrUpdate events). fn apply_seen(&mut self, cx: &mut Context) { - let authors: Vec = self.seen.read().unwrap().iter().copied().collect(); + let authors: Vec = self.seen.borrow().iter().copied().collect(); + if authors.is_empty() { return; } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 6c85cec..baecad4 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -94,9 +94,10 @@ impl RepoStore { /// Fetch this repository's events from the bootstrap relays (one-shot, /// auto-closing subscription). fn subscribe_remote(&mut self, cx: &mut Context) { + let backend = Backend::global(cx); let addr = self.addr.clone(); - Backend::global(cx).update(cx, |backend, cx| { + backend.update(cx, |backend, cx| { let mut repo_filters = vec![ filters::announcement(&addr), filters::state(&addr), @@ -309,20 +310,18 @@ impl RepoStore { fn send(&mut self, builder: EventBuilder, cx: &mut Context) { self.last_error = None; - let rx = Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx)); + let backend = Backend::global(cx); + let task = backend.update(cx, |backend, cx| backend.send(builder, cx)); - let task = cx.spawn(async move |this, cx| { - if let Ok(Err(e)) = rx.recv_async().await { + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(e) = task.await { this.update(cx, |this, cx| { this.last_error = Some(e.to_string()); cx.notify(); })?; } - Ok(()) - }); - - self.tasks.push(task); + })); } } diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 251dab1..94775de 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -58,7 +58,6 @@ impl RepoListView { let name = announcement .name .clone() - .map(|s| SharedString::from(s.trim())) .unwrap_or_else(|| SharedString::from(announcement.id.clone())); let description = announcement.description.clone().unwrap_or_default(); diff --git a/crates/workspace/src/views/sidebar/onboarding_dialog.rs b/crates/workspace/src/views/sidebar/onboarding_dialog.rs index be9f5bb..20112f8 100644 --- a/crates/workspace/src/views/sidebar/onboarding_dialog.rs +++ b/crates/workspace/src/views/sidebar/onboarding_dialog.rs @@ -103,21 +103,20 @@ pub fn open( state.error = None; }); - let rx = backend.update(cx, |backend, cx| { + let task = backend.update(cx, |backend, cx| { backend.create_identity(&name, &pass, cx) }); - let handle = window.window_handle(); let state = state.clone(); - cx.spawn(async move |cx| match rx.recv_async().await { - Ok(Ok(_)) => { + cx.spawn(async move |cx| match task.await { + Ok(_) => { cx.update_window(handle, |_, window, cx| { window.close_dialog(cx); }) .ok(); } - Ok(Err(e)) => { + Err(e) => { cx.update_window(handle, |_, _window, cx| { state.update(cx, |state, _| { state.busy = false; @@ -126,7 +125,6 @@ pub fn open( }) .ok(); } - Err(_) => {} }) .detach(); } diff --git a/crates/workspace/src/views/sidebar/passphrase_dialog.rs b/crates/workspace/src/views/sidebar/passphrase_dialog.rs index 8738b96..e0670b3 100644 --- a/crates/workspace/src/views/sidebar/passphrase_dialog.rs +++ b/crates/workspace/src/views/sidebar/passphrase_dialog.rs @@ -118,18 +118,19 @@ fn unlock( state.error = None; }); - let rx = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx)); - + let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx)); let handle = *handle; let state = state.clone(); - cx.spawn(async move |cx| match rx.recv_async().await { - Ok(Ok(_)) => { - cx.update_window(handle, |_, window, cx| window.close_dialog(cx)) - .ok(); + cx.spawn(async move |cx| match task.await { + Ok(_) => { + cx.update_window(handle, |_this, window, cx| { + window.close_dialog(cx); + }) + .ok(); } - Ok(Err(e)) => { - cx.update_window(handle, |_, _window, cx| { + Err(e) => { + cx.update_window(handle, |_this, _window, cx| { state.update(cx, |state, _| { state.busy = false; state.error = Some(e.to_string().into()); @@ -137,7 +138,6 @@ fn unlock( }) .ok(); } - Err(_) => {} }) .detach(); } -- 2.54.0 From e36d96bf50b4ebf4e0eccecca6fd45797de3307b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 8 Aug 2026 17:08:20 +0700 Subject: [PATCH 22/64] . --- crates/signed_core/src/model.rs | 49 +++++++++++-------------- crates/workspace/src/views/repo_list.rs | 8 ++-- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index fbb7261..60ada21 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -4,18 +4,18 @@ use nostr::prelude::*; /// Parsed NIP-34 repository announcement (plain data, ready for the UI). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Announcement { + /// Repository ID (`d` tag). + pub id: String, /// Author of the announcement event. pub owner: PublicKey, /// When the announcement was published (for latest-wins resolution). pub created_at: Timestamp, - /// Repository ID (`d` tag). - pub id: String, pub name: Option, pub description: Option, /// Webpage URLs for browsing. - pub web: Vec, + pub web: Vec, /// URLs for `git clone`. - pub clone: Vec, + pub clone: Vec, /// Relays the repository monitors for patches and issues. pub relays: Vec, /// Earliest unique commit ID (`r` tag with `euc` marker). @@ -33,36 +33,25 @@ impl Announcement { return None; } - let mut id: Option = None; + let id = event.tags.identifier()?; + + let mut hashtags: Vec = Vec::new(); + hashtags.extend(event.tags.hashtags().map(|t| t.to_string())); + let mut name: Option = None; let mut description: Option = None; - let mut web: Vec = Vec::new(); - let mut clone: Vec = Vec::new(); + let mut web: Vec = Vec::new(); + let mut clone: Vec = Vec::new(); let mut relays: Vec = Vec::new(); let mut euc: Option = None; let mut maintainers: Vec = Vec::new(); - let mut hashtags: Vec = Vec::new(); for tag in event.tags.iter() { - // The `d` and `t` tags aren't part of the NIP-34 tag codec; parse them directly. - if tag.kind() == "d" { - id = tag.content().map(str::to_owned); - continue; - } - if tag.kind() == "t" { - if let Some(value) = tag.content() { - hashtags.push(value.to_owned()); - } - continue; - } - match Nip34Tag::parse(tag.as_slice()) { Ok(Nip34Tag::Name(value)) => name = Some(value.into()), Ok(Nip34Tag::Description(value)) => description = Some(value.into()), - Ok(Nip34Tag::Web(urls)) => web.extend(urls.into_iter().map(|url| url.to_string())), - Ok(Nip34Tag::Clone(urls)) => { - clone.extend(urls.into_iter().map(|url| url.to_string())) - } + Ok(Nip34Tag::Web(urls)) => web.extend(urls), + Ok(Nip34Tag::Clone(urls)) => clone.extend(urls), Ok(Nip34Tag::Relays(urls)) => relays.extend(urls), Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()), Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys), @@ -73,7 +62,7 @@ impl Announcement { Some(Self { owner: event.pubkey, created_at: event.created_at, - id: id?, + id, name, description, web, @@ -141,8 +130,14 @@ mod tests { announcement.description.as_deref(), Some("A test repository") ); - assert_eq!(announcement.web, vec!["https://example.com/repo"]); - assert_eq!(announcement.clone, vec!["https://example.com/repo.git"]); + assert_eq!( + announcement.web, + vec![Url::parse("https://example.com/repo").unwrap()] + ); + assert_eq!( + announcement.clone, + vec![Url::parse("https://example.com/repo.git").unwrap()] + ); assert_eq!( announcement.relays, vec![RelayUrl::parse("wss://relay.example.com").unwrap()] diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 94775de..f6432fd 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -60,7 +60,10 @@ impl RepoListView { .clone() .unwrap_or_else(|| SharedString::from(announcement.id.clone())); - let description = announcement.description.clone().unwrap_or_default(); + let description = announcement + .description + .clone() + .unwrap_or(SharedString::from("No description")); let activity = last_activity .map(relative_time) @@ -103,8 +106,7 @@ impl RepoListView { Avatar::new() .name(owner.name()) .when_some(owner.picture(), |this, url| this.src(url)) - .small() - .border_0(), + .small(), ) .child( div() -- 2.54.0 From 831a89dd1163387e0975b678d4cfcaa2ad11484a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 10 Aug 2026 08:33:19 +0700 Subject: [PATCH 23/64] add repo detail view --- Cargo.lock | 4 + crates/signed_git/Cargo.toml | 3 + crates/signed_git/src/lib.rs | 176 +++++ crates/signed_state/Cargo.toml | 1 + crates/signed_state/src/git_store.rs | 44 ++ crates/signed_state/src/lib.rs | 12 +- crates/workspace/Cargo.toml | 3 + crates/workspace/src/views/mod.rs | 2 + crates/workspace/src/views/repo_detail.rs | 776 ++++++++++++++++++++++ crates/workspace/src/views/repo_list.rs | 40 +- crates/workspace/src/views/sidebar/mod.rs | 2 +- desktop/src/main.rs | 4 + 12 files changed, 1060 insertions(+), 7 deletions(-) create mode 100644 crates/signed_state/src/git_store.rs create mode 100644 crates/workspace/src/views/repo_detail.rs diff --git a/Cargo.lock b/Cargo.lock index c8a5c98..b131831 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7796,6 +7796,7 @@ dependencies = [ "gix", "nostr", "signed_core", + "tempfile", ] [[package]] @@ -7826,6 +7827,7 @@ dependencies = [ "nostr-sdk", "rustls", "signed_core", + "signed_git", "signed_nostr", "utils", ] @@ -10352,10 +10354,12 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" name = "workspace" version = "1.0.0" dependencies = [ + "anyhow", "assets", "gpui", "gpui-component", "signed_core", + "signed_git", "signed_state", "utils", ] diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index bdfb0c0..a853dc1 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -10,3 +10,6 @@ signed_core = { path = "../signed_core" } nostr.workspace = true gix.workspace = true anyhow.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index b208eb2..84b5300 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -140,6 +140,94 @@ fn sanitize_path_component(id: &str) -> String { sanitized } +/// Relative paths of all entries in the worktree (files and directories), +/// directories first, then alphabetically within each group. The `.git` +/// directory is skipped. +pub fn worktree_entries(repo: &gix::Repository) -> Result> { + let workdir = repo.workdir().context("repository has no worktree")?; + + let mut entries: Vec<(PathBuf, bool)> = Vec::new(); + collect_entries(workdir, workdir, &mut entries)?; + + entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { + b_is_dir + .cmp(a_is_dir) + .then_with(|| a.as_os_str().cmp(b.as_os_str())) + }); + Ok(entries.into_iter().map(|(path, _)| path).collect()) +} + +/// Read a file from the worktree. Returns `Ok(None)` if the path is missing +/// or not a regular file. +pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { + let workdir = repo.workdir().context("repository has no worktree")?; + let path = workdir.join(rel); + + match std::fs::read(&path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), + Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), + } +} + +/// Find the README file in the repository root (returned as a path relative +/// to the worktree). Case-insensitive; prefers `README.md`, then `.markdown`, +/// `.mdown`, `.mkdn`, then any other file whose name starts with `readme`. +pub fn find_readme(repo: &gix::Repository) -> Result> { + let Some(workdir) = repo.workdir() else { + return Ok(None); + }; + + let mut candidates: Vec = Vec::new(); + for entry in std::fs::read_dir(workdir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.to_ascii_lowercase().starts_with("readme") { + candidates.push(entry.path()); + } + } + + candidates.sort_by_key(|path| { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("md") => 0, + Some("markdown") => 1, + Some("mdown") => 2, + Some("mkdn") => 3, + Some(_) => 5, + None => 4, + } + }); + + Ok(candidates + .into_iter() + .next() + .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) +} + +fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; + } + + let is_dir = entry.file_type()?.is_dir(); + let path = entry.path(); + let rel = path.strip_prefix(root)?.to_path_buf(); + out.push((rel, is_dir)); + + if is_dir { + collect_entries(root, &path, out)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use nostr::prelude::*; @@ -181,4 +269,92 @@ mod tests { Some("_".into()) ); } + + /// Build a throwaway non-bare repository with the given files (rel → bytes). + fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = gix::init(&dir).expect("init"); + + for (rel, bytes) in files { + let path = dir.path().join(rel); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, bytes).expect("write"); + } + + (dir, repo) + } + + #[test] + fn worktree_entries_lists_all_files_and_dirs() { + let (_dir, repo) = fixture(&[ + ("README.md", b"# Hi"), + ("src/main.rs", b"fn main() {}"), + ("src/lib.rs", b""), + ("docs/guide.md", b"guide"), + ]); + + let entries = worktree_entries(&repo).expect("entries"); + let entries: Vec = entries + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + + assert_eq!( + entries, + vec![ + "docs", + "src", + "README.md", + "docs/guide.md", + "src/lib.rs", + "src/main.rs" + ] + ); + } + + #[test] + fn worktree_read_returns_bytes_or_none() { + let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]); + + assert_eq!( + worktree_read(&repo, Path::new("a.txt")).expect("read"), + Some(b"hello".to_vec()) + ); + assert_eq!( + worktree_read(&repo, Path::new("sub/b.bin")).expect("read"), + Some(vec![0x00, 0x01]) + ); + assert_eq!( + worktree_read(&repo, Path::new("missing.txt")).expect("read"), + None + ); + } + + #[test] + fn find_readme_prefers_markdown() { + let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); + + let readme = find_readme(&repo).expect("find"); + assert_eq!( + readme.map(|p| p.to_string_lossy().into_owned()), + Some("README.md".into()) + ); + } + + #[test] + fn find_readme_falls_back_to_any_readme() { + let (_dir, repo) = fixture(&[("README.rst", b"rst")]); + + let readme = find_readme(&repo).expect("find"); + assert_eq!( + readme.map(|p| p.to_string_lossy().into_owned()), + Some("README.rst".into()) + ); + } + + #[test] + fn find_readme_returns_none_without_one() { + let (_dir, repo) = fixture(&[("main.rs", b"")]); + assert!(find_readme(&repo).expect("find").is_none()); + } } diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index 21ddabf..5192a08 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -6,6 +6,7 @@ publish.workspace = true [dependencies] signed_core = { path = "../signed_core" } +signed_git = { path = "../signed_git" } signed_nostr = { path = "../signed_nostr" } utils = { path = "../utils" } diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs new file mode 100644 index 0000000..ab7b5df --- /dev/null +++ b/crates/signed_state/src/git_store.rs @@ -0,0 +1,44 @@ +use std::path::PathBuf; + +use gpui::{App, Global}; +use signed_git::GitCache; + +struct GlobalGitStore(GitCache); + +impl Global for GlobalGitStore {} + +/// Global access to the on-disk git clone cache (grasp mirrors). +/// +/// Installed at startup via [`GitStore::set_global`]; see also +/// [`signed_state::init`]. +#[derive(Debug, Clone)] +pub struct GitStore(GitCache); + +impl GitStore { + /// Register the clone cache rooted at `root` as an app-wide global. + /// Replaces any previously installed store (see [`signed_state::init`], which + /// installs an empty one). + pub fn set_global(root: impl Into, cx: &mut App) -> Self { + let store = Self::new(root); + cx.set_global(GlobalGitStore(store.0.clone())); + store + } + + /// The app-wide clone cache. + /// + /// # Panics + /// + /// Panics if [`GitStore::set_global`] was never called. + pub fn global(cx: &App) -> Self { + Self(cx.global::().0.clone()) + } + + fn new(root: impl Into) -> Self { + Self(GitCache::new(root.into())) + } + + /// Underlying clone cache. + pub fn cache(&self) -> &GitCache { + &self.0 + } +} diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 7536559..4d1ec70 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,18 +1,20 @@ mod backend; +mod git_store; mod profile; mod repo; mod repo_list; -use std::path::Path; +use std::path::{Path, PathBuf}; pub use backend::{Backend, BackendEvent}; +pub use git_store::GitStore; use gpui::{App, AppContext, Entity}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; -pub use utils::shorten_pubkey; pub use repo::RepoStore; pub use repo_list::RepoListStore; use signed_nostr::new_backend; +pub use utils::shorten_pubkey; /// Initialize the backend and stores, and install them as globals. Call once /// at startup, before opening any window that uses the stores. @@ -35,6 +37,10 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { ProfileStore::set_global(cx.new(ProfileStore::new), cx); + // The clone cache is only meaningful on native platforms; the wasm + // build registers an empty store so `GitStore::global` still works. + GitStore::set_global(PathBuf::new(), cx); + entity } @@ -48,5 +54,7 @@ pub fn init(cx: &mut App) -> Entity { ProfileStore::set_global(cx.new(ProfileStore::new), cx); + GitStore::set_global(PathBuf::new(), cx); + entity } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index a817502..e961ebb 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -7,8 +7,11 @@ publish.workspace = true [dependencies] assets = { path = "../assets" } signed_core = { path = "../signed_core" } +signed_git = { path = "../signed_git" } signed_state = { path = "../signed_state" } utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true + +anyhow.workspace = true diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 4ae1b91..9c95334 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,5 +1,7 @@ +mod repo_detail; mod repo_list; pub(crate) mod sidebar; +pub use repo_detail::RepoDetailView; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/repo_detail.rs b/crates/workspace/src/views/repo_detail.rs new file mode 100644 index 0000000..e2e3217 --- /dev/null +++ b/crates/workspace/src/views/repo_detail.rs @@ -0,0 +1,776 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::Error; +use gpui::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, + Task, WeakEntity, Window, div, px, +}; +use gpui_component::avatar::Avatar; +use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::list::ListItem; +use gpui_component::spinner::Spinner; +use gpui_component::text::{TextView, TextViewState}; +use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; +use signed_core::Announcement; +use signed_state::{GitStore, ProfileStore, RepoStore}; +use utils::relative_time; + +/// Width of the file explorer column. +const TREE_WIDTH: f32 = 280.; +/// Files larger than this are not previewed. +const MAX_PREVIEW_BYTES: usize = 1024 * 1024; + +/// Preview state of a browsed file. +enum FileContent { + /// Decodable text content. + Text(String), + /// Not valid UTF-8. + Binary, + /// Bigger than [`MAX_PREVIEW_BYTES`]. + TooLarge, + /// Reading failed. + Failed(String), +} + +/// A markdown document loaded into a persistent [`TextViewState`]. +/// +/// The state is owned here rather than created per render (as the stateless +/// `text::markdown` helper does), so it survives branch switches in the +/// content pane. GPUI's keyed element state is dropped as soon as the +/// element is absent for a single frame, which would otherwise re-parse the +/// whole document on the main thread every time the pane switches between +/// the README, a file preview, and the loading spinner. +struct MarkdownView { + /// Source path; `None` means the repository README. + path: Option, + state: Entity, +} + +/// Detail view of a repository: header, stats, a file explorer with README +/// preview (cloned from the announcement's `clone` URLs), and metadata. +pub struct RepoDetailView { + /// Live per-repository store, refreshed from the local database. + store: Entity, + /// Snapshot taken at open time, shown until the store's first refresh completes. + initial: Announcement, + /// File explorer state (worktree of the local clone). + tree_state: Entity, + focus_handle: FocusHandle, + tasks: Vec>>, + /// A clone/fetch is in flight. + loading: bool, + error: Option, + /// Root of the local clone, for reading files on demand. + worktree: Option, + /// Markdown document currently in the preview pane (README or a file). + md: Option, + readme_name: Option, + /// Currently previewed file (relative path) and its contents. + selected_file: Option, + files: HashMap, + /// Reads in flight, to avoid duplicate loads. + loading_files: HashSet, +} + +impl RepoDetailView { + pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context) -> Self { + let store = cx.new(|cx| RepoStore::new(initial.addr(), cx)); + let tree_state = cx.new(|cx| TreeState::new(cx)); + + // Defer loading the repository until the window is ready. + cx.defer_in(window, |this, _window, cx| { + this.load_repo(cx); + }); + + Self { + store, + initial, + tree_state, + focus_handle: cx.focus_handle(), + tasks: Vec::new(), + loading: true, + error: None, + worktree: None, + md: None, + readme_name: None, + selected_file: None, + files: HashMap::new(), + loading_files: HashSet::new(), + } + } + + /// Clone (or fetch) the repository and populate the file explorer. + fn load_repo(&mut self, cx: &mut Context) { + self.loading = true; + self.error = None; + cx.notify(); + + let cache = GitStore::global(cx).cache().clone(); + let addr = self.initial.addr(); + let clone_urls: Vec = self.initial.clone.iter().map(ToString::to_string).collect(); + + let load = cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; + let entries = signed_git::worktree_entries(&repo)?; + let readme_path = signed_git::find_readme(&repo)?; + let readme = match &readme_path { + Some(path) => signed_git::worktree_read(&repo, path)?, + None => None, + }; + let worktree = repo.workdir().map(Path::to_path_buf); + + Ok::<_, Error>((entries, readme_path, readme, worktree)) + }); + + let task = cx.spawn(async move |this, cx| { + let result = load.await; + + this.update(cx, |this, cx| { + match result { + Ok((entries, readme_path, readme, Some(worktree))) => { + this.worktree = Some(worktree); + this.tree_state.update(cx, |state, cx| { + state.set_items(build_tree_items(&entries), cx); + }); + if let Some((path, bytes)) = readme_path.zip(readme) { + this.readme_name = Some(path.to_string_lossy().into()); + if let Ok(text) = String::from_utf8(bytes) { + this.set_markdown(None, &text, cx); + } + } + } + Ok((_, _, _, None)) => { + this.error = Some("Repository has no worktree".into()); + } + Err(error) => { + this.error = Some(error.to_string().into()); + } + } + this.loading = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Preview the file at `path` (relative to the worktree root). + 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) || self.loading_files.contains(path) { + cx.notify(); + return; + } + + // Paths come from our own tree walk, but never trust them: refuse + // anything that could escape the worktree. + 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(); + + let task = cx.spawn(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 bytes = match std::fs::read(&full) { + Ok(bytes) => bytes, + Err(error) => return Err(anyhow::anyhow!("{}", error)), + }; + + let kind = if bytes.len() > MAX_PREVIEW_BYTES { + FileContent::TooLarge + } else { + match String::from_utf8(bytes) { + Ok(text) => FileContent::Text(text), + Err(_) => FileContent::Binary, + } + }; + Ok::<_, Error>(kind) + }) + .await; + + this.update(cx, |this, cx| { + this.loading_files.remove(&path); + match content { + Ok(kind) => { + if let FileContent::Text(text) = &kind + && 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); + } + } + this.files.insert(path, kind); + } + Err(error) => { + this.files + .insert(path, FileContent::Failed(error.to_string())); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Load `text` into the persistent markdown TextView state. + /// + /// The state is created empty and fed via `push_str`, which parses on a + /// background task: switching files never blocks the main thread, and + /// the state lives as long as this view, so re-viewing the same document + /// does not re-parse it. + fn set_markdown(&mut self, path: Option, text: &str, cx: &mut Context) { + let state = cx.new(|cx| TextViewState::markdown("", cx)); + state.update(cx, |state, cx| state.push_str(text, cx)); + self.md = Some(MarkdownView { path, state }); + } + + /// The persistent markdown TextView for `path` (`None` = README), or a + /// spinner while the document is being loaded/parsed. + fn markdown_element(&mut self, path: Option<&str>, _cx: &mut Context) -> AnyElement { + let spinner = || { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() + }; + + let Some(md) = &self.md else { + return spinner(); + }; + let ready = match path { + Some(path) => md.path.as_deref() == Some(path), + None => md.path.is_none(), + }; + if !ready { + return spinner(); + } + + TextView::new(&md.state).selectable(true).into_any_element() + } + + fn meta_row(label: SharedString, value: SharedString, cx: &App) -> impl IntoElement { + h_flex() + .gap_2() + .items_start() + .child( + div() + .w_24() + .flex_none() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + .child( + div() + .flex_1() + .text_sm() + .text_color(cx.theme().foreground) + .child(value), + ) + } + + fn stat(label: SharedString, count: usize, cx: &App) -> impl IntoElement { + v_flex() + .gap_1() + .child(div().text_lg().font_semibold().child(count.to_string())) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + } + + /// One row of the file tree: icon + name, indented by depth. + fn render_tree_item( + ix: usize, + entry: &TreeEntry, + selected: bool, + view: &WeakEntity, + ) -> ListItem { + let item = entry.item(); + let id = item.id.clone(); + let is_folder = entry.is_folder(); + + let icon = if is_folder { + if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::FolderClosed + } + } else { + IconName::File + }; + + let view = view.clone(); + + ListItem::new(ix) + .pl(px(8.) + px(14.) * entry.depth() as f32) + .selected(selected) + .child( + h_flex() + .gap_2() + .overflow_hidden() + .child(Icon::new(icon).small()) + .child(div().text_sm().text_ellipsis().child(item.label.clone())), + ) + .on_click(move |_event, window, cx| { + // Folders expand/collapse via the tree itself. + if is_folder { + return; + } + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.open_file(&id, window, cx)); + } + }) + } +} + +impl Panel for RepoDetailView { + fn panel_name(&self) -> &'static str { + "repo_detail" + } + + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let announcement = self + .store + .read(cx) + .announcement + .clone() + .unwrap_or_else(|| self.initial.clone()); + + announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + } +} + +impl EventEmitter for RepoDetailView {} + +impl Focusable for RepoDetailView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RepoDetailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let announcement = self + .store + .read(cx) + .announcement + .clone() + .unwrap_or_else(|| self.initial.clone()); + + let store = self.store.read(cx); + let owner = ProfileStore::global(cx).read(cx).get(&announcement.owner); + + let name = announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())); + + let description = announcement + .description + .clone() + .unwrap_or(SharedString::from("No description")); + + let web = join_urls(&announcement.web); + let clone = join_urls(&announcement.clone); + let relays = announcement + .relays + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + let hashtags = announcement.hashtags.join(", "); + + let pane_title = self + .selected_file + .clone() + .or_else(|| self.readme_name.clone()) + .unwrap_or_else(|| "Overview".into()); + + let tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + + v_flex() + .id("repo-detail") + .size_full() + .overflow_y_scroll() + .p_6() + .gap_4() + // Header + .child( + v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .items_center() + .child( + Avatar::new() + .name(owner.name()) + .when_some(owner.picture(), |this, url| this.src(url)) + .small(), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(owner.name()), + ), + ) + .child(div().text_2xl().font_semibold().child(name)) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(description), + ), + ) + // Activity summary + .child( + h_flex() + .gap_6() + .child(Self::stat("Issues".into(), store.issues.len(), cx)) + .child(Self::stat("Patches".into(), store.patches.len(), cx)) + .child(Self::stat( + "Pull requests".into(), + store.pull_requests.len(), + cx, + )), + ) + // File explorer + README / file preview + .child( + h_flex() + .h(px(380.)) + .flex_none() + .w_full() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .overflow_hidden() + .child(self.render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)), + ) + // Details + .child( + v_flex() + .gap_2() + .child(Self::meta_row( + "Created".into(), + relative_time(announcement.created_at).into(), + cx, + )) + .when_some(announcement.euc.clone(), |this, euc| { + this.child(Self::meta_row("EUC".into(), euc.into(), cx)) + }) + .when(!web.is_empty(), |this| { + this.child(Self::meta_row("Web".into(), web.into(), cx)) + }) + .when(!clone.is_empty(), |this| { + this.child(Self::meta_row("Clone".into(), clone.into(), cx)) + }) + .when(!relays.is_empty(), |this| { + this.child(Self::meta_row("Relays".into(), relays.into(), cx)) + }) + .when(!hashtags.is_empty(), |this| { + this.child(Self::meta_row("Tags".into(), hashtags.into(), cx)) + }), + ) + } +} + +impl RepoDetailView { + /// Left column: the file tree. + fn render_tree_column( + &mut self, + tree_state: Entity, + view: WeakEntity, + cx: &mut Context, + ) -> impl IntoElement { + v_flex() + .w(px(TREE_WIDTH)) + .flex_none() + .h_full() + .border_r(px(1.)) + .border_color(cx.theme().border) + .child( + h_flex() + .h_9() + .px_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child(div().text_xs().font_semibold().child("Files")), + ) + .child(div().flex_1().min_h_0().child(tree( + &tree_state, + move |ix, entry, selected, _window, _cx| { + Self::render_tree_item(ix, entry, selected, &view) + }, + ))) + } + + /// Right column: README, selected file preview, or status text. + fn render_content_column( + &mut self, + pane_title: SharedString, + cx: &mut Context, + ) -> impl IntoElement { + let loading = self.loading; + let error = self.error.clone(); + let selected_file = self.selected_file.clone(); + + let body: AnyElement = if loading { + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child(Spinner::new().small()) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Cloning repository…"), + ) + .into_any_element() + } else if let Some(error) = error { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(error), + ) + .into_any_element() + } else if let Some(path) = selected_file { + match self.files.get(path.as_ref()) { + Some(FileContent::Text(text)) => { + if is_markdown_path(path.as_ref()) { + self.markdown_element(Some(path.as_ref()), cx) + } else { + v_flex() + .size_full() + .children(plain_lines(text, cx)) + .into_any_element() + } + } + Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx), + Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx), + Some(FileContent::Failed(message)) => placeholder(message, cx), + None => v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element(), + } + } else if self.readme_name.is_some() { + self.markdown_element(None, cx) + } else { + placeholder("No README found", cx) + }; + + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child( + h_flex() + .h_9() + .px_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child( + div() + .text_xs() + .font_semibold() + .text_ellipsis() + .whitespace_nowrap() + .child(pane_title), + ), + ) + .child( + div() + .id("repo-content-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .p_4() + .child(body), + ) + } +} + +/// Build nested tree items from a flat, sorted (dirs-first) entry list. +fn build_tree_items(entries: &[PathBuf]) -> Vec { + let mut roots: Vec = Vec::new(); + + for entry in entries { + let parts: Vec = entry + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + insert_path(&mut roots, &parts, ""); + } + + roots +} + +/// Insert `parts` (path components) into the tree rooted at `items`. +/// `prefix` is the path of `items`' parent, used to build item ids. +fn insert_path(items: &mut Vec, parts: &[String], prefix: &str) { + let Some((head, rest)) = parts.split_first() else { + return; + }; + + let id = if prefix.is_empty() { + head.clone() + } else { + format!("{prefix}/{head}") + }; + + if let Some(existing) = items.iter_mut().find(|item| &*item.label == head.as_str()) { + insert_path(&mut existing.children, rest, &id); + } else { + let mut item = TreeItem::new(id.clone(), head.clone()); + insert_path(&mut item.children, rest, &id); + items.push(item); + } +} + +/// Render text with one element per line, preserving blank lines. +fn plain_lines(text: &str, cx: &App) -> Vec { + text.lines() + .map(|line| { + // A space keeps empty lines from collapsing to zero height. + let text = if line.is_empty() { " " } else { line }; + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().foreground) + .child(text.to_string()) + .into_any_element() + }) + .collect() +} + +/// 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" + ) + }) +} + +fn placeholder(message: &str, cx: &App) -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(message.to_string()), + ) + .into_any_element() +} + +fn join_urls(urls: &[T]) -> String { + urls.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_nested_tree_from_flat_entries() { + let entries = vec![ + PathBuf::from("src"), + PathBuf::from("src/lib.rs"), + PathBuf::from("README.md"), + PathBuf::from("docs/guide.md"), + ]; + + let items = build_tree_items(&entries); + + // Input order is preserved (dirs-first, as produced by worktree_entries). + assert_eq!(items.len(), 3); + assert_eq!(items[0].label, "src"); + assert_eq!(items[0].id, "src"); + assert_eq!(items[0].children.len(), 1); + assert_eq!(items[0].children[0].label, "lib.rs"); + assert_eq!(items[0].children[0].id, "src/lib.rs"); + + assert_eq!(items[1].label, "README.md"); + assert_eq!(items[1].id, "README.md"); + + assert_eq!(items[2].label, "docs"); + assert_eq!(items[2].children[0].label, "guide.md"); + assert_eq!(items[2].children[0].id, "docs/guide.md"); + } + + #[test] + fn tree_builder_handles_deep_nesting() { + let entries = vec![ + PathBuf::from("a"), + PathBuf::from("a/b"), + PathBuf::from("a/b/c.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 1); + assert_eq!(items[0].children[0].id, "a/b"); + assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); + } +} diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index f6432fd..32acd2d 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -1,12 +1,13 @@ use std::rc::Rc; +use std::sync::Arc; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, Subscription, Window, div, px, size, + AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Pixels, + Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; use gpui_component::avatar::Avatar; -use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; use gpui_component::scroll::Scrollbar; use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, @@ -15,11 +16,14 @@ use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore, Timestamp}; use utils::relative_time; +use super::RepoDetailView; + const CARD_HEIGHT: f32 = 160.; /// Browse all announced repositories (works anonymously). pub struct RepoListView { store: Entity, + dock_area: WeakEntity, focus_handle: FocusHandle, scroll_handle: VirtualListScrollHandle, item_sizes: Rc>>, @@ -27,7 +31,11 @@ pub struct RepoListView { } impl RepoListView { - pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + dock_area: WeakEntity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { let store = cx.new(|cx| RepoListStore::new(None, cx)); let subscription = cx.observe(&store, |this, store, cx| { @@ -40,6 +48,7 @@ impl RepoListView { Self { store, + dock_area, focus_handle: cx.focus_handle(), scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(vec![]), @@ -70,11 +79,34 @@ impl RepoListView { .map(|label| SharedString::from(format!("Updated {label}"))) .unwrap_or_default(); + // Open the repository in a new center tab when the card is clicked. + let dock_area = self.dock_area.clone(); + let announcement = announcement.clone(); + v_flex() + .id(ElementId::from(format!( + "repo-card-{}", + announcement.addr() + ))) .px_4() .w_full() .border_b(px(1.)) .border_color(cx.theme().border) + .cursor_pointer() + .on_click(move |_event, window, cx| { + let detail = cx.new(|cx| RepoDetailView::new(announcement.clone(), window, cx)); + if let Some(dock_area) = dock_area.upgrade() { + dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel( + Arc::new(detail), + DockPlacement::Center, + None, + window, + cx, + ); + }); + } + }) .child( h_flex() .h_12() diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index f104ba5..06488f2 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -70,7 +70,7 @@ impl SidebarPanel { return; } - let panel = cx.new(|cx| RepoListView::new(window, cx)); + let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx)); self.explore = Some(panel.downgrade()); let _ = self.dock_area.update(cx, |dock_area, cx| { diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 52e6a01..e52c9df 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -26,6 +26,10 @@ fn main() { std::fs::create_dir_all(paths::nostr_dir()).ok(); signed_state::init(paths::nostr_dir(), cx); + // Local git clone cache for browsing repository contents. + std::fs::create_dir_all(paths::repos_dir()).ok(); + signed_state::GitStore::set_global(paths::repos_dir().clone(), cx); + // Set up the window bounds let bounds = Bounds::centered(None, size(px(980.0), px(740.0)), cx); -- 2.54.0 From 1daa10e57cc22da960fa2cd4e10cd3d392be6d06 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 10 Aug 2026 13:51:46 +0700 Subject: [PATCH 24/64] update --- crates/assets/assets/icons/global-off.svg | 1 - crates/assets/assets/icons/unlock.svg | 1 + crates/assets/src/lib.rs | 2 + crates/workspace/src/views/repo_detail.rs | 776 ------------------ .../src/views/repo_detail/browser.rs | 253 ++++++ .../src/views/repo_detail/helpers.rs | 137 ++++ crates/workspace/src/views/repo_detail/mod.rs | 355 ++++++++ crates/workspace/src/views/repo_list.rs | 83 +- .../src/views/sidebar/passphrase_dialog.rs | 2 + crates/workspace/src/workspace.rs | 8 +- desktop/src/main.rs | 3 +- 11 files changed, 798 insertions(+), 823 deletions(-) delete mode 100644 crates/assets/assets/icons/global-off.svg create mode 100644 crates/assets/assets/icons/unlock.svg delete mode 100644 crates/workspace/src/views/repo_detail.rs create mode 100644 crates/workspace/src/views/repo_detail/browser.rs create mode 100644 crates/workspace/src/views/repo_detail/helpers.rs create mode 100644 crates/workspace/src/views/repo_detail/mod.rs diff --git a/crates/assets/assets/icons/global-off.svg b/crates/assets/assets/icons/global-off.svg deleted file mode 100644 index bd90538..0000000 --- a/crates/assets/assets/icons/global-off.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/assets/assets/icons/unlock.svg b/crates/assets/assets/icons/unlock.svg new file mode 100644 index 0000000..f5434e9 --- /dev/null +++ b/crates/assets/assets/icons/unlock.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 66ea625..c4ad70c 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -48,6 +48,7 @@ impl Assets { } pub enum CustomIconName { + Unlock, Filter, GlobalOn, GlobalOff, @@ -56,6 +57,7 @@ pub enum CustomIconName { impl IconNamed for CustomIconName { fn path(self) -> gpui::SharedString { match self { + CustomIconName::Unlock => "icons/unlock.svg", CustomIconName::Filter => "icons/filter.svg", CustomIconName::GlobalOn => "icons/global-on.svg", CustomIconName::GlobalOff => "icons/global-off.svg", diff --git a/crates/workspace/src/views/repo_detail.rs b/crates/workspace/src/views/repo_detail.rs deleted file mode 100644 index e2e3217..0000000 --- a/crates/workspace/src/views/repo_detail.rs +++ /dev/null @@ -1,776 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::path::{Component, Path, PathBuf}; - -use anyhow::Error; -use gpui::prelude::*; -use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - Task, WeakEntity, Window, div, px, -}; -use gpui_component::avatar::Avatar; -use gpui_component::dock::{Panel, PanelEvent}; -use gpui_component::list::ListItem; -use gpui_component::spinner::Spinner; -use gpui_component::text::{TextView, TextViewState}; -use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; -use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; -use signed_core::Announcement; -use signed_state::{GitStore, ProfileStore, RepoStore}; -use utils::relative_time; - -/// Width of the file explorer column. -const TREE_WIDTH: f32 = 280.; -/// Files larger than this are not previewed. -const MAX_PREVIEW_BYTES: usize = 1024 * 1024; - -/// Preview state of a browsed file. -enum FileContent { - /// Decodable text content. - Text(String), - /// Not valid UTF-8. - Binary, - /// Bigger than [`MAX_PREVIEW_BYTES`]. - TooLarge, - /// Reading failed. - Failed(String), -} - -/// A markdown document loaded into a persistent [`TextViewState`]. -/// -/// The state is owned here rather than created per render (as the stateless -/// `text::markdown` helper does), so it survives branch switches in the -/// content pane. GPUI's keyed element state is dropped as soon as the -/// element is absent for a single frame, which would otherwise re-parse the -/// whole document on the main thread every time the pane switches between -/// the README, a file preview, and the loading spinner. -struct MarkdownView { - /// Source path; `None` means the repository README. - path: Option, - state: Entity, -} - -/// Detail view of a repository: header, stats, a file explorer with README -/// preview (cloned from the announcement's `clone` URLs), and metadata. -pub struct RepoDetailView { - /// Live per-repository store, refreshed from the local database. - store: Entity, - /// Snapshot taken at open time, shown until the store's first refresh completes. - initial: Announcement, - /// File explorer state (worktree of the local clone). - tree_state: Entity, - focus_handle: FocusHandle, - tasks: Vec>>, - /// A clone/fetch is in flight. - loading: bool, - error: Option, - /// Root of the local clone, for reading files on demand. - worktree: Option, - /// Markdown document currently in the preview pane (README or a file). - md: Option, - readme_name: Option, - /// Currently previewed file (relative path) and its contents. - selected_file: Option, - files: HashMap, - /// Reads in flight, to avoid duplicate loads. - loading_files: HashSet, -} - -impl RepoDetailView { - pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context) -> Self { - let store = cx.new(|cx| RepoStore::new(initial.addr(), cx)); - let tree_state = cx.new(|cx| TreeState::new(cx)); - - // Defer loading the repository until the window is ready. - cx.defer_in(window, |this, _window, cx| { - this.load_repo(cx); - }); - - Self { - store, - initial, - tree_state, - focus_handle: cx.focus_handle(), - tasks: Vec::new(), - loading: true, - error: None, - worktree: None, - md: None, - readme_name: None, - selected_file: None, - files: HashMap::new(), - loading_files: HashSet::new(), - } - } - - /// Clone (or fetch) the repository and populate the file explorer. - fn load_repo(&mut self, cx: &mut Context) { - self.loading = true; - self.error = None; - cx.notify(); - - let cache = GitStore::global(cx).cache().clone(); - let addr = self.initial.addr(); - let clone_urls: Vec = self.initial.clone.iter().map(ToString::to_string).collect(); - - let load = cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - let entries = signed_git::worktree_entries(&repo)?; - let readme_path = signed_git::find_readme(&repo)?; - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(&repo, path)?, - None => None, - }; - let worktree = repo.workdir().map(Path::to_path_buf); - - Ok::<_, Error>((entries, readme_path, readme, worktree)) - }); - - let task = cx.spawn(async move |this, cx| { - let result = load.await; - - this.update(cx, |this, cx| { - match result { - Ok((entries, readme_path, readme, Some(worktree))) => { - this.worktree = Some(worktree); - this.tree_state.update(cx, |state, cx| { - state.set_items(build_tree_items(&entries), cx); - }); - if let Some((path, bytes)) = readme_path.zip(readme) { - this.readme_name = Some(path.to_string_lossy().into()); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - } - Ok((_, _, _, None)) => { - this.error = Some("Repository has no worktree".into()); - } - Err(error) => { - this.error = Some(error.to_string().into()); - } - } - this.loading = false; - cx.notify(); - })?; - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Preview the file at `path` (relative to the worktree root). - 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) || self.loading_files.contains(path) { - cx.notify(); - return; - } - - // Paths come from our own tree walk, but never trust them: refuse - // anything that could escape the worktree. - 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(); - - let task = cx.spawn(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 bytes = match std::fs::read(&full) { - Ok(bytes) => bytes, - Err(error) => return Err(anyhow::anyhow!("{}", error)), - }; - - let kind = if bytes.len() > MAX_PREVIEW_BYTES { - FileContent::TooLarge - } else { - match String::from_utf8(bytes) { - Ok(text) => FileContent::Text(text), - Err(_) => FileContent::Binary, - } - }; - Ok::<_, Error>(kind) - }) - .await; - - this.update(cx, |this, cx| { - this.loading_files.remove(&path); - match content { - Ok(kind) => { - if let FileContent::Text(text) = &kind - && 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); - } - } - this.files.insert(path, kind); - } - Err(error) => { - this.files - .insert(path, FileContent::Failed(error.to_string())); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Load `text` into the persistent markdown TextView state. - /// - /// The state is created empty and fed via `push_str`, which parses on a - /// background task: switching files never blocks the main thread, and - /// the state lives as long as this view, so re-viewing the same document - /// does not re-parse it. - fn set_markdown(&mut self, path: Option, text: &str, cx: &mut Context) { - let state = cx.new(|cx| TextViewState::markdown("", cx)); - state.update(cx, |state, cx| state.push_str(text, cx)); - self.md = Some(MarkdownView { path, state }); - } - - /// The persistent markdown TextView for `path` (`None` = README), or a - /// spinner while the document is being loaded/parsed. - fn markdown_element(&mut self, path: Option<&str>, _cx: &mut Context) -> AnyElement { - let spinner = || { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element() - }; - - let Some(md) = &self.md else { - return spinner(); - }; - let ready = match path { - Some(path) => md.path.as_deref() == Some(path), - None => md.path.is_none(), - }; - if !ready { - return spinner(); - } - - TextView::new(&md.state).selectable(true).into_any_element() - } - - fn meta_row(label: SharedString, value: SharedString, cx: &App) -> impl IntoElement { - h_flex() - .gap_2() - .items_start() - .child( - div() - .w_24() - .flex_none() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child(label), - ) - .child( - div() - .flex_1() - .text_sm() - .text_color(cx.theme().foreground) - .child(value), - ) - } - - fn stat(label: SharedString, count: usize, cx: &App) -> impl IntoElement { - v_flex() - .gap_1() - .child(div().text_lg().font_semibold().child(count.to_string())) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(label), - ) - } - - /// One row of the file tree: icon + name, indented by depth. - fn render_tree_item( - ix: usize, - entry: &TreeEntry, - selected: bool, - view: &WeakEntity, - ) -> ListItem { - let item = entry.item(); - let id = item.id.clone(); - let is_folder = entry.is_folder(); - - let icon = if is_folder { - if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::FolderClosed - } - } else { - IconName::File - }; - - let view = view.clone(); - - ListItem::new(ix) - .pl(px(8.) + px(14.) * entry.depth() as f32) - .selected(selected) - .child( - h_flex() - .gap_2() - .overflow_hidden() - .child(Icon::new(icon).small()) - .child(div().text_sm().text_ellipsis().child(item.label.clone())), - ) - .on_click(move |_event, window, cx| { - // Folders expand/collapse via the tree itself. - if is_folder { - return; - } - if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| this.open_file(&id, window, cx)); - } - }) - } -} - -impl Panel for RepoDetailView { - fn panel_name(&self) -> &'static str { - "repo_detail" - } - - fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let announcement = self - .store - .read(cx) - .announcement - .clone() - .unwrap_or_else(|| self.initial.clone()); - - announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) - } -} - -impl EventEmitter for RepoDetailView {} - -impl Focusable for RepoDetailView { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for RepoDetailView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let announcement = self - .store - .read(cx) - .announcement - .clone() - .unwrap_or_else(|| self.initial.clone()); - - let store = self.store.read(cx); - let owner = ProfileStore::global(cx).read(cx).get(&announcement.owner); - - let name = announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())); - - let description = announcement - .description - .clone() - .unwrap_or(SharedString::from("No description")); - - let web = join_urls(&announcement.web); - let clone = join_urls(&announcement.clone); - let relays = announcement - .relays - .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - let hashtags = announcement.hashtags.join(", "); - - let pane_title = self - .selected_file - .clone() - .or_else(|| self.readme_name.clone()) - .unwrap_or_else(|| "Overview".into()); - - let tree_state = self.tree_state.clone(); - let view = cx.entity().downgrade(); - - v_flex() - .id("repo-detail") - .size_full() - .overflow_y_scroll() - .p_6() - .gap_4() - // Header - .child( - v_flex() - .gap_2() - .child( - h_flex() - .gap_2() - .items_center() - .child( - Avatar::new() - .name(owner.name()) - .when_some(owner.picture(), |this, url| this.src(url)) - .small(), - ) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(owner.name()), - ), - ) - .child(div().text_2xl().font_semibold().child(name)) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(description), - ), - ) - // Activity summary - .child( - h_flex() - .gap_6() - .child(Self::stat("Issues".into(), store.issues.len(), cx)) - .child(Self::stat("Patches".into(), store.patches.len(), cx)) - .child(Self::stat( - "Pull requests".into(), - store.pull_requests.len(), - cx, - )), - ) - // File explorer + README / file preview - .child( - h_flex() - .h(px(380.)) - .flex_none() - .w_full() - .rounded_md() - .border_1() - .border_color(cx.theme().border) - .overflow_hidden() - .child(self.render_tree_column(tree_state, view, cx)) - .child(self.render_content_column(pane_title, cx)), - ) - // Details - .child( - v_flex() - .gap_2() - .child(Self::meta_row( - "Created".into(), - relative_time(announcement.created_at).into(), - cx, - )) - .when_some(announcement.euc.clone(), |this, euc| { - this.child(Self::meta_row("EUC".into(), euc.into(), cx)) - }) - .when(!web.is_empty(), |this| { - this.child(Self::meta_row("Web".into(), web.into(), cx)) - }) - .when(!clone.is_empty(), |this| { - this.child(Self::meta_row("Clone".into(), clone.into(), cx)) - }) - .when(!relays.is_empty(), |this| { - this.child(Self::meta_row("Relays".into(), relays.into(), cx)) - }) - .when(!hashtags.is_empty(), |this| { - this.child(Self::meta_row("Tags".into(), hashtags.into(), cx)) - }), - ) - } -} - -impl RepoDetailView { - /// Left column: the file tree. - fn render_tree_column( - &mut self, - tree_state: Entity, - view: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - v_flex() - .w(px(TREE_WIDTH)) - .flex_none() - .h_full() - .border_r(px(1.)) - .border_color(cx.theme().border) - .child( - h_flex() - .h_9() - .px_3() - .items_center() - .border_b(px(1.)) - .border_color(cx.theme().border) - .child(div().text_xs().font_semibold().child("Files")), - ) - .child(div().flex_1().min_h_0().child(tree( - &tree_state, - move |ix, entry, selected, _window, _cx| { - Self::render_tree_item(ix, entry, selected, &view) - }, - ))) - } - - /// Right column: README, selected file preview, or status text. - fn render_content_column( - &mut self, - pane_title: SharedString, - cx: &mut Context, - ) -> impl IntoElement { - let loading = self.loading; - let error = self.error.clone(); - let selected_file = self.selected_file.clone(); - - let body: AnyElement = if loading { - v_flex() - .size_full() - .items_center() - .justify_center() - .gap_2() - .child(Spinner::new().small()) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Cloning repository…"), - ) - .into_any_element() - } else if let Some(error) = error { - v_flex() - .size_full() - .items_center() - .justify_center() - .p_4() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(error), - ) - .into_any_element() - } else if let Some(path) = selected_file { - match self.files.get(path.as_ref()) { - Some(FileContent::Text(text)) => { - if is_markdown_path(path.as_ref()) { - self.markdown_element(Some(path.as_ref()), cx) - } else { - v_flex() - .size_full() - .children(plain_lines(text, cx)) - .into_any_element() - } - } - Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx), - Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx), - Some(FileContent::Failed(message)) => placeholder(message, cx), - None => v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element(), - } - } else if self.readme_name.is_some() { - self.markdown_element(None, cx) - } else { - placeholder("No README found", cx) - }; - - v_flex() - .flex_1() - .min_w_0() - .h_full() - .child( - h_flex() - .h_9() - .px_3() - .items_center() - .border_b(px(1.)) - .border_color(cx.theme().border) - .child( - div() - .text_xs() - .font_semibold() - .text_ellipsis() - .whitespace_nowrap() - .child(pane_title), - ), - ) - .child( - div() - .id("repo-content-scroll") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .p_4() - .child(body), - ) - } -} - -/// Build nested tree items from a flat, sorted (dirs-first) entry list. -fn build_tree_items(entries: &[PathBuf]) -> Vec { - let mut roots: Vec = Vec::new(); - - for entry in entries { - let parts: Vec = entry - .components() - .map(|c| c.as_os_str().to_string_lossy().into_owned()) - .collect(); - insert_path(&mut roots, &parts, ""); - } - - roots -} - -/// Insert `parts` (path components) into the tree rooted at `items`. -/// `prefix` is the path of `items`' parent, used to build item ids. -fn insert_path(items: &mut Vec, parts: &[String], prefix: &str) { - let Some((head, rest)) = parts.split_first() else { - return; - }; - - let id = if prefix.is_empty() { - head.clone() - } else { - format!("{prefix}/{head}") - }; - - if let Some(existing) = items.iter_mut().find(|item| &*item.label == head.as_str()) { - insert_path(&mut existing.children, rest, &id); - } else { - let mut item = TreeItem::new(id.clone(), head.clone()); - insert_path(&mut item.children, rest, &id); - items.push(item); - } -} - -/// Render text with one element per line, preserving blank lines. -fn plain_lines(text: &str, cx: &App) -> Vec { - text.lines() - .map(|line| { - // A space keeps empty lines from collapsing to zero height. - let text = if line.is_empty() { " " } else { line }; - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().foreground) - .child(text.to_string()) - .into_any_element() - }) - .collect() -} - -/// 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" - ) - }) -} - -fn placeholder(message: &str, cx: &App) -> AnyElement { - v_flex() - .size_full() - .items_center() - .justify_center() - .p_4() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(message.to_string()), - ) - .into_any_element() -} - -fn join_urls(urls: &[T]) -> String { - urls.iter() - .map(ToString::to_string) - .collect::>() - .join(", ") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builds_nested_tree_from_flat_entries() { - let entries = vec![ - PathBuf::from("src"), - PathBuf::from("src/lib.rs"), - PathBuf::from("README.md"), - PathBuf::from("docs/guide.md"), - ]; - - let items = build_tree_items(&entries); - - // Input order is preserved (dirs-first, as produced by worktree_entries). - assert_eq!(items.len(), 3); - assert_eq!(items[0].label, "src"); - assert_eq!(items[0].id, "src"); - assert_eq!(items[0].children.len(), 1); - assert_eq!(items[0].children[0].label, "lib.rs"); - assert_eq!(items[0].children[0].id, "src/lib.rs"); - - assert_eq!(items[1].label, "README.md"); - assert_eq!(items[1].id, "README.md"); - - assert_eq!(items[2].label, "docs"); - assert_eq!(items[2].children[0].label, "guide.md"); - assert_eq!(items[2].children[0].id, "docs/guide.md"); - } - - #[test] - fn tree_builder_handles_deep_nesting() { - let entries = vec![ - PathBuf::from("a"), - PathBuf::from("a/b"), - PathBuf::from("a/b/c.txt"), - ]; - - let items = build_tree_items(&entries); - assert_eq!(items.len(), 1); - assert_eq!(items[0].children[0].id, "a/b"); - assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); - } -} diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs new file mode 100644 index 0000000..3841e8a --- /dev/null +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -0,0 +1,253 @@ +//! File explorer of the repository detail view: the file tree column and the +//! content column (README / file preview), backed by a persistent +//! [`TextViewState`] for markdown documents. + +use gpui::prelude::*; +use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, div, px}; +use gpui_component::list::ListItem; +use gpui_component::spinner::Spinner; +use gpui_component::text::{TextView, TextViewState}; +use gpui_component::tree::{TreeEntry, TreeState, tree}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; + +use super::RepoDetailView; +use super::helpers::{is_markdown_path, placeholder, plain_lines}; + +/// Width of the file explorer column. +const TREE_WIDTH: f32 = 240.; +/// Files larger than this are not previewed. +pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; + +/// Preview state of a browsed file. +pub(super) enum FileContent { + /// Decodable text content. + Text(String), + /// Not valid UTF-8. + Binary, + /// Bigger than [`MAX_PREVIEW_BYTES`]. + TooLarge, + /// Reading failed. + Failed(String), +} + +/// A markdown document loaded into a persistent [`TextViewState`]. +/// +/// The state is owned by the view rather than created per render (as the +/// stateless `text::markdown` helper does), so it survives branch switches +/// in the content pane. GPUI's keyed element state is dropped as soon as the +/// element is absent for a single frame, which would otherwise re-parse the +/// whole document on the main thread every time the pane switches between +/// the README, a file preview, and the loading spinner. +pub(super) struct MarkdownView { + /// Source path; `None` means the repository README. + pub(super) path: Option, + pub(super) state: Entity, +} + +impl RepoDetailView { + /// One row of the file tree: icon + name, indented by depth. + fn render_tree_item( + ix: usize, + entry: &TreeEntry, + selected: bool, + view: &WeakEntity, + ) -> ListItem { + let item = entry.item(); + let id = item.id.clone(); + let is_folder = entry.is_folder(); + + let icon = if is_folder { + if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::FolderClosed + } + } else { + IconName::File + }; + + let view = view.clone(); + + ListItem::new(ix) + .pl(px(8.) + px(14.) * entry.depth() as f32) + .selected(selected) + .child( + h_flex() + .gap_2() + .overflow_hidden() + .child(Icon::new(icon).small()) + .child(div().text_sm().text_ellipsis().child(item.label.clone())), + ) + .on_click(move |_event, window, cx| { + // Folders expand/collapse via the tree itself. + if is_folder { + return; + } + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.open_file(&id, window, cx)); + } + }) + } + + /// Left column: the file tree. + pub(super) fn render_tree_column( + &mut self, + tree_state: Entity, + view: WeakEntity, + cx: &mut Context, + ) -> impl IntoElement { + v_flex() + .h_full() + .w(px(TREE_WIDTH)) + .p_2() + .flex_none() + .border_r_1() + .border_color(cx.theme().border) + .child(div().flex_1().min_h_0().child(tree( + &tree_state, + move |ix, entry, selected, _window, _cx| { + Self::render_tree_item(ix, entry, selected, &view) + }, + ))) + } + + /// Right column: README, selected file preview, or status text. + pub(super) fn render_content_column( + &mut self, + pane_title: SharedString, + cx: &mut Context, + ) -> impl IntoElement { + let loading = self.loading; + let error = self.error.clone(); + let selected_file = self.selected_file.clone(); + + let body: AnyElement = if loading { + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child(Spinner::new().small()) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Cloning repository…"), + ) + .into_any_element() + } else if let Some(error) = error { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(error), + ) + .into_any_element() + } else if let Some(path) = selected_file { + match self.files.get(path.as_ref()) { + Some(FileContent::Text(text)) => { + if is_markdown_path(path.as_ref()) { + self.markdown_element(Some(path.as_ref()), cx) + } else { + v_flex() + .size_full() + .children(plain_lines(text, cx)) + .into_any_element() + } + } + Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx), + Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx), + Some(FileContent::Failed(message)) => placeholder(message, cx), + None => v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element(), + } + } else if self.readme_name.is_some() { + self.markdown_element(None, cx) + } else { + placeholder("No README found", cx) + }; + + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child( + h_flex() + .px_3() + .h_9() + .bg(cx.theme().muted) + .border_b(px(1.)) + .border_color(cx.theme().border) + .text_color(cx.theme().muted_foreground) + .child( + div() + .text_xs() + .font_semibold() + .text_ellipsis() + .whitespace_nowrap() + .child(pane_title), + ), + ) + .child( + div() + .id("repo-content-scroll") + .flex_1() + .min_h_0() + .p_4() + .overflow_y_scroll() + .child(body), + ) + } + + /// Load `text` into the persistent markdown TextView state. + /// + /// The state is created empty and fed via `push_str`, which parses on a + /// background task: switching files never blocks the main thread, and + /// the state lives as long as this view, so re-viewing the same document + /// does not re-parse it. + pub(super) fn set_markdown( + &mut self, + path: Option, + text: &str, + cx: &mut Context, + ) { + let state = cx.new(|cx| TextViewState::markdown("", cx)); + state.update(cx, |state, cx| state.push_str(text, cx)); + self.md = Some(MarkdownView { path, state }); + } + + /// The persistent markdown TextView for `path` (`None` = README), or a + /// spinner while the document is being loaded/parsed. + fn markdown_element(&mut self, path: Option<&str>, _cx: &mut Context) -> AnyElement { + let spinner = || { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() + }; + + let Some(md) = &self.md else { + return spinner(); + }; + let ready = match path { + Some(path) => md.path.as_deref() == Some(path), + None => md.path.is_none(), + }; + if !ready { + return spinner(); + } + + TextView::new(&md.state).selectable(true).into_any_element() + } +} diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs new file mode 100644 index 0000000..0972756 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -0,0 +1,137 @@ +//! Pure helpers for the repository detail view: file-tree building, plain +//! text rendering and small element builders. + +use std::path::{Path, PathBuf}; + +use gpui::prelude::*; +use gpui::{AnyElement, App, div}; +use gpui_component::tree::TreeItem; +use gpui_component::{ActiveTheme, v_flex}; + +/// Build nested tree items from a flat, sorted (dirs-first) entry list. +pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { + let mut roots: Vec = Vec::new(); + + for entry in entries { + let parts: Vec = entry + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + insert_path(&mut roots, &parts, ""); + } + + roots +} + +/// Insert `parts` (path components) into the tree rooted at `items`. +/// `prefix` is the path of `items`' parent, used to build item ids. +fn insert_path(items: &mut Vec, parts: &[String], prefix: &str) { + let Some((head, rest)) = parts.split_first() else { + return; + }; + + let id = if prefix.is_empty() { + head.clone() + } else { + format!("{prefix}/{head}") + }; + + if let Some(existing) = items.iter_mut().find(|item| &*item.label == head.as_str()) { + insert_path(&mut existing.children, rest, &id); + } else { + let mut item = TreeItem::new(id.clone(), head.clone()); + insert_path(&mut item.children, rest, &id); + items.push(item); + } +} + +/// Render text with one element per line, preserving blank lines. +pub(super) fn plain_lines(text: &str, cx: &App) -> Vec { + text.lines() + .map(|line| { + // A space keeps empty lines from collapsing to zero height. + let text = if line.is_empty() { " " } else { line }; + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().foreground) + .child(text.to_string()) + .into_any_element() + }) + .collect() +} + +/// Whether a file path has a markdown extension. +pub(super) 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" + ) + }) +} + +/// A centered muted placeholder message. +pub(super) fn placeholder(message: &str, cx: &App) -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(message.to_string()), + ) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_nested_tree_from_flat_entries() { + let entries = vec![ + PathBuf::from("src"), + PathBuf::from("src/lib.rs"), + PathBuf::from("README.md"), + PathBuf::from("docs/guide.md"), + ]; + + let items = build_tree_items(&entries); + + // Input order is preserved (dirs-first, as produced by worktree_entries). + assert_eq!(items.len(), 3); + assert_eq!(items[0].label, "src"); + assert_eq!(items[0].id, "src"); + assert_eq!(items[0].children.len(), 1); + assert_eq!(items[0].children[0].label, "lib.rs"); + assert_eq!(items[0].children[0].id, "src/lib.rs"); + + assert_eq!(items[1].label, "README.md"); + assert_eq!(items[1].id, "README.md"); + + assert_eq!(items[2].label, "docs"); + assert_eq!(items[2].children[0].label, "guide.md"); + assert_eq!(items[2].children[0].id, "docs/guide.md"); + } + + #[test] + fn tree_builder_handles_deep_nesting() { + let entries = vec![ + PathBuf::from("a"), + PathBuf::from("a/b"), + PathBuf::from("a/b/c.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 1); + assert_eq!(items[0].children[0].id, "a/b"); + assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); + } +} diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs new file mode 100644 index 0000000..da88e00 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -0,0 +1,355 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::Error; +use gpui::prelude::*; +use gpui::{ + App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Task, Window, div, +}; +use gpui_component::button::{Button, ButtonVariants, DropdownButton}; +use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::menu::PopupMenuItem; +use gpui_component::tree::TreeState; +use gpui_component::{ActiveTheme, IconName, StyledExt, h_flex, v_flex}; +use signed_core::Announcement; +use signed_state::{GitStore, RepoStore}; + +mod browser; +mod helpers; + +use browser::{FileContent, MAX_PREVIEW_BYTES, MarkdownView}; +use helpers::{build_tree_items, is_markdown_path}; + +/// Detail view of a repository: header, stats, a file explorer with README +/// preview (cloned from the announcement's `clone` URLs), and metadata. +pub struct RepoDetailView { + /// Live per-repository store, refreshed from the local database. + store: Entity, + /// Snapshot taken at open time, shown until the store's first refresh completes. + initial: Announcement, + /// File explorer state (worktree of the local clone). + tree_state: Entity, + /// Root of the local clone, for reading files on demand. + worktree: Option, + /// Markdown document currently in the preview pane (README or a file). + md: Option, + readme_name: Option, + /// Currently previewed file (relative path) and its contents. + selected_file: Option, + files: HashMap, + /// Reads in flight, to avoid duplicate loads. + loading_files: HashSet, + /// A clone/fetch is in flight. + loading: bool, + error: Option, + focus_handle: FocusHandle, + tasks: Vec>>, +} + +impl RepoDetailView { + pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context) -> Self { + let store = cx.new(|cx| RepoStore::new(initial.addr(), cx)); + let tree_state = cx.new(|cx| TreeState::new(cx)); + + // Defer loading the repository until the window is ready. + cx.defer_in(window, |this, _window, cx| { + this.load_repo(cx); + }); + + Self { + store, + initial, + tree_state, + worktree: None, + md: None, + readme_name: None, + selected_file: None, + files: HashMap::new(), + loading_files: HashSet::new(), + loading: true, + error: None, + focus_handle: cx.focus_handle(), + tasks: Vec::new(), + } + } + + /// Clone (or fetch) the repository and populate the file explorer. + fn load_repo(&mut self, cx: &mut Context) { + self.loading = true; + self.error = None; + cx.notify(); + + let cache = GitStore::global(cx).cache().clone(); + let addr = self.initial.addr(); + let clone_urls: Vec = self.initial.clone.iter().map(ToString::to_string).collect(); + + let load = cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; + let entries = signed_git::worktree_entries(&repo)?; + let readme_path = signed_git::find_readme(&repo)?; + let readme = match &readme_path { + Some(path) => signed_git::worktree_read(&repo, path)?, + None => None, + }; + let worktree = repo.workdir().map(Path::to_path_buf); + + Ok::<_, Error>((entries, readme_path, readme, worktree)) + }); + + let task = cx.spawn(async move |this, cx| { + let result = load.await; + + this.update(cx, |this, cx| { + match result { + Ok((entries, readme_path, readme, Some(worktree))) => { + this.worktree = Some(worktree); + this.tree_state.update(cx, |state, cx| { + state.set_items(build_tree_items(&entries), cx); + }); + if let Some((path, bytes)) = readme_path.zip(readme) { + this.readme_name = Some(path.to_string_lossy().into()); + if let Ok(text) = String::from_utf8(bytes) { + this.set_markdown(None, &text, cx); + } + } + } + Ok((_, _, _, None)) => { + this.error = Some("Repository has no worktree".into()); + } + Err(error) => { + this.error = Some(error.to_string().into()); + } + } + this.loading = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Preview the file at `path` (relative to the worktree root). + 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) || self.loading_files.contains(path) { + cx.notify(); + return; + } + + // Paths come from our own tree walk, but never trust them: refuse + // anything that could escape the worktree. + 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(); + + let task = cx.spawn(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 bytes = match std::fs::read(&full) { + Ok(bytes) => bytes, + Err(error) => return Err(anyhow::anyhow!("{}", error)), + }; + + let kind = if bytes.len() > MAX_PREVIEW_BYTES { + FileContent::TooLarge + } else { + match String::from_utf8(bytes) { + Ok(text) => FileContent::Text(text), + Err(_) => FileContent::Binary, + } + }; + Ok::<_, Error>(kind) + }) + .await; + + this.update(cx, |this, cx| { + this.loading_files.remove(&path); + match content { + Ok(kind) => { + if let FileContent::Text(text) = &kind + && 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); + } + } + this.files.insert(path, kind); + } + Err(error) => { + this.files + .insert(path, FileContent::Failed(error.to_string())); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } +} + +impl Panel for RepoDetailView { + fn panel_name(&self) -> &'static str { + "repo_detail" + } + + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let announcement = self + .store + .read(cx) + .announcement + .clone() + .unwrap_or_else(|| self.initial.clone()); + + announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + } +} + +impl EventEmitter for RepoDetailView {} + +impl Focusable for RepoDetailView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RepoDetailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + + let announcement = self + .store + .read(cx) + .announcement + .clone() + .unwrap_or_else(|| self.initial.clone()); + + let name = announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())); + + let description = announcement + .description + .clone() + .unwrap_or(SharedString::from("No description")); + + let pane_title = self + .selected_file + .clone() + .or_else(|| self.readme_name.clone()) + .unwrap_or_else(|| "Overview".into()); + + let relays = announcement.relays.clone(); + + v_flex() + .id("repo") + .size_full() + .child( + h_flex() + .px_4() + .pt_2() + .pb_4() + .w_full() + .justify_between() + .border_b_1() + .border_color(cx.theme().border) + .child( + v_flex() + .flex_none() + .child(div().font_semibold().child(name)) + .child( + div() + .w_full() + .text_sm() + .text_color(cx.theme().muted_foreground) + .line_clamp(3) + .child(description), + ), + ) + .child( + h_flex() + .flex_1() + .gap_2() + .justify_end() + .child( + DropdownButton::new("relays") + .button( + Button::new("relay-trigger") + .label(format!("{} relays", relays.len())) + .ghost(), + ) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + if relays.is_empty() { + return menu.item( + PopupMenuItem::new("No relays").disabled(true), + ); + } + for relay in relays.iter() { + let url = relay.to_string(); + menu = menu.item( + PopupMenuItem::new(url.clone()).on_click( + move |_, _, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string(url.clone()), + ); + }, + ), + ); + } + menu + }), + ) + .child( + Button::new("link") + .icon(IconName::ExternalLink) + .tooltip("Open in gitworkshop.dev") + .secondary(), + ) + .child( + Button::new("clone") + .icon(IconName::ArrowDown) + .tooltip("Clone") + .primary(), + ), + ), + ) + .child( + h_flex() + .flex_1() + .w_full() + .overflow_hidden() + .child(self.render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)), + ) + } +} diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 32acd2d..3f3ae21 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Pixels, - Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; use gpui_component::avatar::Avatar; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; @@ -56,13 +56,31 @@ impl RepoListView { } } + fn open_repo( + &mut self, + announcement: &Announcement, + window: &mut Window, + cx: &mut Context, + ) { + let dock_area = self.dock_area.clone(); + let detail = cx.new(|cx| RepoDetailView::new(announcement.clone(), window, cx)); + + if let Some(dock_area) = dock_area.upgrade() { + dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel(Arc::new(detail), DockPlacement::Center, None, window, cx); + }); + } + } + fn render_card( &self, + ix: usize, announcement: &Announcement, last_activity: Option, - cx: &mut App, + cx: &mut Context, ) -> AnyElement { - let owner = ProfileStore::global(cx).read(cx).get(&announcement.owner); + let profile_store = ProfileStore::global(cx); + let owner = profile_store.read(cx).get(&announcement.owner); let name = announcement .name @@ -79,34 +97,13 @@ impl RepoListView { .map(|label| SharedString::from(format!("Updated {label}"))) .unwrap_or_default(); - // Open the repository in a new center tab when the card is clicked. - let dock_area = self.dock_area.clone(); - let announcement = announcement.clone(); - v_flex() - .id(ElementId::from(format!( - "repo-card-{}", - announcement.addr() - ))) + .id(ix) .px_4() .w_full() .border_b(px(1.)) .border_color(cx.theme().border) - .cursor_pointer() - .on_click(move |_event, window, cx| { - let detail = cx.new(|cx| RepoDetailView::new(announcement.clone(), window, cx)); - if let Some(dock_area) = dock_area.upgrade() { - dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel( - Arc::new(detail), - DockPlacement::Center, - None, - window, - cx, - ); - }); - } - }) + .hover(|this| this.bg(cx.theme().list_hover)) .child( h_flex() .h_12() @@ -158,6 +155,12 @@ impl RepoListView { .child(activity), ), ) + .on_click(cx.listener({ + let announcement = announcement.clone(); + move |this, _ev, window, cx| { + this.open_repo(&announcement, window, cx); + } + })) .into_any_element() } } @@ -214,23 +217,21 @@ impl Render for RepoListView { ) }) .when(has_announcements, |this| { + let view = cx.entity().clone(); + let sizes = self.item_sizes.clone(); + this.child( - v_virtual_list( - cx.entity().clone(), - "repos", - self.item_sizes.clone(), - move |this, range, _window, cx| { - let mut items = vec![]; + v_virtual_list(view, "repos", sizes, move |this, range, _window, cx| { + let mut items = vec![]; - for ix in range { - let announcement: &Announcement = &announcements[ix]; - let activity = last_activity.get(&announcement.addr()).copied(); - items.push(this.render_card(announcement, activity, cx)); - } + for ix in range { + let announcement: &Announcement = &announcements[ix]; + let activity = last_activity.get(&announcement.addr()).copied(); + items.push(this.render_card(ix, announcement, activity, cx)); + } - items - }, - ) + items + }) .track_scroll(&self.scroll_handle) .size_full(), ) diff --git a/crates/workspace/src/views/sidebar/passphrase_dialog.rs b/crates/workspace/src/views/sidebar/passphrase_dialog.rs index e0670b3..1ad2f73 100644 --- a/crates/workspace/src/views/sidebar/passphrase_dialog.rs +++ b/crates/workspace/src/views/sidebar/passphrase_dialog.rs @@ -1,3 +1,4 @@ +use assets::CustomIconName; use gpui::prelude::*; use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; @@ -78,6 +79,7 @@ pub fn open(window: &mut Window, cx: &mut App) { DialogFooter::new().justify_end().child( Button::new("unlock") .primary() + .icon(CustomIconName::Unlock) .label("Unlock") .loading(busy) .disabled(busy) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1a109c6..66bfe71 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4,7 +4,7 @@ use assets::CustomIconName; use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::dock::{DockArea, DockItem}; +use gpui_component::dock::{DockArea, DockItem, PanelStyle}; use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex}; use signed_state::{Backend, BackendEvent}; @@ -21,9 +21,8 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let backend = Backend::global(cx); - let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx)); - + let dock = + cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(PanelStyle::TabBar)); let weak_dock = dock.downgrade(); let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); @@ -39,6 +38,7 @@ impl Workspace { sidebar.update(cx, |sidebar, cx| sidebar.open_explore(window, cx)); + let backend = Backend::global(cx); let connected = backend.read(cx).is_connected(); let sync_progress = backend.read(cx).sync_progress(); diff --git a/desktop/src/main.rs b/desktop/src/main.rs index e52c9df..c289c7d 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -31,13 +31,14 @@ fn main() { signed_state::GitStore::set_global(paths::repos_dir().clone(), cx); // Set up the window bounds - let bounds = Bounds::centered(None, size(px(980.0), px(740.0)), cx); + let bounds = Bounds::centered(None, size(px(1120.0), px(720.0)), cx); // Set up the window options let opts = WindowOptions { window_background: WindowBackgroundAppearance::Opaque, window_decorations: Some(WindowDecorations::Client), window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(960.0), px(640.0))), kind: WindowKind::Normal, app_id: Some("Signed".to_owned()), titlebar: Some(TitlebarOptions { -- 2.54.0 From 91a76a6f52d2102e4864b2edd0cb6dd6f7a850f3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 10 Aug 2026 18:24:48 +0700 Subject: [PATCH 25/64] update --- crates/workspace/src/views/repo_detail/mod.rs | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index da88e00..0267fb0 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -269,6 +269,7 @@ impl Render for RepoDetailView { .unwrap_or_else(|| "Overview".into()); let relays = announcement.relays.clone(); + let web = announcement.web.clone(); v_flex() .id("repo") @@ -284,12 +285,13 @@ impl Render for RepoDetailView { .border_color(cx.theme().border) .child( v_flex() - .flex_none() + .flex_1() + .min_w_0() .child(div().font_semibold().child(name)) .child( div() .w_full() - .text_sm() + .text_xs() .text_color(cx.theme().muted_foreground) .line_clamp(3) .child(description), @@ -297,7 +299,7 @@ impl Render for RepoDetailView { ) .child( h_flex() - .flex_1() + .flex_none() .gap_2() .justify_end() .child( @@ -329,6 +331,28 @@ impl Render for RepoDetailView { menu }), ) + .child( + DropdownButton::new("web") + .button(Button::new("web-trigger").label("Websites").ghost()) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + if web.is_empty() { + return menu + .item(PopupMenuItem::new("No web").disabled(true)); + } + for url in web.iter() { + let href = url.to_string(); + menu = menu.item( + PopupMenuItem::new(href.clone()).on_click( + move |_, _, cx| { + cx.open_url(&href); + }, + ), + ); + } + menu + }), + ) .child( Button::new("link") .icon(IconName::ExternalLink) -- 2.54.0 From aabccdf0992d2770b47d1ae55945ca2a4ff4d5bb Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 11 Aug 2026 10:56:39 +0700 Subject: [PATCH 26/64] add code preview --- Cargo.lock | 407 +++++++++++++++++- Cargo.toml | 4 +- .../src/views/repo_detail/browser.rs | 115 +++-- .../src/views/repo_detail/helpers.rs | 132 +++++- crates/workspace/src/views/repo_detail/mod.rs | 28 +- 5 files changed, 629 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b131831..3c06477 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1018,9 +1018,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.1" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -3522,6 +3522,41 @@ dependencies = [ "smallvec", "smol", "tracing", + "tree-sitter", + "tree-sitter-astro-next", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cmake", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-elixir", + "tree-sitter-embedded-template", + "tree-sitter-go", + "tree-sitter-graphql", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-jsdoc", + "tree-sitter-json", + "tree-sitter-kotlin-sg", + "tree-sitter-lua", + "tree-sitter-make", + "tree-sitter-md", + "tree-sitter-php", + "tree-sitter-proto", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-sequel", + "tree-sitter-svelte-next", + "tree-sitter-swift", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-yaml", + "tree-sitter-zig", "unicode-segmentation", "uuid", "windows 0.58.0", @@ -8035,6 +8070,12 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -8361,7 +8402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -8823,6 +8864,366 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter" +version = "0.26.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c567a8e18ae93f20982c90370b16fd24023aeaf52f6052b96957ab253a0fec" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-astro-next" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794a4a59fc2d88e49b4bc41fef9522d77184a36f4e68bbaf545cd1eb2364c46e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cmake" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-diff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-elixir" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-embedded-template" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-graphql" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-jsdoc" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-kotlin-sg" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06ec43ae3c12165d4ac08afe4e1f5fc6757ffe274fa7bd5af9007ef11ba4319" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-lua" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-proto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-scala" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-sequel" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-svelte-next" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f88190d0743e897c3e148a7e241aba0a8844b8afe816943851426e3f7b9753" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-zig" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.16" diff --git a/Cargo.toml b/Cargo.toml index 7b1755b..9126736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,9 @@ gpui_macos = { git = "https://github.com/zed-industries/zed" } gpui_tokio = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" } -gpui-component = { git = "https://github.com/longbridge/gpui-component" } +# `tree-sitter-languages` enables syntax highlighting for the TextView +# code preview (fenced code blocks are highlighted with tree-sitter). +gpui-component = { git = "https://github.com/longbridge/gpui-component", features = ["tree-sitter-languages"] } nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 3841e8a..1513b39 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -1,9 +1,10 @@ //! File explorer of the repository detail view: the file tree column and the -//! content column (README / file preview), backed by a persistent -//! [`TextViewState`] for markdown documents. +//! content column (README / file preview), backed by persistent +//! [`TextViewState`]s for markdown documents and code files. use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, div, px}; +use gpui_component::clipboard::Clipboard; use gpui_component::list::ListItem; use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; @@ -11,7 +12,7 @@ use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use super::RepoDetailView; -use super::helpers::{is_markdown_path, placeholder, plain_lines}; +use super::helpers::{code_language, fenced_code, is_markdown_path, placeholder}; /// Width of the file explorer column. const TREE_WIDTH: f32 = 240.; @@ -44,6 +45,28 @@ pub(super) struct MarkdownView { pub(super) state: Entity, } +/// A code file loaded into a persistent [`TextViewState`], rendered as a +/// fenced code block so the markdown parser syntax-highlights it. +/// +/// Same persistence rationale as [`MarkdownView`]: the state lives as long +/// as this view, so re-viewing the same file does not re-parse it, and +/// parsing happens on a background task. +pub(super) struct CodeView { + /// Source path, relative to the worktree root. + pub(super) path: SharedString, + pub(super) state: Entity, +} + +/// Spinner shown while a document is being loaded/parsed. +fn preview_spinner() -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() +} + impl RepoDetailView { /// One row of the file tree: icon + name, indented by depth. fn render_tree_item( @@ -150,14 +173,11 @@ impl RepoDetailView { .into_any_element() } else if let Some(path) = selected_file { match self.files.get(path.as_ref()) { - Some(FileContent::Text(text)) => { + Some(FileContent::Text(_)) => { if is_markdown_path(path.as_ref()) { self.markdown_element(Some(path.as_ref()), cx) } else { - v_flex() - .size_full() - .children(plain_lines(text, cx)) - .into_any_element() + self.code_element(path.as_ref(), cx) } } Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx), @@ -197,15 +217,7 @@ impl RepoDetailView { .child(pane_title), ), ) - .child( - div() - .id("repo-content-scroll") - .flex_1() - .min_h_0() - .p_4() - .overflow_y_scroll() - .child(body), - ) + .child(div().id("repo-content").flex_1().min_h_0().child(body)) } /// Load `text` into the persistent markdown TextView state. @@ -228,26 +240,71 @@ impl RepoDetailView { /// The persistent markdown TextView for `path` (`None` = README), or a /// spinner while the document is being loaded/parsed. fn markdown_element(&mut self, path: Option<&str>, _cx: &mut Context) -> AnyElement { - let spinner = || { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element() - }; - let Some(md) = &self.md else { - return spinner(); + return preview_spinner(); }; let ready = match path { Some(path) => md.path.as_deref() == Some(path), None => md.path.is_none(), }; if !ready { - return spinner(); + return preview_spinner(); } - TextView::new(&md.state).selectable(true).into_any_element() + TextView::new(&md.state) + .selectable(true) + .scrollable(true) + .p_4() + .into_any_element() + } + + /// Load `text` into the persistent code TextView state for `path`. + /// + /// The code is wrapped in a markdown fence (see [`fenced_code`]) so the + /// TextView renders it as a syntax-highlighted code block. Like + /// [`set_markdown`], the state is created empty and fed via `push_str` + /// so parsing happens on a background task instead of blocking the main + /// thread. + pub(super) fn set_code(&mut self, path: SharedString, text: &str, cx: &mut Context) { + let source = fenced_code(text, code_language(path.as_ref())); + let state = cx.new(|cx| TextViewState::markdown("", cx)); + state.update(cx, |state, cx| state.push_str(&source, cx)); + self.code = Some(CodeView { path, state }); + } + + /// The persistent code TextView for `path`, or a spinner while the file + /// is being loaded/parsed. + fn code_element(&mut self, path: &str, _cx: &mut Context) -> AnyElement { + let Some(code) = &self.code else { + return preview_spinner(); + }; + if code.path.as_ref() != path { + return preview_spinner(); + } + + TextView::new(&code.state) + .selectable(true) + .scrollable(true) + .p_4() + .code_block_actions(|code_block, _window, cx| { + let lang = code_block.lang().unwrap_or_default(); + h_flex() + .gap_2() + .items_center() + .when(!lang.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(lang.clone()), + ) + }) + .child( + Clipboard::new(format!("copy-code-{lang}")) + .value(code_block.code()) + .tooltip("Copy code"), + ) + }) + .into_any_element() } } diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index 0972756..ade4ac0 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -1,5 +1,5 @@ -//! Pure helpers for the repository detail view: file-tree building, plain -//! text rendering and small element builders. +//! Pure helpers for the repository detail view: file-tree building, code +//! preview helpers and small element builders. use std::path::{Path, PathBuf}; @@ -45,20 +45,85 @@ fn insert_path(items: &mut Vec, parts: &[String], prefix: &str) { } } -/// Render text with one element per line, preserving blank lines. -pub(super) fn plain_lines(text: &str, cx: &App) -> Vec { - text.lines() - .map(|line| { - // A space keeps empty lines from collapsing to zero height. - let text = if line.is_empty() { " " } else { line }; - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().foreground) - .child(text.to_string()) - .into_any_element() - }) - .collect() +/// The markdown fence language for a file path, or `None` for plain text. +/// +/// Names are chosen so `gpui_component`'s highlighter can resolve them +/// (`highlighter::Language::from_name` accepts short aliases such as `rs` +/// and `js`). +pub(super) fn code_language(path: &str) -> Option<&'static str> { + let name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + + // Some common files are recognized by name rather than extension. + match name { + "Makefile" | "makefile" => return Some("make"), + "CMakeLists.txt" => return Some("cmake"), + _ => {} + } + + let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "rs" => "rust", + "toml" => "toml", + "json" | "jsonc" => "json", + "py" => "python", + "js" | "mjs" | "cjs" => "javascript", + "ts" | "mts" | "cts" => "typescript", + "tsx" | "jsx" => "tsx", + "go" => "go", + "c" | "h" => "c", + "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp", + "cs" => "csharp", + "java" => "java", + "kt" | "kts" | "ktm" => "kotlin", + "swift" => "swift", + "php" | "phtml" => "php", + "rb" => "ruby", + "sh" | "bash" | "zsh" => "bash", + "yml" | "yaml" => "yaml", + "css" | "scss" | "sass" => "css", + "html" | "htm" => "html", + "lua" => "lua", + "sql" => "sql", + "proto" | "protobuf" => "proto", + "cmake" => "cmake", + "zig" => "zig", + "ex" | "exs" => "elixir", + "graphql" | "gql" => "graphql", + "diff" | "patch" => "diff", + "svelte" => "svelte", + "astro" => "astro", + "scala" => "scala", + _ => return None, + }) +} + +/// Wrap `code` in a fenced markdown code block tagged with `lang`, so the +/// markdown [`TextViewState`] renders it as a syntax-highlighted code block. +/// +/// The fence is one backtick longer than the longest run of backticks in +/// `code`, so the content can never close the block early. +pub(super) fn fenced_code(code: &str, lang: Option<&str>) -> String { + // Split on non-backtick characters so the segments are runs of backticks. + let longest_run = code.split(|c| c != '`').map(str::len).max().unwrap_or(0); + let fence = "`".repeat((longest_run + 1).max(3)); + + let mut out = + String::with_capacity(code.len() + fence.len() * 2 + lang.map_or(1, |lang| lang.len() + 2)); + out.push_str(&fence); + if let Some(lang) = lang { + out.push(' '); + out.push_str(lang); + } + out.push('\n'); + out.push_str(code); + if !code.ends_with('\n') { + out.push('\n'); + } + out.push_str(&fence); + out } /// Whether a file path has a markdown extension. @@ -134,4 +199,39 @@ mod tests { assert_eq!(items[0].children[0].id, "a/b"); assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); } + + #[test] + fn fenced_code_wraps_in_triple_backticks() { + let out = fenced_code("fn main() {}\n", Some("rust")); + assert_eq!(out, "``` rust\nfn main() {}\n```"); + } + + #[test] + fn fenced_code_uses_longer_fence_than_content() { + let code = "let x = \"```\";\n`code`"; + let out = fenced_code(code, None); + // The longest run of backticks in `code` is 3, so the fence is 4. + assert!(out.starts_with("````\n")); + assert!(out.ends_with("````")); + assert!(out.contains(code)); + } + + #[test] + fn fenced_code_keeps_trailing_newline() { + assert_eq!(fenced_code("a\n", None), "```\na\n```"); + assert_eq!(fenced_code("a", None), "```\na\n```"); + } + + #[test] + fn code_language_maps_extensions_and_names() { + assert_eq!(code_language("src/main.rs"), Some("rust")); + assert_eq!(code_language("Cargo.toml"), Some("toml")); + assert_eq!(code_language("app.js"), Some("javascript")); + assert_eq!(code_language("index.tsx"), Some("tsx")); + assert_eq!(code_language("Makefile"), Some("make")); + assert_eq!(code_language("CMakeLists.txt"), Some("cmake")); + assert_eq!(code_language("data.csv"), None); + assert_eq!(code_language("LICENSE"), None); + assert_eq!(code_language("README.md"), None); + } } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 0267fb0..73b300b 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -18,7 +18,7 @@ use signed_state::{GitStore, RepoStore}; mod browser; mod helpers; -use browser::{FileContent, MAX_PREVIEW_BYTES, MarkdownView}; +use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView}; use helpers::{build_tree_items, is_markdown_path}; /// Detail view of a repository: header, stats, a file explorer with README @@ -34,6 +34,8 @@ pub struct RepoDetailView { worktree: Option, /// Markdown document currently in the preview pane (README or a file). md: Option, + /// Code file currently in the preview pane. + code: Option, readme_name: Option, /// Currently previewed file (relative path) and its contents. selected_file: Option, @@ -63,6 +65,7 @@ impl RepoDetailView { tree_state, worktree: None, md: None, + code: None, readme_name: None, selected_file: None, files: HashMap::new(), @@ -150,9 +153,11 @@ impl RepoDetailView { Component::ParentDir | Component::RootDir | Component::Prefix(_) ) }); + let Some(worktree) = self.worktree.clone() else { return; }; + if unsafe_path { return; } @@ -186,13 +191,19 @@ impl RepoDetailView { this.loading_files.remove(&path); match content { Ok(kind) => { - if let FileContent::Text(text) = &kind - && 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); + 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, cx); + } } } this.files.insert(path, kind); @@ -280,6 +291,7 @@ impl Render for RepoDetailView { .pt_2() .pb_4() .w_full() + .items_start() .justify_between() .border_b_1() .border_color(cx.theme().border) -- 2.54.0 From e77cfe29d93adfab17ad240b2940a87a83c6bed4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 11 Aug 2026 11:05:28 +0700 Subject: [PATCH 27/64] update --- crates/assets/assets/icons/git-clone.svg | 1 + crates/assets/src/lib.rs | 2 ++ crates/workspace/src/views/repo_detail/mod.rs | 5 +++-- crates/workspace/src/views/sidebar/mod.rs | 3 +++ 4 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 crates/assets/assets/icons/git-clone.svg diff --git a/crates/assets/assets/icons/git-clone.svg b/crates/assets/assets/icons/git-clone.svg new file mode 100644 index 0000000..ed82e28 --- /dev/null +++ b/crates/assets/assets/icons/git-clone.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index c4ad70c..b0d1fb8 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -52,6 +52,7 @@ pub enum CustomIconName { Filter, GlobalOn, GlobalOff, + GitClone, } impl IconNamed for CustomIconName { @@ -61,6 +62,7 @@ impl IconNamed for CustomIconName { CustomIconName::Filter => "icons/filter.svg", CustomIconName::GlobalOn => "icons/global-on.svg", CustomIconName::GlobalOff => "icons/global-off.svg", + CustomIconName::GitClone => "icons/git-clone.svg", } .into() } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 73b300b..a3de9a3 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; use anyhow::Error; +use assets::CustomIconName; use gpui::prelude::*; use gpui::{ App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, @@ -291,6 +292,7 @@ impl Render for RepoDetailView { .pt_2() .pb_4() .w_full() + .gap_2() .items_start() .justify_between() .border_b_1() @@ -302,7 +304,6 @@ impl Render for RepoDetailView { .child(div().font_semibold().child(name)) .child( div() - .w_full() .text_xs() .text_color(cx.theme().muted_foreground) .line_clamp(3) @@ -373,7 +374,7 @@ impl Render for RepoDetailView { ) .child( Button::new("clone") - .icon(IconName::ArrowDown) + .icon(CustomIconName::GitClone) .tooltip("Clone") .primary(), ), diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 06488f2..72dec76 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -210,6 +210,9 @@ impl Render for SidebarPanel { .child(NavItem::new("explore", "Browse", IconName::Globe).on_click( cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), )) + .child(NavItem::new("search", "Saerch", IconName::Search).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + )) .child( v_flex().w_full().child( h_flex() -- 2.54.0 From 6f381c68c3dc6c56ccb1520543ec5553ece825bc Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 11 Aug 2026 13:30:09 +0700 Subject: [PATCH 28/64] . --- Cargo.lock | 3 + crates/signed_git/Cargo.toml | 2 +- crates/signed_git/src/lib.rs | 151 ++++++++++++++++++ crates/utils/src/lib.rs | 2 +- crates/utils/src/time.rs | 5 + .../src/views/repo_detail/browser.rs | 42 ++++- crates/workspace/src/views/repo_detail/mod.rs | 44 +++++ crates/workspace/src/workspace.rs | 21 ++- 8 files changed, 261 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c06477..ca5895e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3068,13 +3068,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" dependencies = [ + "bitflags 2.13.1", "bstr", "gix-commitgraph", "gix-date", "gix-error", "gix-hash", + "gix-hashtable", "gix-object", "gix-revwalk", + "gix-trace", "nonempty", ] diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index a853dc1..35cbaa0 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -8,7 +8,7 @@ publish.workspace = true signed_core = { path = "../signed_core" } nostr.workspace = true -gix.workspace = true +gix = { workspace = true, features = ["revision"] } anyhow.workspace = true [dev-dependencies] diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 84b5300..f4b8bcd 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -140,6 +140,19 @@ fn sanitize_path_component(id: &str) -> String { sanitized } +/// Metadata of a commit, as shown in the repository browser's file header. +#[derive(Debug, Clone)] +pub struct FileCommit { + /// Shortened commit id (7+ hex chars, disambiguated if needed). + pub id: String, + /// First line of the commit message. + pub summary: String, + /// Author name. + pub author: String, + /// Author time, seconds since the Unix epoch. + pub time: i64, +} + /// Relative paths of all entries in the worktree (files and directories), /// directories first, then alphabetically within each group. The `.git` /// directory is skipped. @@ -209,6 +222,57 @@ pub fn find_readme(repo: &gix::Repository) -> Result> { .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) } +/// Find the most recent commit that changed `rel` (a path relative to the +/// worktree), like `git log -1 -- ` does for non-merge commits. +/// +/// Walks history from `HEAD` newest-first and returns the first commit whose +/// tree entry for `rel` differs from its first parent's; a merge that only +/// changed the file through its second parent is therefore not reported. +/// Returns `Ok(None)` if no commit touched the file (e.g. untracked files). +pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let head = repo.head_id()?; + let walk = repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + for info in walk.all()? { + let info = info?; + let commit = info.object()?; + let blob = commit.tree()?.lookup_entry_by_path(rel)?; + let parent_blob = match info.parent_ids().next() { + Some(parent) => parent + .object()? + .into_commit() + .tree()? + .lookup_entry_by_path(rel)?, + None => None, + }; + + if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) { + let author = commit.author()?; + let message = commit.message()?; + return Ok(Some(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + })); + } + } + + Ok(None) +} + +/// Like [`last_commit`], but opens the repository located at `workdir` +/// (for non-bare clones the clone root is the worktree) first. +pub fn worktree_last_commit(workdir: &Path, rel: &Path) -> Result> { + last_commit(&gix::open(workdir)?, rel) +} + fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; @@ -330,6 +394,93 @@ mod tests { ); } + /// Stage everything and create a commit with the git CLI (like + /// [`apply_patch`], the crate already shells out to the CLI). + fn commit_all(repo: &gix::Repository, message: &str) { + let dir = repo.workdir().expect("workdir"); + let run = |args: &[&str]| { + let status = Command::new("git") + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); + }; + run(&["add", "-A"]); + run(&["commit", "-m", message]); + } + + #[test] + fn last_commit_returns_most_recent_change() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); + commit_all(&repo, "change a"); + + // A commit touching another file must not be reported for a.txt. + std::fs::write(dir.path().join("b.txt"), b"other").expect("write"); + commit_all(&repo, "add b"); + + let commit = last_commit(&repo, Path::new("a.txt")) + .expect("lookup") + .expect("found"); + assert_eq!(commit.summary, "change a"); + assert_eq!(commit.author, "Test Author"); + assert!(!commit.id.is_empty()); + assert!(commit.time > 0); + } + + #[test] + fn last_commit_returns_none_for_untracked_files() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + std::fs::write(dir.path().join("untracked.txt"), b"x").expect("write"); + + let commit = last_commit(&repo, Path::new("untracked.txt")).expect("lookup"); + assert!(commit.is_none()); + } + + #[test] + fn last_commit_reports_merge_commits() { + let (dir, repo) = fixture(&[("a.txt", b"base")]); + commit_all(&repo, "initial"); + + let run = |args: &[&str]| { + let status = Command::new("git") + .current_dir(dir.path()) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); + }; + run(&["checkout", "-b", "feature"]); + std::fs::write(dir.path().join("a.txt"), b"feature").expect("write"); + commit_all(&repo, "feature change"); + run(&["checkout", "-"]); + // --no-ff forces a merge commit; it is the latest commit changing a.txt. + run(&["merge", "--no-ff", "--no-edit", "feature"]); + + let commit = last_commit(&repo, Path::new("a.txt")) + .expect("lookup") + .expect("found"); + assert_eq!( + commit.id, + repo.head_id().expect("head").shorten_or_id().to_string() + ); + assert!(commit.summary.starts_with("Merge branch")); + } + #[test] fn find_readme_prefers_markdown() { let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 5ce1f4c..0fe56c3 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -2,4 +2,4 @@ mod pubkey; mod time; pub use pubkey::shorten_pubkey; -pub use time::relative_time; +pub use time::{relative_time, relative_time_secs}; diff --git a/crates/utils/src/time.rs b/crates/utils/src/time.rs index 3c350d9..b48310c 100644 --- a/crates/utils/src/time.rs +++ b/crates/utils/src/time.rs @@ -20,6 +20,11 @@ pub fn relative_time(timestamp: Timestamp) -> String { } } +/// Format a unix timestamp in seconds as a short relative time (e.g. "3h ago"). +pub fn relative_time_secs(secs: i64) -> String { + relative_time(Timestamp::from_secs(secs.max(0) as u64)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 1513b39..f40e981 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -4,6 +4,7 @@ use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, div, px}; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; use gpui_component::list::ListItem; use gpui_component::spinner::Spinner; @@ -196,6 +197,23 @@ impl RepoDetailView { placeholder("No README found", cx) }; + // Latest commit for the current pane: the selected file, or the README + // while nothing is selected. Computed after the body above, which + // needs `&mut self`. + let commit = self + .selected_file + .as_ref() + .and_then(|path| self.commits.get(path.as_ref())) + .or_else(|| { + if self.selected_file.is_none() { + self.readme_name + .as_ref() + .and_then(|name| self.commits.get(name.as_ref())) + } else { + None + } + }); + v_flex() .flex_1() .min_w_0() @@ -204,6 +222,7 @@ impl RepoDetailView { h_flex() .px_3() .h_9() + .gap_2() .bg(cx.theme().muted) .border_b(px(1.)) .border_color(cx.theme().border) @@ -215,7 +234,28 @@ impl RepoDetailView { .text_ellipsis() .whitespace_nowrap() .child(pane_title), - ), + ) + .when_some(commit, |this, commit| { + this.child( + h_flex() + .flex_1() + .gap_1() + .child( + Button::new("commit") + .xsmall() + .text() + .label(commit.id.clone()), + ) + .child( + div() + .max_w(px(250.)) + .text_xs() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + }), ) .child(div().id("repo-content").flex_1().min_h_0().child(body)) } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index a3de9a3..4e13d56 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -14,6 +14,7 @@ use gpui_component::menu::PopupMenuItem; use gpui_component::tree::TreeState; use gpui_component::{ActiveTheme, IconName, StyledExt, h_flex, v_flex}; use signed_core::Announcement; +use signed_git::FileCommit; use signed_state::{GitStore, RepoStore}; mod browser; @@ -43,6 +44,10 @@ pub struct RepoDetailView { files: HashMap, /// Reads in flight, to avoid duplicate loads. loading_files: HashSet, + /// Latest commit touching a previewed file (or the README), keyed by path. + commits: HashMap, + /// Commit queries in flight, to avoid duplicate loads. + loading_commits: HashSet, /// A clone/fetch is in flight. loading: bool, error: Option, @@ -71,6 +76,8 @@ impl RepoDetailView { selected_file: None, files: HashMap::new(), loading_files: HashSet::new(), + commits: HashMap::new(), + loading_commits: HashSet::new(), loading: true, error: None, focus_handle: cx.focus_handle(), @@ -113,6 +120,7 @@ impl RepoDetailView { }); if let Some((path, bytes)) = readme_path.zip(readme) { this.readme_name = Some(path.to_string_lossy().into()); + this.load_commit(&path.to_string_lossy(), cx); if let Ok(text) = String::from_utf8(bytes) { this.set_markdown(None, &text, cx); } @@ -165,6 +173,7 @@ impl RepoDetailView { self.loading_files.insert(path.to_string()); let path = path.to_string(); + self.load_commit(&path, cx); let task = cx.spawn(async move |this, cx| { let path_for_read = path.clone(); @@ -222,6 +231,41 @@ impl RepoDetailView { self.tasks.push(task); } + + /// Query the latest commit touching `path` on a background task and cache + /// it in [`Self::commits`], for the file header in the content column. + fn load_commit(&mut self, path: &str, cx: &mut Context) { + if self.commits.contains_key(path) || self.loading_commits.contains(path) { + return; + } + let Some(worktree) = self.worktree.clone() else { + return; + }; + + self.loading_commits.insert(path.to_string()); + let path = path.to_string(); + + let task = cx.spawn(async move |this, cx| { + let path_for_query = path.clone(); + let result = cx + .background_spawn(async move { + signed_git::worktree_last_commit(&worktree, Path::new(&path_for_query)) + }) + .await; + + this.update(cx, |this, cx| { + this.loading_commits.remove(&path); + if let Ok(Some(commit)) = result { + this.commits.insert(path, commit); + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } } impl Panel for RepoDetailView { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 66bfe71..7ea4d16 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -21,23 +21,23 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let dock = - cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(PanelStyle::TabBar)); + let style = PanelStyle::TabBar; + let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(style)); let weak_dock = dock.downgrade(); + let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); + let weak_sidebar = sidebar.downgrade(); dock.update(cx, |dock_area, cx| { dock_area.set_left_dock( - DockItem::panel(Arc::new(sidebar.clone())), - Some(px(260.)), + DockItem::panel(Arc::new(sidebar)), + Some(px(240.)), true, window, cx, ); }); - sidebar.update(cx, |sidebar, cx| sidebar.open_explore(window, cx)); - let backend = Backend::global(cx); let connected = backend.read(cx).is_connected(); let sync_progress = backend.read(cx).sync_progress(); @@ -86,6 +86,15 @@ impl Workspace { passphrase_dialog::open(window, cx); } + // Open the explore panel after the sidebar has been initialized. + cx.defer_in(window, move |_, window, cx| { + weak_sidebar + .update(cx, |this, cx| { + this.open_explore(window, cx); + }) + .ok(); + }); + Self { dock, status, -- 2.54.0 From 5f38c083311aa3416956e80b8a12664c2fe68c92 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 12 Aug 2026 08:29:00 +0700 Subject: [PATCH 29/64] add commit browse --- crates/signed_git/src/lib.rs | 64 +++++ .../src/views/repo_detail/commits.rs | 128 +++++++++ crates/workspace/src/views/repo_detail/mod.rs | 269 ++++++++++++------ 3 files changed, 376 insertions(+), 85 deletions(-) create mode 100644 crates/workspace/src/views/repo_detail/commits.rs diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index f4b8bcd..d9e0435 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -273,6 +273,42 @@ pub fn worktree_last_commit(workdir: &Path, rel: &Path) -> Result Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = repo.head_id().ok() else { + return Ok(Vec::new()); + }; + let walk = repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut commits = Vec::new(); + for info in walk.all()? { + let info = info?; + let commit = info.object()?; + let author = commit.author()?; + let message = commit.message()?; + commits.push(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + }); + } + Ok(commits) +} + +/// Like [`all_commits`], but opens the repository located at `workdir` +/// (for non-bare clones the clone root is the worktree) first. +pub fn worktree_all_commits(workdir: &Path) -> Result> { + all_commits(&gix::open(workdir)?) +} + fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; @@ -436,6 +472,34 @@ mod tests { assert!(commit.time > 0); } + #[test] + fn all_commits_lists_every_commit() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); + commit_all(&repo, "second"); + std::fs::write(dir.path().join("b.txt"), b"b").expect("write"); + commit_all(&repo, "third"); + + let commits = all_commits(&repo).expect("commits"); + let mut summaries: Vec<&str> = commits.iter().map(|c| c.summary.as_str()).collect(); + summaries.sort(); + assert_eq!(summaries, vec!["initial", "second", "third"]); + assert!( + commits + .iter() + .all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0) + ); + } + + #[test] + fn all_commits_returns_empty_without_head() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + + assert!(all_commits(&repo).expect("commits").is_empty()); + } + #[test] fn last_commit_returns_none_for_untracked_files() { let (dir, repo) = fixture(&[("a.txt", b"one")]); diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs new file mode 100644 index 0000000..89d83d3 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -0,0 +1,128 @@ +//! Commits tab of the repository detail view: a virtual list of all +//! commits reachable from HEAD, newest first, with the total count shown +//! as a badge on the tab. + +use gpui::prelude::*; +use gpui::{AnyElement, App, Context, div, px}; +use gpui_component::scroll::Scrollbar; +use gpui_component::spinner::Spinner; +use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list}; +use signed_git::FileCommit; +use utils::relative_time_secs; + +use super::RepoDetailView; +use super::helpers::placeholder; + +/// Height of one commit row in the virtual list. +pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.; + +/// One row of the commit list: id, summary, author and relative time. +fn commit_row(ix: usize, commit: &FileCommit, cx: &App) -> AnyElement { + h_flex() + .id(ix) + .px_4() + .h(px(COMMIT_ROW_HEIGHT)) + .w_full() + .gap_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .hover(|this| this.bg(cx.theme().list_hover)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .justify_center() + .child( + h_flex() + .gap_2() + .items_center() + .overflow_hidden() + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.id.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.author.clone()) + .child(relative_time_secs(commit.time)), + ), + ) + .into_any_element() +} + +impl RepoDetailView { + /// Full-height body of the Commits tab: all commits in a virtual + /// list, or a status message while loading / when there are none. + pub(super) fn render_commits_tab(&mut self, cx: &mut Context) -> AnyElement { + let Some(commits) = self.all_commits.clone() else { + return if self.loading_all_commits { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() + } else { + placeholder("Failed to load commits", cx) + }; + }; + + if commits.is_empty() { + return placeholder("No commits found", cx); + } + + let view = cx.entity().clone(); + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); + + v_flex() + .relative() + .flex_1() + .w_full() + .min_h_0() + .child( + v_virtual_list( + view, + "repo-commits", + sizes, + move |_this, range, _window, cx| { + let mut rows = Vec::with_capacity(range.len()); + for ix in range { + rows.push(commit_row(ix, &commits[ix], cx)); + } + rows + }, + ) + .track_scroll(&scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&self.scroll_handle)), + ) + .into_any_element() + } +} diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 4e13d56..59a6e61 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -1,26 +1,33 @@ use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; +use std::rc::Rc; use anyhow::Error; use assets::CustomIconName; use gpui::prelude::*; use gpui::{ - App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, - SharedString, Task, Window, div, + App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Task, Window, div, px, size, }; use gpui_component::button::{Button, ButtonVariants, DropdownButton}; use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::menu::PopupMenuItem; +use gpui_component::tab::{Tab, TabBar}; +use gpui_component::tag::Tag; use gpui_component::tree::TreeState; -use gpui_component::{ActiveTheme, IconName, StyledExt, h_flex, v_flex}; +use gpui_component::{ + ActiveTheme, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, +}; use signed_core::Announcement; use signed_git::FileCommit; use signed_state::{GitStore, RepoStore}; mod browser; +mod commits; mod helpers; use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView}; +use commits::COMMIT_ROW_HEIGHT; use helpers::{build_tree_items, is_markdown_path}; /// Detail view of a repository: header, stats, a file explorer with README @@ -48,6 +55,16 @@ pub struct RepoDetailView { commits: HashMap, /// Commit queries in flight, to avoid duplicate loads. loading_commits: HashSet, + /// Active header tab: 0 = Files (tree), 1 = Commits. + active_tab: usize, + /// All commits reachable from HEAD, newest first; `None` until the + /// walk finishes (or fails). + all_commits: Option>, + /// Commit walk in flight. + loading_all_commits: bool, + /// Virtual list state of the Commits tab. + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, /// A clone/fetch is in flight. loading: bool, error: Option, @@ -78,6 +95,11 @@ impl RepoDetailView { loading_files: HashSet::new(), commits: HashMap::new(), loading_commits: HashSet::new(), + active_tab: 0, + all_commits: None, + loading_all_commits: false, + scroll_handle: VirtualListScrollHandle::new(), + item_sizes: Rc::new(Vec::new()), loading: true, error: None, focus_handle: cx.focus_handle(), @@ -118,6 +140,7 @@ impl RepoDetailView { this.tree_state.update(cx, |state, cx| { state.set_items(build_tree_items(&entries), cx); }); + this.load_all_commits(cx); if let Some((path, bytes)) = readme_path.zip(readme) { this.readme_name = Some(path.to_string_lossy().into()); this.load_commit(&path.to_string_lossy(), cx); @@ -266,6 +289,40 @@ impl RepoDetailView { self.tasks.push(task); } + + /// Walk all commits reachable from HEAD on a background task, for the + /// Commits tab and its total-count badge. + fn load_all_commits(&mut self, cx: &mut Context) { + if self.loading_all_commits || self.all_commits.is_some() { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + self.loading_all_commits = true; + + let task = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) + .await; + + this.update(cx, |this, cx| { + if let Ok(commits) = result { + let count = commits.len(); + this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); + this.all_commits = Some(commits); + } + this.loading_all_commits = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } } impl Panel for RepoDetailView { @@ -326,111 +383,153 @@ impl Render for RepoDetailView { let relays = announcement.relays.clone(); let web = announcement.web.clone(); + let commits_count = self.all_commits.as_ref().map(Vec::len); v_flex() .id("repo") .size_full() .child( - h_flex() + v_flex() .px_4() .pt_2() - .pb_4() + .pb_2() .w_full() - .gap_2() - .items_start() - .justify_between() + .gap_4() .border_b_1() .border_color(cx.theme().border) .child( - v_flex() - .flex_1() - .min_w_0() - .child(div().font_semibold().child(name)) + h_flex() + .w_full() + .gap_2() + .items_start() + .justify_between() .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .line_clamp(3) - .child(description), + v_flex() + .flex_1() + .min_w_0() + .child(div().font_semibold().child(name)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .line_clamp(3) + .text_ellipsis() + .child(description), + ), + ) + .child( + h_flex() + .flex_none() + .gap_2() + .justify_end() + .child( + DropdownButton::new("relays") + .button( + Button::new("relay-trigger") + .label(format!("{} relays", relays.len())) + .ghost(), + ) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + if relays.is_empty() { + return menu.item( + PopupMenuItem::new("No relays") + .disabled(true), + ); + } + for relay in relays.iter() { + let url = relay.to_string(); + menu = menu.item( + PopupMenuItem::new(url.clone()).on_click( + move |_, _, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string( + url.clone(), + ), + ); + }, + ), + ); + } + menu + }), + ) + .child( + DropdownButton::new("web") + .button( + Button::new("web-trigger") + .label("Websites") + .ghost(), + ) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + if web.is_empty() { + return menu.item( + PopupMenuItem::new("No web").disabled(true), + ); + } + for url in web.iter() { + let href = url.to_string(); + menu = menu.item( + PopupMenuItem::new(href.clone()).on_click( + move |_, _, cx| { + cx.open_url(&href); + }, + ), + ); + } + menu + }), + ) + .child( + Button::new("link") + .icon(IconName::ExternalLink) + .tooltip("Open in gitworkshop.dev") + .secondary(), + ) + .child( + Button::new("clone") + .icon(CustomIconName::GitClone) + .tooltip("Clone") + .primary(), + ), ), ) .child( h_flex() - .flex_none() - .gap_2() - .justify_end() .child( - DropdownButton::new("relays") - .button( - Button::new("relay-trigger") - .label(format!("{} relays", relays.len())) - .ghost(), - ) - .dropdown_menu(move |menu, _window, _cx| { - let mut menu = menu; - if relays.is_empty() { - return menu.item( - PopupMenuItem::new("No relays").disabled(true), - ); - } - for relay in relays.iter() { - let url = relay.to_string(); - menu = menu.item( - PopupMenuItem::new(url.clone()).on_click( - move |_, _, cx| { - cx.write_to_clipboard( - ClipboardItem::new_string(url.clone()), - ); - }, - ), - ); - } - menu - }), + TabBar::new("repo-tabs") + .segmented() + .selected_index(self.active_tab) + .child(Tab::new().label("Files")) + .child(Tab::new().label("Commits").when_some( + commits_count, + |this, count| { + this.suffix( + Tag::secondary() + .xsmall() + .mr_1() + .child(SharedString::from(count.to_string())), + ) + }, + )) + .on_click(cx.listener(|this, index, _window, cx| { + this.active_tab = *index; + cx.notify(); + })), ) - .child( - DropdownButton::new("web") - .button(Button::new("web-trigger").label("Websites").ghost()) - .dropdown_menu(move |menu, _window, _cx| { - let mut menu = menu; - if web.is_empty() { - return menu - .item(PopupMenuItem::new("No web").disabled(true)); - } - for url in web.iter() { - let href = url.to_string(); - menu = menu.item( - PopupMenuItem::new(href.clone()).on_click( - move |_, _, cx| { - cx.open_url(&href); - }, - ), - ); - } - menu - }), - ) - .child( - Button::new("link") - .icon(IconName::ExternalLink) - .tooltip("Open in gitworkshop.dev") - .secondary(), - ) - .child( - Button::new("clone") - .icon(CustomIconName::GitClone) - .tooltip("Clone") - .primary(), - ), + .child(div().flex_1()), ), ) - .child( - h_flex() + .child(match self.active_tab { + 0 => h_flex() .flex_1() .w_full() .overflow_hidden() .child(self.render_tree_column(tree_state, view, cx)) - .child(self.render_content_column(pane_title, cx)), - ) + .child(self.render_content_column(pane_title, cx)) + .into_any_element(), + _ => self.render_commits_tab(cx), + }) } } -- 2.54.0 From 54781e2ad9a88101d0cc6fb8f36220ea6767e7c2 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 12 Aug 2026 10:40:12 +0700 Subject: [PATCH 30/64] add ref viewer --- Cargo.lock | 343 ++++++++-------- crates/assets/assets/icons/git-branch.svg | 1 + crates/assets/assets/icons/tag.svg | 1 + crates/assets/src/lib.rs | 4 + crates/signed_git/src/lib.rs | 234 ++++++++++- crates/workspace/src/views/repo_detail/mod.rs | 379 +++++++++++++++++- 6 files changed, 751 insertions(+), 211 deletions(-) create mode 100644 crates/assets/assets/icons/git-branch.svg create mode 100644 crates/assets/assets/icons/tag.svg diff --git a/Cargo.lock b/Cargo.lock index ca5895e..1e79761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,9 +480,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -591,7 +591,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "y4m", ] @@ -890,9 +890,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -1244,7 +1244,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "gpui_util", "indexmap", @@ -1595,9 +1595,9 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" dependencies = [ "link-section", "linktime-proc-macro", @@ -1652,7 +1652,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1687,7 +1687,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "proc-macro2", "quote", @@ -2293,9 +2293,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2308,9 +2308,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2331,15 +2331,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2348,9 +2348,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2367,32 +2367,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2545,7 +2545,7 @@ dependencies = [ "gix-zlib", "nonempty", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2572,7 +2572,7 @@ dependencies = [ "gix-quote", "gix-trace", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-bom", ] @@ -2636,7 +2636,7 @@ dependencies = [ "gix-sec", "gix-utils", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-bom", ] @@ -2650,7 +2650,7 @@ dependencies = [ "bstr", "gix-path", "libc", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2669,7 +2669,7 @@ dependencies = [ "gix-sec", "gix-trace", "gix-url", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2693,7 +2693,7 @@ dependencies = [ "bstr", "gix-hash", "gix-object", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2708,7 +2708,7 @@ dependencies = [ "gix-path", "gix-ref", "gix-sec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2757,7 +2757,7 @@ dependencies = [ "gix-trace", "gix-utils", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2770,7 +2770,7 @@ dependencies = [ "gix-features", "gix-path", "gix-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2794,7 +2794,7 @@ dependencies = [ "faster-hex", "gix-features", "sha1-checked", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2846,7 +2846,7 @@ dependencies = [ "memmap2", "rustix 1.1.4", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2857,7 +2857,7 @@ checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848" dependencies = [ "gix-tempfile", "gix-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2890,7 +2890,7 @@ dependencies = [ "gix-validate", "itoa", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2912,7 +2912,7 @@ dependencies = [ "memmap2", "parking_lot", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2934,7 +2934,7 @@ dependencies = [ "memmap2", "parking_lot", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2946,7 +2946,7 @@ dependencies = [ "bstr", "faster-hex", "gix-trace", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2958,7 +2958,7 @@ dependencies = [ "bstr", "gix-trace", "gix-validate", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2973,7 +2973,7 @@ dependencies = [ "gix-config-value", "gix-glob", "gix-path", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2986,7 +2986,7 @@ dependencies = [ "gix-config-value", "parking_lot", "rustix 1.1.4", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3012,7 +3012,7 @@ dependencies = [ "gix-transport", "gix-utils", "nonempty", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3043,7 +3043,7 @@ dependencies = [ "gix-utils", "gix-validate", "memmap2", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3059,7 +3059,7 @@ dependencies = [ "gix-revision", "gix-validate", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3094,7 +3094,7 @@ dependencies = [ "gix-hashtable", "gix-object", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3119,7 +3119,7 @@ dependencies = [ "gix-hash", "gix-lock", "nonempty", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3134,7 +3134,7 @@ dependencies = [ "gix-pathspec", "gix-refspec", "gix-url", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3173,7 +3173,7 @@ dependencies = [ "gix-url", "parking_lot", "reqwest", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3190,7 +3190,7 @@ dependencies = [ "gix-object", "gix-revwalk", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3203,7 +3203,7 @@ dependencies = [ "gix-path", "gix-utils", "percent-encoding", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3260,7 +3260,7 @@ dependencies = [ "gix-path", "gix-worktree", "io-close", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3287,7 +3287,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", "zlib-rs", ] @@ -3375,7 +3375,7 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows 0.62.2", ] @@ -3402,7 +3402,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "accesskit", "anyhow", @@ -3468,7 +3468,7 @@ dependencies = [ "strum", "sum_tree", "taffy", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "ttf-parser", "url", @@ -3486,7 +3486,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#f96f1576ee704b48476f6782962690e0feb7985b" +source = "git+https://github.com/longbridge/gpui-component#6e3f241136ed9a60d0f38d53ac3bdf285a3a13f7" dependencies = [ "aho-corasick", "anyhow", @@ -3569,7 +3569,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#f96f1576ee704b48476f6782962690e0feb7985b" +source = "git+https://github.com/longbridge/gpui-component#6e3f241136ed9a60d0f38d53ac3bdf285a3a13f7" dependencies = [ "anyhow", "gpui", @@ -3583,7 +3583,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#f96f1576ee704b48476f6782962690e0feb7985b" +source = "git+https://github.com/longbridge/gpui-component#6e3f241136ed9a60d0f38d53ac3bdf285a3a13f7" dependencies = [ "proc-macro2", "quote", @@ -3593,7 +3593,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "accesskit", "accesskit_unix", @@ -3645,7 +3645,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "accesskit", "accesskit_macos", @@ -3694,7 +3694,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3705,7 +3705,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "console_error_panic_hook", "gpui", @@ -3718,7 +3718,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "schemars", "serde", @@ -3728,7 +3728,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "anyhow", "log", @@ -3738,7 +3738,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "anyhow", "bytemuck", @@ -3791,7 +3791,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "accesskit", "accesskit_windows", @@ -4110,7 +4110,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "anyhow", "async-compression", @@ -4130,7 +4130,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -4649,7 +4649,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -4707,9 +4707,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -4858,15 +4858,15 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" [[package]] name = "link-section" -version = "0.19.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" [[package]] name = "linktime-proc-macro" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" [[package]] name = "linux-raw-sys" @@ -5105,7 +5105,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "anyhow", "bindgen", @@ -5232,7 +5232,7 @@ dependencies = [ "once_cell", "rustc-hash 1.1.0", "spirv", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-ident", ] @@ -5566,9 +5566,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -6153,7 +6153,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "collections", "serde", @@ -6378,9 +6378,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -6463,28 +6463,6 @@ dependencies = [ "toml_edit 0.25.13+spec-1.1.0", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -6575,15 +6553,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -6607,7 +6576,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -6630,7 +6599,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -6798,7 +6767,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha 0.9.0", "simd_helpers", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "wasm-bindgen", ] @@ -6910,7 +6879,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6936,7 +6905,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "derive_refineable", ] @@ -7019,7 +6988,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "anyhow", "bytes", @@ -7349,9 +7318,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -7420,7 +7389,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "async-task", "backtrace", @@ -8042,9 +8011,9 @@ dependencies = [ [[package]] name = "stacksafe" -version = "0.1.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" +checksum = "95f9c34983ac74195c710c473db6fdf1085f64a47dbaa0090d1bea03be70da66" dependencies = [ "stacker", "stacksafe-macro", @@ -8052,13 +8021,13 @@ dependencies = [ [[package]] name = "stacksafe-macro" -version = "0.1.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" +checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1" dependencies = [ - "proc-macro-error2", + "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -8143,7 +8112,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "heapless 0.9.3", "log", @@ -8393,7 +8362,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", "windows 0.61.3", "windows-version", ] @@ -8405,7 +8374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -8442,11 +8411,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -8462,9 +8431,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -9268,7 +9237,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.19", + "thiserror 2.0.20", "utf-8", ] @@ -9492,7 +9461,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "perf", "quote", @@ -9640,9 +9609,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -9653,9 +9622,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -9663,9 +9632,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9673,9 +9642,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -9686,9 +9655,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -9799,7 +9768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] @@ -9817,9 +9786,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -9947,7 +9916,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-wasm", @@ -10036,7 +10005,7 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "wayland-sys", "web-sys", @@ -10167,7 +10136,7 @@ checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" dependencies = [ "parking_lot", "rayon", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows 0.61.3", "windows-future 0.2.1", ] @@ -10822,13 +10791,13 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcb" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", - "quick-xml 0.30.0", + "quick-xml", "x11", ] @@ -10943,9 +10912,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -11002,14 +10971,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -11038,6 +11007,15 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zed-font-kit" version = "0.14.1-zed" @@ -11266,7 +11244,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "anyhow", "chrono", @@ -11283,7 +11261,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" dependencies = [ "tracing", "tracing-subscriber", @@ -11294,7 +11272,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#38ca9106c5306ef93e52c35643df015a27f15b72" +source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" [[package]] name = "zune-core" @@ -11337,41 +11315,42 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", "serde_bytes", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.3", "winnow 1.0.4", ] diff --git a/crates/assets/assets/icons/git-branch.svg b/crates/assets/assets/icons/git-branch.svg new file mode 100644 index 0000000..d021e7f --- /dev/null +++ b/crates/assets/assets/icons/git-branch.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/assets/icons/tag.svg b/crates/assets/assets/icons/tag.svg new file mode 100644 index 0000000..429e28f --- /dev/null +++ b/crates/assets/assets/icons/tag.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index b0d1fb8..75fa882 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -53,6 +53,8 @@ pub enum CustomIconName { GlobalOn, GlobalOff, GitClone, + GitBranch, + Tag, } impl IconNamed for CustomIconName { @@ -63,6 +65,8 @@ impl IconNamed for CustomIconName { CustomIconName::GlobalOn => "icons/global-on.svg", CustomIconName::GlobalOff => "icons/global-off.svg", CustomIconName::GitClone => "icons/git-clone.svg", + CustomIconName::GitBranch => "icons/git-branch.svg", + CustomIconName::Tag => "icons/tag.svg", } .into() } diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index d9e0435..454a09c 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -309,6 +309,104 @@ pub fn worktree_all_commits(workdir: &Path) -> Result> { all_commits(&gix::open(workdir)?) } +/// Short names of local branches (`refs/heads/*`), sorted alphabetically. +pub fn worktree_branches(workdir: &Path) -> Result> { + let repo = gix::open(workdir)?; + let mut names = Vec::new(); + for reference in repo.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) +} + +/// Short names of tags (`refs/tags/*`), sorted alphabetically. +pub fn worktree_tags(workdir: &Path) -> Result> { + let repo = gix::open(workdir)?; + let mut names = Vec::new(); + for reference in repo.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) +} + +/// Short name of the branch HEAD points to, or `None` when detached (e.g. +/// after checking out a tag or a commit directly). +pub fn current_branch(repo: &gix::Repository) -> Result> { + let head = repo.head()?; + let Some(name) = head.referent_name() else { + return Ok(None); + }; + Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) +} + +/// Everything the browser needs to refresh after a branch or tag switch. +pub struct WorktreeSnapshot { + /// Relative paths of all worktree entries, directories first. + pub entries: Vec, + /// README path relative to the worktree, if any. + pub readme_path: Option, + /// Contents of the README, if any. + pub readme: Option>, + /// Branch HEAD points to (`None` when detached, e.g. on a tag). + pub current_branch: Option, +} + +/// Snapshot the worktree after a branch/tag switch: entries, README and the +/// branch HEAD points to, opening the repository once. +pub fn worktree_snapshot(workdir: &Path) -> Result { + let repo = gix::open(workdir)?; + let readme_path = find_readme(&repo)?; + let readme = match &readme_path { + Some(path) => worktree_read(&repo, path)?, + None => None, + }; + Ok(WorktreeSnapshot { + entries: worktree_entries(&repo)?, + readme_path, + readme, + current_branch: current_branch(&repo)?, + }) +} + +/// Switch the checked-out ref and update the worktree to match, like +/// `git checkout --force`. Local modifications are discarded since these +/// clones are read-only browser copies. +fn checkout(workdir: &Path, args: &[&str]) -> Result<()> { + let output = Command::new("git") + .arg("checkout") + .arg("--force") + .args(args) + .current_dir(workdir) + .output() + .context("failed to spawn `git checkout`")?; + if !output.status.success() { + bail!( + "git checkout {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +/// Check out the local branch `name`; HEAD stays attached to it. +pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { + // The short name (not `refs/heads/`) keeps HEAD attached; the + // full ref name would be treated as a commit-ish and detach it. + checkout(workdir, &[name]) +} + +/// Check out the tag `name`; HEAD becomes detached at the tagged commit, +/// which [`current_branch`] reports as `None`. +pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { + // `--detach` pins the full tag ref so HEAD always ends up detached. + checkout(workdir, &["--detach", &format!("refs/tags/{name}")]) +} + fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; @@ -433,22 +531,23 @@ mod tests { /// Stage everything and create a commit with the git CLI (like /// [`apply_patch`], the crate already shells out to the CLI). fn commit_all(repo: &gix::Repository, message: &str) { - let dir = repo.workdir().expect("workdir"); - let run = |args: &[&str]| { - let status = Command::new("git") - .current_dir(dir) - .env("GIT_AUTHOR_NAME", "Test Author") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test Author") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .env("GIT_EDITOR", "true") - .args(args) - .status() - .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); - }; - run(&["add", "-A"]); - run(&["commit", "-m", message]); + git_run(repo.workdir().expect("workdir"), &["add", "-A"]); + git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); + } + + /// Run a git command in `dir`, asserting success. + fn git_run(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); } #[test] @@ -572,4 +671,107 @@ mod tests { let (_dir, repo) = fixture(&[("main.rs", b"")]); assert!(find_readme(&repo).expect("find").is_none()); } + + #[test] + fn worktree_branches_and_tags_list_short_names() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + git_run(dir, &["checkout", "-b", "feature"]); + git_run(dir, &["tag", "v0.9"]); + git_run(dir, &["tag", "v1.0"]); + + // The initial branch name depends on git configuration; only the + // branch we created is fixed. + let branches = worktree_branches(dir).expect("branches"); + assert_eq!(branches.len(), 2); + assert!(branches.contains(&"feature".to_string())); + assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted"); + + assert_eq!( + worktree_tags(dir).expect("tags"), + vec!["v0.9".to_string(), "v1.0".to_string()] + ); + } + + #[test] + fn current_branch_tracks_checkout() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + let default = worktree_branches(dir) + .expect("branches") + .into_iter() + .next() + .expect("default branch"); + assert_eq!( + current_branch(&repo).expect("branch").as_deref(), + Some(default.as_str()) + ); + + git_run(dir, &["checkout", "-b", "feature"]); + assert_eq!( + current_branch(&repo).expect("branch").as_deref(), + Some("feature") + ); + + // Tags detach HEAD. + git_run(dir, &["tag", "v1.0"]); + worktree_checkout_tag(dir, "v1.0").expect("checkout tag"); + assert_eq!(current_branch(&repo).expect("branch"), None); + + // Branches re-attach HEAD. + worktree_checkout_branch(dir, &default).expect("checkout branch"); + assert_eq!( + current_branch(&repo).expect("branch").as_deref(), + Some(default.as_str()) + ); + } + + #[test] + fn worktree_snapshot_reflects_checked_out_ref() { + let (dir, repo) = fixture(&[("README.md", b"# main"), ("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + git_run(dir, &["checkout", "-b", "feature"]); + std::fs::write(dir.join("README.md"), b"# feature").expect("write"); + std::fs::write(dir.join("b.txt"), b"b").expect("write"); + commit_all(&repo, "feature work"); + + let snapshot = worktree_snapshot(dir).expect("snapshot"); + assert_eq!(snapshot.current_branch.as_deref(), Some("feature")); + assert_eq!( + String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), + "# feature" + ); + let entries: Vec = snapshot + .entries + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + assert!(entries.contains(&"b.txt".to_string())); + + let default = worktree_branches(dir) + .expect("branches") + .into_iter() + .find(|name| name != "feature") + .expect("default branch"); + worktree_checkout_branch(dir, &default).expect("checkout"); + + let snapshot = worktree_snapshot(dir).expect("snapshot"); + assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str())); + assert_eq!( + String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), + "# main" + ); + assert!( + !snapshot + .entries + .iter() + .any(|p| p.to_string_lossy() == "b.txt") + ); + } } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 59a6e61..3e5d130 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -6,17 +6,19 @@ use anyhow::Error; use assets::CustomIconName; use gpui::prelude::*; use gpui::{ - App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, Task, Window, div, px, size, + AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, + Render, SharedString, Size, Subscription, Task, Window, div, px, size, }; use gpui_component::button::{Button, ButtonVariants, DropdownButton}; +use gpui_component::combobox::{Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerCtx}; use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::menu::PopupMenuItem; +use gpui_component::searchable_list::SearchableVec; use gpui_component::tab::{Tab, TabBar}; use gpui_component::tag::Tag; use gpui_component::tree::TreeState; use gpui_component::{ - ActiveTheme, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, + ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, }; use signed_core::Announcement; use signed_git::FileCommit; @@ -30,6 +32,15 @@ use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView}; use commits::COMMIT_ROW_HEIGHT; use helpers::{build_tree_items, is_markdown_path}; +/// What kind of ref the header selectors switch to. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RefKind { + /// A local branch (`refs/heads/*`); HEAD stays attached. + Branch, + /// A tag (`refs/tags/*`); HEAD becomes detached. + Tag, +} + /// Detail view of a repository: header, stats, a file explorer with README /// preview (cloned from the announcement's `clone` URLs), and metadata. pub struct RepoDetailView { @@ -68,6 +79,17 @@ pub struct RepoDetailView { /// A clone/fetch is in flight. loading: bool, error: Option, + /// Branch selector (header): local branches, searchable. + branch_select: Entity>>, + /// Tag selector (header): tags, searchable. + tag_select: Entity>>, + /// A branch/tag switch is in flight (checkout plus explorer reload). + switching_ref: bool, + /// Bumped on every branch/tag switch; in-flight loads tagged with an + /// older generation are discarded when they complete. + ref_generation: u64, + /// Subscriptions keeping the selectors' confirm events alive. + _subscriptions: Vec, focus_handle: FocusHandle, tasks: Vec>>, } @@ -77,9 +99,49 @@ impl RepoDetailView { let store = cx.new(|cx| RepoStore::new(initial.addr(), cx)); let tree_state = cx.new(|cx| TreeState::new(cx)); + // Empty until the clone completes; populated with the local refs. + let branch_select: Entity>> = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + let tag_select: Entity>> = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + + let subscriptions = vec![ + cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { + // `Change` fires only when the selection actually changed + // (picking the already-selected branch emits nothing), so a + // confirmed value always means a switch. + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Branch, name.clone(), window, cx); + } + }), + cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| { + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Tag, name.clone(), window, cx); + } + }), + ]; + // Defer loading the repository until the window is ready. - cx.defer_in(window, |this, _window, cx| { - this.load_repo(cx); + cx.defer_in(window, |this, window, cx| { + this.load_repo(window, cx); }); Self { @@ -102,13 +164,18 @@ impl RepoDetailView { item_sizes: Rc::new(Vec::new()), loading: true, error: None, + branch_select, + tag_select, + switching_ref: false, + ref_generation: 0, + _subscriptions: subscriptions, focus_handle: cx.focus_handle(), tasks: Vec::new(), } } /// Clone (or fetch) the repository and populate the file explorer. - fn load_repo(&mut self, cx: &mut Context) { + fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; cx.notify(); @@ -126,20 +193,63 @@ impl RepoDetailView { None => None, }; let worktree = repo.workdir().map(Path::to_path_buf); + // Ref listing is auxiliary UI: a broken ref must not prevent the + // explorer from loading, so failures degrade to empty selectors. + let (branches, tags, current_branch) = match &worktree { + Some(worktree) => ( + signed_git::worktree_branches(worktree).unwrap_or_default(), + signed_git::worktree_tags(worktree).unwrap_or_default(), + signed_git::current_branch(&repo).unwrap_or(None), + ), + None => (Vec::new(), Vec::new(), None), + }; - Ok::<_, Error>((entries, readme_path, readme, worktree)) + Ok::<_, Error>(( + entries, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + )) }); - let task = cx.spawn(async move |this, cx| { + let task = cx.spawn_in(window, async move |this, cx| { let result = load.await; - this.update(cx, |this, cx| { + this.update_in(cx, |this, window, cx| { match result { - Ok((entries, readme_path, readme, Some(worktree))) => { + Ok(( + entries, + readme_path, + readme, + Some(worktree), + branches, + tags, + current_branch, + )) => { this.worktree = Some(worktree); this.tree_state.update(cx, |state, cx| { state.set_items(build_tree_items(&entries), cx); }); + + // Populate the branch/tag selectors with the local + // refs, selecting the branch HEAD points to. + let branches: Vec = + branches.into_iter().map(Into::into).collect(); + let tags: Vec = tags.into_iter().map(Into::into).collect(); + this.branch_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(branches), window, cx); + if let Some(branch) = current_branch { + let branch: SharedString = branch.into(); + state.set_selected_values(&[branch], window, cx); + } + }); + this.tag_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(tags), window, cx); + }); + this.load_all_commits(cx); if let Some((path, bytes)) = readme_path.zip(readme) { this.readme_name = Some(path.to_string_lossy().into()); @@ -149,7 +259,7 @@ impl RepoDetailView { } } } - Ok((_, _, _, None)) => { + Ok((_, _, _, None, _, _, _)) => { this.error = Some("Repository has no worktree".into()); } Err(error) => { @@ -197,6 +307,7 @@ impl RepoDetailView { self.loading_files.insert(path.to_string()); let path = path.to_string(); self.load_commit(&path, cx); + let generation = self.ref_generation; let task = cx.spawn(async move |this, cx| { let path_for_read = path.clone(); @@ -221,6 +332,11 @@ impl RepoDetailView { .await; this.update(cx, |this, cx| { + // The worktree was switched while this file was reading; + // the result belongs to the previous branch. + if generation != this.ref_generation { + return; + } this.loading_files.remove(&path); match content { Ok(kind) => { @@ -267,6 +383,7 @@ impl RepoDetailView { self.loading_commits.insert(path.to_string()); let path = path.to_string(); + let generation = self.ref_generation; let task = cx.spawn(async move |this, cx| { let path_for_query = path.clone(); @@ -277,6 +394,9 @@ impl RepoDetailView { .await; this.update(cx, |this, cx| { + if generation != this.ref_generation { + return; + } this.loading_commits.remove(&path); if let Ok(Some(commit)) = result { this.commits.insert(path, commit); @@ -302,6 +422,7 @@ impl RepoDetailView { }; self.loading_all_commits = true; + let generation = self.ref_generation; let task = cx.spawn(async move |this, cx| { let result = cx @@ -309,6 +430,9 @@ impl RepoDetailView { .await; this.update(cx, |this, cx| { + if generation != this.ref_generation { + return; + } if let Ok(commits) = result { let count = commits.len(); this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); @@ -323,6 +447,192 @@ impl RepoDetailView { self.tasks.push(task); } + + /// Check out `name` (a branch or tag picked in the header) and refresh + /// the explorer once the switch completes. + fn switch_ref( + &mut self, + kind: RefKind, + name: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + if self.switching_ref { + return; + } + let Some(worktree) = self.worktree.clone() else { + return; + }; + + // Branches and tags are mutually exclusive states of HEAD: selecting + // one clears the other selector. Remember the previous selections so + // they can be restored if the checkout fails. + let previous_branch = self.branch_select.read(cx).selected_value(); + let previous_tag = self.tag_select.read(cx).selected_value(); + + match kind { + RefKind::Branch => { + self.tag_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + RefKind::Tag => { + self.branch_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + } + self.switching_ref = true; + // In-flight loads of the previous branch are discarded when they + // complete. + self.ref_generation += 1; + cx.notify(); + + let checkout_name = name.clone(); + let task = cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn(async move { + match kind { + RefKind::Branch => { + signed_git::worktree_checkout_branch(&worktree, &checkout_name) + } + RefKind::Tag => { + signed_git::worktree_checkout_tag(&worktree, &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.switching_ref = false; + this.restore_selection(&this.branch_select, &previous_branch, window, cx); + this.restore_selection(&this.tag_select, &previous_tag, window, cx); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Restore a selector to `previous`, or clear it (after a failed switch). + fn restore_selection( + &self, + select: &Entity>>, + previous: &Option, + window: &mut Window, + cx: &mut Context, + ) { + select.update(cx, |state, cx| match previous { + Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), + None => state.clear_selection(cx), + }); + } + + /// Trigger body for the branch/tag selectors: the kind icon, the + /// selection (or placeholder) and the caret. `Combobox` replaces its + /// default trigger entirely, which is the only way to show an icon + /// inside the trigger label. + fn render_ref_trigger( + ctx: &ComboboxTriggerCtx>, + icon: CustomIconName, + cx: &App, + ) -> AnyElement { + let muted = cx.theme().muted_foreground; + + h_flex() + .w_full() + .min_w_0() + .gap_1() + .items_center() + .child(Icon::new(icon).small().flex_shrink_0()) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .when(ctx.selection.is_empty(), |this| this.text_color(muted)) + .child( + ctx.selection + .first() + .map(|(_, item)| item.clone()) + .or_else(|| ctx.placeholder.cloned()) + .unwrap_or_default(), + ), + ) + .child(Caret::new(ctx.size).text_color(muted)) + .into_any_element() + } + + /// Refresh the file explorer, preview pane and commit list after a + /// successful branch or tag switch. The selectors were already updated + /// by [`Self::switch_ref`]; [`Self::switching_ref`] stays set until this + /// reload finishes, so a second switch cannot interleave. + fn reload_worktree(&mut self, cx: &mut Context) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let task = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { signed_git::worktree_snapshot(&worktree) }) + .await; + + this.update(cx, |this, cx| { + this.switching_ref = false; + match result { + Ok(snapshot) => { + // Rebuild the tree from scratch: entries of the + // previous branch are gone, and with them the + // expansion state. + this.tree_state.update(cx, |state, cx| { + state.set_items(build_tree_items(&snapshot.entries), cx); + }); + + // Drop cached previews and commits of the old branch. + this.selected_file = None; + this.files.clear(); + this.loading_files.clear(); + this.commits.clear(); + this.loading_commits.clear(); + this.md = None; + this.code = None; + this.readme_name = None; + this.all_commits = None; + this.loading_all_commits = false; + + if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) { + this.readme_name = Some(path.to_string_lossy().into()); + this.load_commit(&path.to_string_lossy(), cx); + if let Ok(text) = String::from_utf8(bytes) { + this.set_markdown(None, &text, cx); + } + } + this.load_all_commits(cx); + } + Err(error) => { + this.error = Some(error.to_string().into()); + // The tree may show files that no longer exist. + this.tree_state.update(cx, |state, cx| { + state.set_items(Vec::new(), cx); + }); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } } impl Panel for RepoDetailView { @@ -384,6 +694,7 @@ impl Render for RepoDetailView { let relays = announcement.relays.clone(); let web = announcement.web.clone(); let commits_count = self.all_commits.as_ref().map(Vec::len); + let worktree_empty = self.switching_ref || self.worktree.is_none(); v_flex() .id("repo") @@ -394,7 +705,7 @@ impl Render for RepoDetailView { .pt_2() .pb_2() .w_full() - .gap_4() + .gap_8() .border_b_1() .border_color(cx.theme().border) .child( @@ -497,6 +808,7 @@ impl Render for RepoDetailView { ) .child( h_flex() + .items_center() .child( TabBar::new("repo-tabs") .segmented() @@ -518,7 +830,48 @@ impl Render for RepoDetailView { cx.notify(); })), ) - .child(div().flex_1()), + .child( + h_flex() + .flex_1() + .gap_2() + .justify_end() + .child( + div().w(px(120.)).child( + Combobox::new(&self.branch_select) + .placeholder("Branch") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + Self::render_ref_trigger( + ctx, + CustomIconName::GitBranch, + cx, + ) + }), + ), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.tag_select) + .placeholder("Tag") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + Self::render_ref_trigger( + ctx, + CustomIconName::Tag, + cx, + ) + }), + ), + ), + ), ), ) .child(match self.active_tab { -- 2.54.0 From 447888e2fbd23033cb4a31dc94fd0b8ff3b49a1e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 12 Aug 2026 11:12:09 +0700 Subject: [PATCH 31/64] . --- crates/signed_git/src/lib.rs | 49 ++++++++++++++++++- crates/workspace/src/views/repo_detail/mod.rs | 39 ++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 454a09c..17c16f7 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -309,6 +309,23 @@ pub fn worktree_all_commits(workdir: &Path) -> Result> { all_commits(&gix::open(workdir)?) } +/// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a +/// repository without commits yet (unborn HEAD). +pub fn head_commit(repo: &gix::Repository) -> Result> { + let Some(head) = repo.head_id().ok() else { + return Ok(None); + }; + let commit = head.object()?.into_commit(); + let author = commit.author()?; + let message = commit.message()?; + Ok(Some(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + })) +} + /// Short names of local branches (`refs/heads/*`), sorted alphabetically. pub fn worktree_branches(workdir: &Path) -> Result> { let repo = gix::open(workdir)?; @@ -353,10 +370,12 @@ pub struct WorktreeSnapshot { pub readme: Option>, /// Branch HEAD points to (`None` when detached, e.g. on a tag). pub current_branch: Option, + /// Commit HEAD points to, if any (see [`head_commit`]). + pub head_commit: Option, } -/// Snapshot the worktree after a branch/tag switch: entries, README and the -/// branch HEAD points to, opening the repository once. +/// Snapshot the worktree after a branch/tag switch: entries, README, the +/// branch HEAD points to and its commit, opening the repository once. pub fn worktree_snapshot(workdir: &Path) -> Result { let repo = gix::open(workdir)?; let readme_path = find_readme(&repo)?; @@ -369,6 +388,7 @@ pub fn worktree_snapshot(workdir: &Path) -> Result { readme_path, readme, current_branch: current_branch(&repo)?, + head_commit: head_commit(&repo)?, }) } @@ -672,6 +692,23 @@ mod tests { assert!(find_readme(&repo).expect("find").is_none()); } + #[test] + fn head_commit_reports_head() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + + // Unborn HEAD: no commit yet. + assert!(head_commit(&repo).expect("head").is_none()); + + commit_all(&repo, "initial"); + let head = head_commit(&repo).expect("head").expect("commit"); + assert_eq!( + head.id, + repo.head_id().expect("head id").shorten_or_id().to_string() + ); + assert_eq!(head.summary, "initial"); + assert_eq!(head.author, "Test Author"); + } + #[test] fn worktree_branches_and_tags_list_short_names() { let (dir, repo) = fixture(&[("a.txt", b"one")]); @@ -743,6 +780,10 @@ mod tests { let snapshot = worktree_snapshot(dir).expect("snapshot"); assert_eq!(snapshot.current_branch.as_deref(), Some("feature")); + assert_eq!( + snapshot.head_commit.as_ref().expect("head commit").summary, + "feature work" + ); assert_eq!( String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), "# feature" @@ -763,6 +804,10 @@ mod tests { let snapshot = worktree_snapshot(dir).expect("snapshot"); assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str())); + assert_eq!( + snapshot.head_commit.as_ref().expect("head commit").summary, + "initial" + ); assert_eq!( String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), "# main" diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 3e5d130..038e98a 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -79,6 +79,8 @@ pub struct RepoDetailView { /// A clone/fetch is in flight. loading: bool, error: Option, + /// Commit HEAD currently points to, shown in the header button. + head_commit: Option, /// Branch selector (header): local branches, searchable. branch_select: Entity>>, /// Tag selector (header): tags, searchable. @@ -164,6 +166,7 @@ impl RepoDetailView { item_sizes: Rc::new(Vec::new()), loading: true, error: None, + head_commit: None, branch_select, tag_select, switching_ref: false, @@ -203,6 +206,7 @@ impl RepoDetailView { ), None => (Vec::new(), Vec::new(), None), }; + let head_commit = signed_git::head_commit(&repo).unwrap_or(None); Ok::<_, Error>(( entries, @@ -212,6 +216,7 @@ impl RepoDetailView { branches, tags, current_branch, + head_commit, )) }); @@ -228,8 +233,10 @@ impl RepoDetailView { branches, tags, current_branch, + head_commit, )) => { this.worktree = Some(worktree); + this.head_commit = head_commit; this.tree_state.update(cx, |state, cx| { state.set_items(build_tree_items(&entries), cx); }); @@ -259,7 +266,7 @@ impl RepoDetailView { } } } - Ok((_, _, _, None, _, _, _)) => { + Ok((_, _, _, None, _, _, _, _)) => { this.error = Some("Repository has no worktree".into()); } Err(error) => { @@ -589,6 +596,7 @@ impl RepoDetailView { this.switching_ref = false; match result { Ok(snapshot) => { + this.head_commit = snapshot.head_commit; // Rebuild the tree from scratch: entries of the // previous branch are gone, and with them the // expansion state. @@ -619,6 +627,7 @@ impl RepoDetailView { } Err(error) => { this.error = Some(error.to_string().into()); + this.head_commit = None; // The tree may show files that no longer exist. this.tree_state.update(cx, |state, cx| { state.set_items(Vec::new(), cx); @@ -870,6 +879,34 @@ impl Render for RepoDetailView { ) }), ), + ) + .child( + Button::new("enc") + .secondary() + .when_some(self.head_commit.as_ref(), |this, commit| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&commit.id)), + ) + .child( + div() + .max_w(px(200.)) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_xs() + .child(SharedString::from(&commit.summary)), + ) + }) + .tooltip( + self.head_commit + .as_ref() + .map_or_else(SharedString::default, |commit| { + commit.summary.clone().into() + }), + ), ), ), ), -- 2.54.0 From 650afad6ba28d13bcdfba92cf883732a6bc60b7f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 12 Aug 2026 20:41:40 +0700 Subject: [PATCH 32/64] add code editor --- .../src/views/repo_detail/browser.rs | 80 +++++++++---------- .../src/views/repo_detail/helpers.rs | 48 ----------- crates/workspace/src/views/repo_detail/mod.rs | 8 +- 3 files changed, 41 insertions(+), 95 deletions(-) diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index f40e981..5429e67 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -1,11 +1,12 @@ //! File explorer of the repository detail view: the file tree column and the //! content column (README / file preview), backed by persistent -//! [`TextViewState`]s for markdown documents and code files. +//! [`TextViewState`]s for markdown documents and persistent [`InputState`]s +//! for code files. use gpui::prelude::*; -use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, div, px}; +use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::clipboard::Clipboard; +use gpui_component::input::{Input, InputState}; use gpui_component::list::ListItem; use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; @@ -13,7 +14,7 @@ use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use super::RepoDetailView; -use super::helpers::{code_language, fenced_code, is_markdown_path, placeholder}; +use super::helpers::{code_language, is_markdown_path, placeholder}; /// Width of the file explorer column. const TREE_WIDTH: f32 = 240.; @@ -46,16 +47,17 @@ pub(super) struct MarkdownView { pub(super) state: Entity, } -/// A code file loaded into a persistent [`TextViewState`], rendered as a -/// fenced code block so the markdown parser syntax-highlights it. +/// A code file loaded into a persistent [`InputState`], rendered as a +/// disabled (read-only) code editor with syntax highlighting, line numbers +/// and search. /// /// Same persistence rationale as [`MarkdownView`]: the state lives as long /// as this view, so re-viewing the same file does not re-parse it, and -/// parsing happens on a background task. +/// parsing happens on a background task inside the editor. pub(super) struct CodeView { /// Source path, relative to the worktree root. pub(super) path: SharedString, - pub(super) state: Entity, + pub(super) state: Entity, } /// Spinner shown while a document is being loaded/parsed. @@ -298,22 +300,31 @@ impl RepoDetailView { .into_any_element() } - /// Load `text` into the persistent code TextView state for `path`. + /// Load `text` into the persistent code editor state for `path`. /// - /// The code is wrapped in a markdown fence (see [`fenced_code`]) so the - /// TextView renders it as a syntax-highlighted code block. Like - /// [`set_markdown`], the state is created empty and fed via `push_str` - /// so parsing happens on a background task instead of blocking the main - /// thread. - pub(super) fn set_code(&mut self, path: SharedString, text: &str, cx: &mut Context) { - let source = fenced_code(text, code_language(path.as_ref())); - let state = cx.new(|cx| TextViewState::markdown("", cx)); - state.update(cx, |state, cx| state.push_str(&source, cx)); + /// The state is created in code editor mode so the Input renders it as + /// a syntax-highlighted, read-only editor. Like [`set_markdown`], the + /// state lives as long as this view, so re-viewing the same file does + /// not re-parse it; the tree-sitter parse runs on a background task + /// inside the editor instead of blocking the main thread. + pub(super) fn set_code( + &mut self, + path: SharedString, + text: &str, + window: &mut Window, + cx: &mut Context, + ) { + let language = code_language(path.as_ref()).unwrap_or("text"); + let state = cx.new(|cx| { + InputState::new(window, cx) + .code_editor(language) + .default_value(text) + }); self.code = Some(CodeView { path, state }); } - /// The persistent code TextView for `path`, or a spinner while the file - /// is being loaded/parsed. + /// The persistent code editor for `path`, or a spinner while the file is + /// being loaded/parsed. fn code_element(&mut self, path: &str, _cx: &mut Context) -> AnyElement { let Some(code) = &self.code else { return preview_spinner(); @@ -322,29 +333,12 @@ impl RepoDetailView { return preview_spinner(); } - TextView::new(&code.state) - .selectable(true) - .scrollable(true) - .p_4() - .code_block_actions(|code_block, _window, cx| { - let lang = code_block.lang().unwrap_or_default(); - h_flex() - .gap_2() - .items_center() - .when(!lang.is_empty(), |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(lang.clone()), - ) - }) - .child( - Clipboard::new(format!("copy-code-{lang}")) - .value(code_block.code()) - .tooltip("Copy code"), - ) - }) + // Disabled: the preview is read-only. Selection, copy and the + // built-in search (Cmd/Ctrl+F) still work; editing is blocked by the + // component's disabled state. + Input::new(&code.state) + .disabled(true) + .h_full() .into_any_element() } } diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index ade4ac0..cf6b7f4 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -100,32 +100,6 @@ pub(super) fn code_language(path: &str) -> Option<&'static str> { }) } -/// Wrap `code` in a fenced markdown code block tagged with `lang`, so the -/// markdown [`TextViewState`] renders it as a syntax-highlighted code block. -/// -/// The fence is one backtick longer than the longest run of backticks in -/// `code`, so the content can never close the block early. -pub(super) fn fenced_code(code: &str, lang: Option<&str>) -> String { - // Split on non-backtick characters so the segments are runs of backticks. - let longest_run = code.split(|c| c != '`').map(str::len).max().unwrap_or(0); - let fence = "`".repeat((longest_run + 1).max(3)); - - let mut out = - String::with_capacity(code.len() + fence.len() * 2 + lang.map_or(1, |lang| lang.len() + 2)); - out.push_str(&fence); - if let Some(lang) = lang { - out.push(' '); - out.push_str(lang); - } - out.push('\n'); - out.push_str(code); - if !code.ends_with('\n') { - out.push('\n'); - } - out.push_str(&fence); - out -} - /// Whether a file path has a markdown extension. pub(super) fn is_markdown_path(path: &str) -> bool { Path::new(path) @@ -200,28 +174,6 @@ mod tests { assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); } - #[test] - fn fenced_code_wraps_in_triple_backticks() { - let out = fenced_code("fn main() {}\n", Some("rust")); - assert_eq!(out, "``` rust\nfn main() {}\n```"); - } - - #[test] - fn fenced_code_uses_longer_fence_than_content() { - let code = "let x = \"```\";\n`code`"; - let out = fenced_code(code, None); - // The longest run of backticks in `code` is 3, so the fence is 4. - assert!(out.starts_with("````\n")); - assert!(out.ends_with("````")); - assert!(out.contains(code)); - } - - #[test] - fn fenced_code_keeps_trailing_newline() { - assert_eq!(fenced_code("a\n", None), "```\na\n```"); - assert_eq!(fenced_code("a", None), "```\na\n```"); - } - #[test] fn code_language_maps_extensions_and_names() { assert_eq!(code_language("src/main.rs"), Some("rust")); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 038e98a..0912548 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -284,7 +284,7 @@ impl RepoDetailView { } /// Preview the file at `path` (relative to the worktree root). - fn open_file(&mut self, path: &str, _window: &mut Window, cx: &mut Context) { + 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) || self.loading_files.contains(path) { @@ -316,7 +316,7 @@ impl RepoDetailView { self.load_commit(&path, cx); let generation = self.ref_generation; - let task = cx.spawn(async move |this, cx| { + let task = cx.spawn_in(window, async move |this, cx| { let path_for_read = path.clone(); let content = cx .background_spawn(async move { @@ -338,7 +338,7 @@ impl RepoDetailView { }) .await; - this.update(cx, |this, cx| { + this.update_in(cx, |this, window, cx| { // The worktree was switched while this file was reading; // the result belongs to the previous branch. if generation != this.ref_generation { @@ -358,7 +358,7 @@ impl RepoDetailView { 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, cx); + this.set_code(path.clone().into(), text, window, cx); } } } -- 2.54.0 From 9b1dd526a56334ba202869e89e7ee8e7ebc8cdd2 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 13 Aug 2026 10:15:23 +0700 Subject: [PATCH 33/64] . --- crates/signed_git/src/lib.rs | 198 +++++++++--- crates/signed_state/src/repo.rs | 2 + .../src/views/repo_detail/browser.rs | 4 + .../src/views/repo_detail/commits.rs | 33 +- .../src/views/repo_detail/helpers.rs | 133 ++++++-- crates/workspace/src/views/repo_detail/mod.rs | 295 +++++++++++++----- 6 files changed, 509 insertions(+), 156 deletions(-) diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 17c16f7..b2cd14c 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -2,6 +2,7 @@ //! //! All functions may block; call them inside `cx.background_spawn`. +use std::collections::HashSet; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -140,6 +141,11 @@ fn sanitize_path_component(id: &str) -> String { sanitized } +/// In-memory object cache for history walks (see [`open_with_cache`]). +/// Without one, every walk re-decodes the same commit objects from the +/// object database. +const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; + /// Metadata of a commit, as shown in the repository browser's file header. #[derive(Debug, Clone)] pub struct FileCommit { @@ -222,6 +228,26 @@ pub fn find_readme(repo: &gix::Repository) -> Result> { .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) } +/// Open the repository at `workdir` with an in-memory object cache sized for +/// history walks. +fn open_with_cache(workdir: &Path) -> Result { + let mut repo = gix::open(workdir)?; + repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); + Ok(repo) +} + +/// A [`FileCommit`] from a walk commit: author, message title and shortened id. +fn file_commit(commit: &gix::Commit<'_>) -> Result { + let author = commit.author()?; + let message = commit.message()?; + Ok(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + }) +} + /// Find the most recent commit that changed `rel` (a path relative to the /// worktree), like `git log -1 -- ` does for non-merge commits. /// @@ -230,56 +256,110 @@ pub fn find_readme(repo: &gix::Repository) -> Result> { /// changed the file through its second parent is therefore not reported. /// Returns `Ok(None)` if no commit touched the file (e.g. untracked files). pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result> { + let rel = rel.to_path_buf(); + Ok(last_commits(repo, std::slice::from_ref(&rel))? + .into_iter() + .next() + .map(|(_, commit)| commit)) +} + +/// Newest commit touching each of `rels` (relative to the worktree), like +/// `git log -1 -- ` per path, found in a single history walk: every +/// commit is decoded once and shared across all paths. Paths without any +/// commit (e.g. untracked files) are absent from the result. +pub fn worktree_last_commits( + workdir: &Path, + rels: &[PathBuf], +) -> Result> { + last_commits(&open_with_cache(workdir)?, rels) +} + +/// The walk behind [`last_commit`] and [`worktree_last_commits`], stopping as +/// soon as every pending path has its commit. +fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result> { use gix::traverse::commit::simple::CommitTimeOrder; - let head = repo.head_id()?; + let Some(head) = repo.head_id().ok() else { + return Ok(Vec::new()); + }; + + // De-duplicate while preserving order. + let mut pending: Vec = Vec::with_capacity(rels.len()); + let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); + for rel in rels { + if seen.insert(rel.as_path()) { + pending.push(rel.clone()); + } + } + let walk = repo .rev_walk([head]) .sorting(gix::revision::walk::Sorting::ByCommitTime( CommitTimeOrder::NewestFirst, )); + let mut found = Vec::new(); for info in walk.all()? { + if pending.is_empty() { + break; + } let info = info?; let commit = info.object()?; - let blob = commit.tree()?.lookup_entry_by_path(rel)?; - let parent_blob = match info.parent_ids().next() { - Some(parent) => parent - .object()? - .into_commit() - .tree()? - .lookup_entry_by_path(rel)?, + let tree = commit.tree()?; + let parent_tree = match info.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), None => None, }; - if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) { - let author = commit.author()?; - let message = commit.message()?; - return Ok(Some(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time()?.seconds, - })); + // Compare each still-unresolved path against this commit and its + // first parent; resolved paths leave the pending set. + let mut ix = 0; + while ix < pending.len() { + let rel = &pending[ix]; + let blob = tree.lookup_entry_by_path(rel)?; + let parent_blob = match &parent_tree { + Some(tree) => tree.lookup_entry_by_path(rel)?, + None => None, + }; + + if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) + { + found.push((rel.clone(), file_commit(&commit)?)); + pending.swap_remove(ix); + } else { + ix += 1; + } } } - Ok(None) + Ok(found) } -/// Like [`last_commit`], but opens the repository located at `workdir` -/// (for non-bare clones the clone root is the worktree) first. -pub fn worktree_last_commit(workdir: &Path, rel: &Path) -> Result> { - last_commit(&gix::open(workdir)?, rel) +/// Cap on [`CommitList::commits`]: the virtual list renders a window at a +/// time and the tab badge shows the real count, so a huge history is never +/// fully materialized in memory. +pub const MAX_LISTED_COMMITS: usize = 20_000; + +/// Commits reachable from `HEAD`, newest first, possibly capped: `commits` +/// holds at most [`MAX_LISTED_COMMITS`] entries and `total` is the real +/// count (for the tab badge). +pub struct CommitList { + /// Number of commits reachable from HEAD. + pub total: usize, + /// Newest commits, capped at [`MAX_LISTED_COMMITS`]. + pub commits: Vec, } /// All commits reachable from `HEAD`, newest first, with author and summary. -/// Returns `Ok(vec![])` for a repository without any commits yet. -pub fn all_commits(repo: &gix::Repository) -> Result> { +/// Returns an empty list for a repository without any commits yet. +pub fn all_commits(repo: &gix::Repository) -> Result { use gix::traverse::commit::simple::CommitTimeOrder; let Some(head) = repo.head_id().ok() else { - return Ok(Vec::new()); + return Ok(CommitList { + total: 0, + commits: Vec::new(), + }); }; let walk = repo .rev_walk([head]) @@ -288,25 +368,21 @@ pub fn all_commits(repo: &gix::Repository) -> Result> { )); let mut commits = Vec::new(); + let mut total = 0; for info in walk.all()? { let info = info?; - let commit = info.object()?; - let author = commit.author()?; - let message = commit.message()?; - commits.push(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time()?.seconds, - }); + total += 1; + if commits.len() < MAX_LISTED_COMMITS { + commits.push(file_commit(&info.object()?)?); + } } - Ok(commits) + Ok(CommitList { total, commits }) } /// Like [`all_commits`], but opens the repository located at `workdir` /// (for non-bare clones the clone root is the worktree) first. -pub fn worktree_all_commits(workdir: &Path) -> Result> { - all_commits(&gix::open(workdir)?) +pub fn worktree_all_commits(workdir: &Path) -> Result { + all_commits(&open_with_cache(workdir)?) } /// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a @@ -328,7 +404,7 @@ pub fn head_commit(repo: &gix::Repository) -> Result> { /// Short names of local branches (`refs/heads/*`), sorted alphabetically. pub fn worktree_branches(workdir: &Path) -> Result> { - let repo = gix::open(workdir)?; + let repo = open_with_cache(workdir)?; let mut names = Vec::new(); for reference in repo.references()?.local_branches()? { let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; @@ -340,7 +416,7 @@ pub fn worktree_branches(workdir: &Path) -> Result> { /// Short names of tags (`refs/tags/*`), sorted alphabetically. pub fn worktree_tags(workdir: &Path) -> Result> { - let repo = gix::open(workdir)?; + let repo = open_with_cache(workdir)?; let mut names = Vec::new(); for reference in repo.references()?.tags()? { let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; @@ -377,7 +453,7 @@ pub struct WorktreeSnapshot { /// Snapshot the worktree after a branch/tag switch: entries, README, the /// branch HEAD points to and its commit, opening the repository once. pub fn worktree_snapshot(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; + let repo = open_with_cache(workdir)?; let readme_path = find_readme(&repo)?; let readme = match &readme_path { Some(path) => worktree_read(&repo, path)?, @@ -448,6 +524,8 @@ fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> R #[cfg(test)] mod tests { + use std::collections::HashMap; + use nostr::prelude::*; use signed_core::repo_addr; @@ -601,12 +679,13 @@ mod tests { std::fs::write(dir.path().join("b.txt"), b"b").expect("write"); commit_all(&repo, "third"); - let commits = all_commits(&repo).expect("commits"); - let mut summaries: Vec<&str> = commits.iter().map(|c| c.summary.as_str()).collect(); + let list = all_commits(&repo).expect("commits"); + assert_eq!(list.total, 3); + let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect(); summaries.sort(); assert_eq!(summaries, vec!["initial", "second", "third"]); assert!( - commits + list.commits .iter() .all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0) ); @@ -616,7 +695,9 @@ mod tests { fn all_commits_returns_empty_without_head() { let (_dir, repo) = fixture(&[("a.txt", b"one")]); - assert!(all_commits(&repo).expect("commits").is_empty()); + let list = all_commits(&repo).expect("commits"); + assert!(list.commits.is_empty()); + assert_eq!(list.total, 0); } #[test] @@ -664,6 +745,35 @@ mod tests { assert!(commit.summary.starts_with("Merge branch")); } + #[test] + fn last_commits_batches_multiple_paths() { + let (dir, repo) = fixture(&[("a.txt", b"one"), ("b.txt", b"b")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); + commit_all(&repo, "change a"); + std::fs::write(dir.path().join("b.txt"), b"bb").expect("write"); + commit_all(&repo, "change b"); + + let found = worktree_last_commits( + dir.path(), + &[ + PathBuf::from("a.txt"), + PathBuf::from("b.txt"), + // Untracked paths are simply absent from the result. + PathBuf::from("missing.txt"), + ], + ) + .expect("commits"); + let by_path: HashMap<&Path, &FileCommit> = found + .iter() + .map(|(path, commit)| (path.as_path(), commit)) + .collect(); + assert_eq!(by_path.len(), 2); + assert_eq!(by_path[Path::new("a.txt")].summary, "change a"); + assert_eq!(by_path[Path::new("b.txt")].summary, "change b"); + } + #[test] fn find_readme_prefers_markdown() { let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index baecad4..0202456 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -135,6 +135,7 @@ impl RepoStore { }) }); + self.tasks.retain(|task| !task.is_ready()); self.tasks.push(task); } @@ -201,6 +202,7 @@ impl RepoStore { )) }); + self.tasks.retain(|task| !task.is_ready()); self.tasks.push(cx.spawn(async move |this, cx| { let (announcement, state, issues, patches, pull_requests, statuses) = match work.await { Ok(data) => data, diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 5429e67..2df4c47 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -20,6 +20,10 @@ use super::helpers::{code_language, is_markdown_path, placeholder}; const TREE_WIDTH: f32 = 240.; /// Files larger than this are not previewed. pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; +/// Preview cache caps: at most this many files (or this many text bytes) +/// are kept in memory at once; the oldest previews are evicted beyond that. +pub(super) const MAX_PREVIEWED_FILES: usize = 32; +pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024; /// Preview state of a browsed file. pub(super) enum FileContent { diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index 89d83d3..c1b051f 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -72,7 +72,7 @@ impl RepoDetailView { /// Full-height body of the Commits tab: all commits in a virtual /// list, or a status message while loading / when there are none. pub(super) fn render_commits_tab(&mut self, cx: &mut Context) -> AnyElement { - let Some(commits) = self.all_commits.clone() else { + let Some(list) = self.all_commits.as_ref() else { return if self.loading_all_commits { v_flex() .size_full() @@ -85,13 +85,18 @@ impl RepoDetailView { }; }; - if commits.is_empty() { + if list.commits.is_empty() { return placeholder("No commits found", cx); } + // Copy only the values the element tree needs; the list itself is + // borrowed inside the renderer below instead of being cloned per + // frame (a full history can be tens of thousands of commits). let view = cx.entity().clone(); let sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); + let shown = list.commits.len(); + let total = list.total; v_flex() .relative() @@ -103,17 +108,29 @@ impl RepoDetailView { view, "repo-commits", sizes, - move |_this, range, _window, cx| { - let mut rows = Vec::with_capacity(range.len()); - for ix in range { - rows.push(commit_row(ix, &commits[ix], cx)); - } - rows + move |this, range, _window, cx| { + let commits = this + .all_commits + .as_ref() + .map(|list| list.commits.as_slice()) + .unwrap_or(&[]); + range.map(|ix| commit_row(ix, &commits[ix], cx)).collect() }, ) .track_scroll(&scroll_handle) .size_full(), ) + .when(shown < total, |this| { + // The history is capped; tell the user the list is truncated. + this.child( + div() + .py_2() + .w_full() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("Showing {shown} of {total} commits")), + ) + }) .child( div() .absolute() diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index cf6b7f4..93b6a6e 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -1,6 +1,7 @@ //! Pure helpers for the repository detail view: file-tree building, code //! preview helpers and small element builders. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use gpui::prelude::*; @@ -8,43 +9,75 @@ use gpui::{AnyElement, App, div}; use gpui_component::tree::TreeItem; use gpui_component::{ActiveTheme, v_flex}; -/// Build nested tree items from a flat, sorted (dirs-first) entry list. -pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { - let mut roots: Vec = Vec::new(); - - for entry in entries { - let parts: Vec = entry - .components() - .map(|c| c.as_os_str().to_string_lossy().into_owned()) - .collect(); - insert_path(&mut roots, &parts, ""); - } - - roots +/// A `Send` file-tree node: the tree is built on a background thread and +/// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot +/// cross threads) on the main thread. +pub(super) struct TreeItemSeed { + /// Path of the node, relative to the worktree root. + id: String, + /// File or directory name. + label: String, + children: Vec, } -/// Insert `parts` (path components) into the tree rooted at `items`. -/// `prefix` is the path of `items`' parent, used to build item ids. -fn insert_path(items: &mut Vec, parts: &[String], prefix: &str) { - let Some((head, rest)) = parts.split_first() else { - return; - }; - - let id = if prefix.is_empty() { - head.clone() - } else { - format!("{prefix}/{head}") - }; - - if let Some(existing) = items.iter_mut().find(|item| &*item.label == head.as_str()) { - insert_path(&mut existing.children, rest, &id); - } else { - let mut item = TreeItem::new(id.clone(), head.clone()); - insert_path(&mut item.children, rest, &id); - items.push(item); +impl From for TreeItem { + fn from(seed: TreeItemSeed) -> Self { + let mut item = TreeItem::new(seed.id, seed.label); + item.children = seed.children.into_iter().map(Into::into).collect(); + item } } +/// Build nested tree items from a flat, sorted (dirs-first) entry list. +/// +/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a +/// worktree walk can yield tens of thousands of entries. Nodes live in an +/// arena and parents are found via a path -> index map, which keeps the +/// build linear in the number of path components. +pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { + // Node indices by full path, for O(1) parent lookup 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() +} + /// The markdown fence language for a file path, or `None` for plain text. /// /// Names are chosen so `gpui_component`'s highlighter can resolve them @@ -174,6 +207,42 @@ mod tests { assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); } + #[test] + fn tree_builder_merges_shared_prefixes() { + // File children of a directory arrive after other directories' + // entries (the worktree list is dirs-first globally); the shared + // prefix must still resolve to one node. + let entries = vec![ + PathBuf::from("a/x.txt"), + PathBuf::from("b/y.txt"), + PathBuf::from("a/z.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 2); + assert_eq!(items[0].label, "a"); + assert_eq!(items[0].children.len(), 2); + assert_eq!(items[1].label, "b"); + } + + #[test] + fn tree_seeds_convert_to_tree_items() { + let entries = vec![ + PathBuf::from("src"), + PathBuf::from("src/main.rs"), + PathBuf::from("README.md"), + ]; + + let items: Vec = build_tree_items(&entries) + .into_iter() + .map(Into::into) + .collect(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].label, "src"); + assert_eq!(items[0].children.len(), 1); + assert_eq!(items[0].children[0].label, "main.rs"); + } + #[test] fn code_language_maps_extensions_and_names() { assert_eq!(code_language("src/main.rs"), Some("rust")); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 0912548..46a00e8 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Component, Path, PathBuf}; use std::rc::Rc; @@ -16,19 +16,22 @@ use gpui_component::menu::PopupMenuItem; use gpui_component::searchable_list::SearchableVec; use gpui_component::tab::{Tab, TabBar}; use gpui_component::tag::Tag; -use gpui_component::tree::TreeState; +use gpui_component::tree::{TreeItem, TreeState}; use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, }; use signed_core::Announcement; -use signed_git::FileCommit; +use signed_git::{CommitList, FileCommit}; use signed_state::{GitStore, RepoStore}; mod browser; mod commits; mod helpers; -use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView}; +use browser::{ + CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, + MarkdownView, +}; use commits::COMMIT_ROW_HEIGHT; use helpers::{build_tree_items, is_markdown_path}; @@ -44,10 +47,16 @@ enum RefKind { /// Detail view of a repository: header, stats, a file explorer with README /// preview (cloned from the announcement's `clone` URLs), and metadata. pub struct RepoDetailView { - /// Live per-repository store, refreshed from the local database. - store: Entity, - /// Snapshot taken at open time, shown until the store's first refresh completes. + /// Snapshot taken at open time, shown until the store's first refresh + /// completes (and as a fallback while the store has no announcement). initial: Announcement, + /// Latest announcement from the store, cached so `render` (which runs + /// every frame) does not re-read and re-clone the store's copy. + announcement: Option, + /// Relay/web URLs of [`Self::announcement`] as display strings, for the + /// header dropdowns; `Rc` so the menu builders clone cheaply per frame. + relays: Rc>, + web: Rc>, /// File explorer state (worktree of the local clone). tree_state: Entity, /// Root of the local clone, for reading files on demand. @@ -60,17 +69,25 @@ pub struct RepoDetailView { /// Currently previewed file (relative path) and its contents. selected_file: Option, files: HashMap, + /// Paths of cached previews, oldest first; feeds the eviction caps in + /// [`Self::evict_previews`]. + file_order: VecDeque, + /// Total text bytes held by [`Self::files`]. + preview_bytes: usize, /// Reads in flight, to avoid duplicate loads. loading_files: HashSet, /// Latest commit touching a previewed file (or the README), keyed by path. commits: HashMap, - /// Commit queries in flight, to avoid duplicate loads. - loading_commits: HashSet, + /// Paths queued for the next batched commit query (see [`Self::load_commits`]). + pending_commits: Vec, + /// A batched commit query is in flight. + loading_commits: bool, /// Active header tab: 0 = Files (tree), 1 = Commits. active_tab: usize, - /// All commits reachable from HEAD, newest first; `None` until the - /// walk finishes (or fails). - all_commits: Option>, + /// Commits reachable from HEAD, newest first; `None` until the walk + /// finishes (or fails). `commits` may be capped by + /// [`CommitList`]; `total` feeds the tab badge. + all_commits: Option, /// Commit walk in flight. loading_all_commits: bool, /// Virtual list state of the Commits tab. @@ -90,10 +107,13 @@ pub struct RepoDetailView { /// Bumped on every branch/tag switch; in-flight loads tagged with an /// older generation are discarded when they complete. ref_generation: u64, - /// Subscriptions keeping the selectors' confirm events alive. - _subscriptions: Vec, focus_handle: FocusHandle, + /// In-flight tasks; finished tasks are pruned on every push, so the vec + /// stays bounded by the number of concurrent loads. tasks: Vec>>, + /// Subscriptions keeping the selectors' confirm events and the store's + /// refreshes alive. + _subscriptions: Vec, } impl RepoDetailView { @@ -121,7 +141,36 @@ impl RepoDetailView { .searchable(true) }); - let subscriptions = vec![ + // Cache the announcement for the header: the store only changes it + // during debounced refreshes, but `render` runs every frame. The + // observe subscription owns the store for the view's lifetime. + let subscription = cx.observe(&store, |this, store, cx| { + let fresh = store.read(cx).announcement.clone(); + if this.announcement == fresh { + return; + } + this.announcement = fresh; + // The header falls back to the open-time snapshot while the + // store has no announcement; keep its dropdown lists in sync. + let announcement = this.announcement.as_ref().unwrap_or(&this.initial); + this.relays = Rc::new( + announcement + .relays + .iter() + .map(|relay| relay.to_string().into()) + .collect(), + ); + this.web = Rc::new( + announcement + .web + .iter() + .map(|url| url.to_string().into()) + .collect(), + ); + cx.notify(); + }); + + let mut subscriptions = vec![ cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { // `Change` fires only when the selection actually changed // (picking the already-selected branch emits nothing), so a @@ -140,15 +189,35 @@ impl RepoDetailView { } }), ]; + subscriptions.push(subscription); // Defer loading the repository until the window is ready. cx.defer_in(window, |this, window, cx| { this.load_repo(window, cx); }); + // Header dropdowns of the open-time snapshot, until the store's + // first refresh replaces them. + let relays = Rc::new( + initial + .relays + .iter() + .map(|relay| relay.to_string().into()) + .collect(), + ); + let web = Rc::new( + initial + .web + .iter() + .map(|url| url.to_string().into()) + .collect(), + ); + Self { - store, initial, + announcement: None, + relays, + web, tree_state, worktree: None, md: None, @@ -156,9 +225,12 @@ impl RepoDetailView { readme_name: None, selected_file: None, files: HashMap::new(), + file_order: VecDeque::new(), + preview_bytes: 0, loading_files: HashSet::new(), commits: HashMap::new(), - loading_commits: HashSet::new(), + pending_commits: Vec::new(), + loading_commits: false, active_tab: 0, all_commits: None, loading_all_commits: false, @@ -190,6 +262,10 @@ impl RepoDetailView { let load = cx.background_spawn(async move { let repo = cache.ensure_clone(&addr, &clone_urls)?; let entries = signed_git::worktree_entries(&repo)?; + // The tree is built off the main thread; the seeds are plain + // owned strings and convert to `TreeItem`s (which hold `Rc` + // state) on the main thread. + let tree = build_tree_items(&entries); let readme_path = signed_git::find_readme(&repo)?; let readme = match &readme_path { Some(path) => signed_git::worktree_read(&repo, path)?, @@ -209,7 +285,7 @@ impl RepoDetailView { let head_commit = signed_git::head_commit(&repo).unwrap_or(None); Ok::<_, Error>(( - entries, + tree, readme_path, readme, worktree, @@ -226,7 +302,7 @@ impl RepoDetailView { this.update_in(cx, |this, window, cx| { match result { Ok(( - entries, + tree, readme_path, readme, Some(worktree), @@ -238,7 +314,10 @@ impl RepoDetailView { this.worktree = Some(worktree); this.head_commit = head_commit; this.tree_state.update(cx, |state, cx| { - state.set_items(build_tree_items(&entries), cx); + state.set_items( + tree.into_iter().map(Into::into).collect::>(), + cx, + ); }); // Populate the branch/tag selectors with the local @@ -280,7 +359,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(task); } /// Preview the file at `path` (relative to the worktree root). @@ -321,20 +400,24 @@ impl RepoDetailView { let content = cx .background_spawn(async move { let full = worktree.join(&path_for_read); + // Refuse oversized files before reading them: reading a + // multi-gigabyte file just to classify it as too large + // would waste the disk and memory bandwidth. + 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)), }; - - let kind = if bytes.len() > MAX_PREVIEW_BYTES { - FileContent::TooLarge - } else { - match String::from_utf8(bytes) { - Ok(text) => FileContent::Text(text), - Err(_) => FileContent::Binary, - } - }; - Ok::<_, Error>(kind) + match String::from_utf8(bytes) { + Ok(text) => Ok(FileContent::Text(text)), + Err(_) => Ok(FileContent::Binary), + } }) .await; @@ -361,8 +444,11 @@ impl RepoDetailView { this.set_code(path.clone().into(), text, window, cx); } } + this.preview_bytes += text.len(); } - this.files.insert(path, kind); + this.files.insert(path.clone(), kind); + this.file_order.push_back(path); + this.evict_previews(); } Err(error) => { this.files @@ -375,38 +461,64 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(task); } - /// Query the latest commit touching `path` on a background task and cache - /// it in [`Self::commits`], for the file header in the content column. + /// Queue `path` for the per-file commit query; requests are batched into + /// one history walk (see [`Self::load_commits`]). fn load_commit(&mut self, path: &str, cx: &mut Context) { - if self.commits.contains_key(path) || self.loading_commits.contains(path) { + 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); + } + } + + /// Walk history once for every queued path on a background task, and + /// cache the latest commit touching each of them in [`Self::commits`] + /// (for the file header in the content column). + /// + /// Batching shares one walk (and its object decodes) across all paths + /// queued while the previous walk was in flight, instead of walking the + /// full history per file. + 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.insert(path.to_string()); - let path = path.to_string(); + self.loading_commits = true; + let paths = std::mem::take(&mut self.pending_commits); let generation = self.ref_generation; let task = cx.spawn(async move |this, cx| { - let path_for_query = path.clone(); + let rels: Vec = paths.iter().map(PathBuf::from).collect(); let result = cx - .background_spawn(async move { - signed_git::worktree_last_commit(&worktree, Path::new(&path_for_query)) - }) + .background_spawn( + async move { signed_git::worktree_last_commits(&worktree, &rels) }, + ) .await; this.update(cx, |this, cx| { if generation != this.ref_generation { return; } - this.loading_commits.remove(&path); - if let Ok(Some(commit)) = result { - this.commits.insert(path, commit); + this.loading_commits = false; + if let Ok(found) = result { + for (path, commit) in found { + this.commits + .insert(path.to_string_lossy().into_owned(), commit); + } + } + // Paths queued while the walk was in flight start the next + // batch. + if !this.pending_commits.is_empty() { + this.load_commits(cx); } cx.notify(); })?; @@ -414,11 +526,12 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(task); } /// Walk all commits reachable from HEAD on a background task, for the - /// Commits tab and its total-count badge. + /// Commits tab and its total-count badge. The list is capped by + /// [`CommitList`]; only the newest commits are materialized. fn load_all_commits(&mut self, cx: &mut Context) { if self.loading_all_commits || self.all_commits.is_some() { return; @@ -440,10 +553,10 @@ impl RepoDetailView { if generation != this.ref_generation { return; } - if let Ok(commits) = result { - let count = commits.len(); + if let Ok(list) = result { + let count = list.commits.len(); this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); - this.all_commits = Some(commits); + this.all_commits = Some(list); } this.loading_all_commits = false; cx.notify(); @@ -452,7 +565,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(task); } /// Check out `name` (a branch or tag picked in the header) and refresh @@ -524,7 +637,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(task); } /// Restore a selector to `previous`, or clear it (after a failed switch). @@ -589,27 +702,38 @@ impl RepoDetailView { let task = cx.spawn(async move |this, cx| { let result = cx - .background_spawn(async move { signed_git::worktree_snapshot(&worktree) }) + .background_spawn(async move { + let snapshot = signed_git::worktree_snapshot(&worktree)?; + // Build the tree off the main thread, like [`Self::load_repo`]. + let tree = build_tree_items(&snapshot.entries); + Ok::<_, Error>((snapshot, tree)) + }) .await; this.update(cx, |this, cx| { this.switching_ref = false; match result { - Ok(snapshot) => { + Ok((snapshot, tree)) => { this.head_commit = snapshot.head_commit; // Rebuild the tree from scratch: entries of the // previous branch are gone, and with them the // expansion state. this.tree_state.update(cx, |state, cx| { - state.set_items(build_tree_items(&snapshot.entries), cx); + state.set_items( + tree.into_iter().map(Into::into).collect::>(), + cx, + ); }); // Drop cached previews and commits of the old branch. this.selected_file = None; this.files.clear(); + this.file_order.clear(); + this.preview_bytes = 0; this.loading_files.clear(); this.commits.clear(); - this.loading_commits.clear(); + this.pending_commits.clear(); + this.loading_commits = false; this.md = None; this.code = None; this.readme_name = None; @@ -640,8 +764,45 @@ impl RepoDetailView { Ok(()) }); + self.track(task); + } + + /// Track `task` until it completes; finished tasks are pruned on every + /// push so the vec stays bounded by the number of in-flight loads. + fn track(&mut self, task: Task>) { + self.tasks.retain(|task| !task.is_ready()); self.tasks.push(task); } + + /// Drop the oldest previews beyond the cache caps, keeping the currently + /// selected file. The parsed editor state of an evicted file is dropped + /// along with its entry, so re-opening it re-parses on a background task. + 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 path = self.file_order.pop_front().expect("non-empty"); + 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().map(|md| md.path.as_deref()) == Some(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); + } + } } impl Panel for RepoDetailView { @@ -649,13 +810,8 @@ impl Panel for RepoDetailView { "repo_detail" } - fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let announcement = self - .store - .read(cx) - .announcement - .clone() - .unwrap_or_else(|| self.initial.clone()); + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let announcement = self.announcement.as_ref().unwrap_or(&self.initial); announcement .name @@ -677,12 +833,9 @@ impl Render for RepoDetailView { let tree_state = self.tree_state.clone(); let view = cx.entity().downgrade(); - let announcement = self - .store - .read(cx) - .announcement - .clone() - .unwrap_or_else(|| self.initial.clone()); + let announcement = self.announcement.as_ref().unwrap_or(&self.initial); + let relays = self.relays.clone(); + let web = self.web.clone(); let name = announcement .name @@ -700,9 +853,7 @@ impl Render for RepoDetailView { .or_else(|| self.readme_name.clone()) .unwrap_or_else(|| "Overview".into()); - let relays = announcement.relays.clone(); - let web = announcement.web.clone(); - let commits_count = self.all_commits.as_ref().map(Vec::len); + let commits_count = self.all_commits.as_ref().map(|list| list.total); let worktree_empty = self.switching_ref || self.worktree.is_none(); v_flex() -- 2.54.0 From 080a026d3f29dd4d76c5644af1654c4a23d1fcd7 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 14 Aug 2026 08:48:20 +0700 Subject: [PATCH 34/64] add commit panel --- Cargo.lock | 34 ++ crates/signed_git/Cargo.toml | 2 +- crates/signed_git/src/lib.rs | 450 +++++++++++++++ .../src/views/repo_detail/commits.rs | 23 +- .../workspace/src/views/repo_detail/diff.rs | 544 ++++++++++++++++++ .../src/views/repo_detail/helpers.rs | 26 + crates/workspace/src/views/repo_detail/mod.rs | 58 +- crates/workspace/src/views/repo_list.rs | 5 +- crates/workspace/src/workspace.rs | 5 +- desktop/src/main.rs | 4 +- 10 files changed, 1135 insertions(+), 16 deletions(-) create mode 100644 crates/workspace/src/views/repo_detail/diff.rs diff --git a/Cargo.lock b/Cargo.lock index 1e79761..f9868ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1612,6 +1612,20 @@ dependencies = [ "cmov", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -2691,8 +2705,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" dependencies = [ "bstr", + "gix-command", + "gix-filter", + "gix-fs", "gix-hash", + "gix-imara-diff", "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", "thiserror 2.0.20", ] @@ -2821,6 +2844,16 @@ dependencies = [ "unicode-bom", ] +[[package]] +name = "gix-imara-diff" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" +dependencies = [ + "bstr", + "hashbrown 0.15.5", +] + [[package]] name = "gix-index" version = "0.54.0" @@ -3143,6 +3176,7 @@ version = "24.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" dependencies = [ + "dashmap", "gix-fs", "libc", "parking_lot", diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index 35cbaa0..3a17301 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -8,7 +8,7 @@ publish.workspace = true signed_core = { path = "../signed_core" } nostr.workspace = true -gix = { workspace = true, features = ["revision"] } +gix = { workspace = true, features = ["revision", "blob-diff"] } anyhow.workspace = true [dev-dependencies] diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index b2cd14c..e0684d2 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; +use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; use gix::interrupt::IS_INTERRUPTED; use gix::progress::Discard; use signed_core::RepoAddr; @@ -153,6 +154,9 @@ pub struct FileCommit { pub id: String, /// First line of the commit message. pub summary: String, + /// Rest of the commit message after the title; `None` when there is no + /// body (single-line commit messages). + pub description: Option, /// Author name. pub author: String, /// Author time, seconds since the Unix epoch. @@ -243,6 +247,10 @@ fn file_commit(commit: &gix::Commit<'_>) -> Result { Ok(FileCommit { id: commit.id().shorten_or_id().to_string(), summary: String::from_utf8_lossy(message.title).trim().to_string(), + description: message + .body + .map(|body| String::from_utf8_lossy(body).trim().to_string()) + .filter(|body| !body.is_empty()), author: String::from_utf8_lossy(author.name).trim().to_string(), time: author.time()?.seconds, }) @@ -385,6 +393,287 @@ pub fn worktree_all_commits(workdir: &Path) -> Result { all_commits(&open_with_cache(workdir)?) } +/// The kind of a [`DiffLine`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffLineKind { + /// An unchanged context line, present on both sides. + Context, + /// A line added by the commit. + Addition, + /// A line removed by the commit. + Deletion, +} + +/// One line of a file diff. +#[derive(Debug, Clone)] +pub struct DiffLine { + pub kind: DiffLineKind, + /// 1-based line number in the old version, if the line exists there. + pub old: Option, + /// 1-based line number in the new version, if the line exists there. + pub new: Option, + /// Line content without the trailing newline. + pub text: String, +} + +/// A hunk of a file diff, like `@@ -a,b +c,d @@`, with the lines between the +/// two headers (context around the change, then removals and additions). +#[derive(Debug, Clone)] +pub struct DiffHunk { + /// 1-based start line in the old version. + pub old_start: u32, + /// Number of old lines covered by the hunk. + pub old_lines: u32, + /// 1-based start line in the new version. + pub new_start: u32, + /// Number of new lines covered by the hunk. + pub new_lines: u32, + pub lines: Vec, +} + +/// How a file changed in a commit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffStatus { + Added, + Modified, + Deleted, + Renamed, + Copied, +} + +/// The diff of one file in a commit. +#[derive(Debug, Clone)] +pub struct FileDiff { + /// Path of the file relative to the repo root (the destination path for + /// renames and copies). + pub path: String, + /// Previous path, for renames and copies. + pub old_path: Option, + pub status: DiffStatus, + /// Number of added lines; 0 for binary files. + pub insertions: usize, + /// Number of removed lines; 0 for binary files. + pub deletions: usize, + /// True if either version is binary (then `hunks` is empty). + pub binary: bool, + pub hunks: Vec, +} + +/// The changes of one commit: every file it added, modified, deleted or +/// renamed, with line-level hunks for text files. +#[derive(Debug, Clone)] +pub struct CommitDiff { + pub files: Vec, +} + +/// The changes of the commit `id` (short or full) in the repository at +/// `workdir`, compared against its first parent (the empty tree for the root +/// commit), like `git show`. Directory entries and submodules are skipped; +/// their contents are reported as individual file changes. Files are sorted +/// by path. +pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { + commit_diff(&open_with_cache(workdir)?, id) +} + +fn commit_diff(repo: &gix::Repository, id: &str) -> Result { + use gix::diff::blob::platform::prepare_diff::Operation; + use gix::object::tree::diff::Change; + use gix::objs::tree::EntryKind; + + let commit_id = repo.rev_parse_single(id.as_bytes())?; + let commit = commit_id.object()?.into_commit(); + let new_tree = commit.tree()?; + let old_tree = match commit.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), + None => None, + }; + + let changes = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)?; + let mut cache = repo.diff_resource_cache_for_tree_diff()?; + + let mut files = Vec::new(); + for change in changes { + let attached = Change::from_change_ref(change.to_ref(), repo, repo); + + // The tree diff also reports directory entries; only their contents + // are listed, so skip trees and submodule gitlinks. + let (path, old_path, status) = match attached { + Change::Addition { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Added) + } + Change::Deletion { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Deleted) + } + Change::Modification { + location, + previous_entry_mode, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + previous_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + (location.to_owned(), None, DiffStatus::Modified) + } + Change::Rewrite { + location, + source_location, + source_entry_mode, + entry_mode, + copy, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + source_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + let status = if copy { + DiffStatus::Copied + } else { + DiffStatus::Renamed + }; + ( + location.to_owned(), + Some(source_location.to_owned()), + status, + ) + } + _ => continue, + }; + + // Always diff with the built-in algorithm: external diff drivers + // would shell out, which is out of scope for a read-only viewer. + let platform = attached.diff(&mut cache)?; + platform + .resource_cache + .options + .skip_internal_diff_if_external_is_configured = true; + let outcome = platform.resource_cache.prepare_diff()?; + + let (binary, hunks, insertions, deletions) = match outcome.operation { + Operation::InternalDiff { algorithm } => { + let input = outcome.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + + let mut hunks = Vec::new(); + let mut insertions = 0usize; + let mut deletions = 0usize; + let collector = HunkCollector { + hunks: &mut hunks, + insertions: &mut insertions, + deletions: &mut deletions, + }; + gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) + .consume()?; + (false, hunks, insertions, deletions) + } + Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), + Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"), + }; + + files.push(FileDiff { + path: String::from_utf8_lossy(&path).into_owned(), + old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), + status, + insertions, + deletions, + binary, + hunks, + }); + } + + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(CommitDiff { files }) +} + +/// Collects the hunks of one blob diff while tracking per-line numbers. +/// +/// The unified-diff headers give the 1-based start line of the hunk in each +/// file; context lines advance both counters, removals only the old one and +/// additions only the new one, so each line ends up with its real line +/// numbers in both versions. +struct HunkCollector<'a> { + hunks: &'a mut Vec, + insertions: &'a mut usize, + deletions: &'a mut usize, +} + +impl ConsumeHunk for HunkCollector<'_> { + type Out = (); + + fn consume_hunk( + &mut self, + header: HunkHeader, + lines: &[(GixLineKind, &[u8])], + ) -> std::io::Result<()> { + let mut old_ln = header.before_hunk_start; + let mut new_ln = header.after_hunk_start; + let mut out = Vec::with_capacity(lines.len()); + + for (kind, content) in lines { + let text = String::from_utf8_lossy(content).into_owned(); + let line = match kind { + GixLineKind::Context => { + let line = DiffLine { + kind: DiffLineKind::Context, + old: Some(old_ln), + new: Some(new_ln), + text, + }; + old_ln += 1; + new_ln += 1; + line + } + GixLineKind::Remove => { + *self.deletions += 1; + let line = DiffLine { + kind: DiffLineKind::Deletion, + old: Some(old_ln), + new: None, + text, + }; + old_ln += 1; + line + } + GixLineKind::Add => { + *self.insertions += 1; + let line = DiffLine { + kind: DiffLineKind::Addition, + old: None, + new: Some(new_ln), + text, + }; + new_ln += 1; + line + } + }; + out.push(line); + } + + self.hunks.push(DiffHunk { + old_start: header.before_hunk_start, + old_lines: header.before_hunk_len, + new_start: header.after_hunk_start, + new_lines: header.after_hunk_len, + lines: out, + }); + Ok(()) + } + + fn finish(self) {} +} + /// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a /// repository without commits yet (unborn HEAD). pub fn head_commit(repo: &gix::Repository) -> Result> { @@ -397,6 +686,10 @@ pub fn head_commit(repo: &gix::Repository) -> Result> { Ok(Some(FileCommit { id: commit.id().shorten_or_id().to_string(), summary: String::from_utf8_lossy(message.title).trim().to_string(), + description: message + .body + .map(|body| String::from_utf8_lossy(body).trim().to_string()) + .filter(|body| !body.is_empty()), author: String::from_utf8_lossy(author.name).trim().to_string(), time: author.time()?.seconds, })) @@ -929,4 +1222,161 @@ mod tests { .any(|p| p.to_string_lossy() == "b.txt") ); } + + #[test] + fn commit_diff_lists_added_modified_and_deleted_files() { + let (dir, repo) = fixture(&[("keep.txt", b"keep"), ("mod.txt", b"one\ntwo\nthree\n")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("mod.txt"), b"one\ntwo!\nthree\n").expect("write"); + std::fs::write(dir.path().join("new.txt"), b"hello\n").expect("write"); + std::fs::remove_file(dir.path().join("keep.txt")).expect("remove"); + commit_all(&repo, "changes"); + + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); + + let by_path: HashMap<&str, &FileDiff> = diff + .files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect(); + assert_eq!(by_path.len(), 3); + + let added = by_path["new.txt"]; + assert_eq!(added.status, DiffStatus::Added); + assert_eq!(added.insertions, 1); + assert_eq!(added.deletions, 0); + assert_eq!(added.hunks.len(), 1); + assert_eq!(added.hunks[0].lines.len(), 1); + assert_eq!(added.hunks[0].lines[0].kind, DiffLineKind::Addition); + assert_eq!(added.hunks[0].lines[0].old, None); + assert_eq!(added.hunks[0].lines[0].new, Some(1)); + assert_eq!(added.hunks[0].lines[0].text, "hello"); + + let modified = by_path["mod.txt"]; + assert_eq!(modified.status, DiffStatus::Modified); + assert_eq!(modified.insertions, 1); + assert_eq!(modified.deletions, 1); + assert!(!modified.binary); + let lines = &modified.hunks[0].lines; + // One hunk with context around the single-line change: the removed + // line is old 2, the added line is new 2. + assert!(lines.iter().any(|line| { + line.kind == DiffLineKind::Deletion + && line.old == Some(2) + && line.new.is_none() + && line.text == "two" + })); + assert!(lines.iter().any(|line| { + line.kind == DiffLineKind::Addition + && line.old.is_none() + && line.new == Some(2) + && line.text == "two!" + })); + assert!(lines.iter().any(|line| { + line.kind == DiffLineKind::Context && line.old == Some(1) && line.new == Some(1) + })); + + let deleted = by_path["keep.txt"]; + assert_eq!(deleted.status, DiffStatus::Deleted); + assert_eq!(deleted.deletions, 1); + assert_eq!(deleted.hunks[0].lines[0].kind, DiffLineKind::Deletion); + assert_eq!(deleted.hunks[0].lines[0].old, Some(1)); + assert_eq!(deleted.hunks[0].lines[0].new, None); + } + + #[test] + fn commit_diff_reports_binary_files_without_hunks() { + let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]); + commit_all(&repo, "initial"); + + std::fs::write(_dir.path().join("blob.bin"), b"\x00\x03").expect("write"); + commit_all(&repo, "binary change"); + + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); + let file = diff + .files + .iter() + .find(|f| f.path == "blob.bin") + .expect("file"); + assert!(file.binary); + assert!(file.hunks.is_empty()); + assert_eq!(file.insertions, 0); + assert_eq!(file.deletions, 0); + } + + #[test] + fn commit_diff_resolves_short_ids_and_root_commit() { + let (dir, repo) = fixture(&[("a.txt", b"one\n")]); + commit_all(&repo, "initial"); + + // The root commit diffs against the empty tree: everything is added. + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); + assert_eq!(diff.files.len(), 1); + assert_eq!(diff.files[0].path, "a.txt"); + assert_eq!(diff.files[0].status, DiffStatus::Added); + assert_eq!(diff.files[0].insertions, 1); + } + + #[test] + fn file_commit_includes_message_body() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "title"); + + // A single-line message has no body. + let head = head_commit(&repo).expect("head").expect("commit"); + assert_eq!(head.summary, "title"); + assert_eq!(head.description, None); + + // A message with a body exposes it, trimmed. + let dir = _dir.path(); + let status = Command::new("git") + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args([ + "commit", + "--allow-empty", + "-m", + "title two", + "-m", + "line one\n\nline two", + ]) + .status() + .expect("spawn git"); + assert!(status.success(), "git commit failed"); + + let head = head_commit(&repo).expect("head").expect("commit"); + assert_eq!(head.summary, "title two"); + assert_eq!(head.description.as_deref(), Some("line one\n\nline two")); + } + + #[test] + fn commit_diff_reports_renames() { + let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]); + commit_all(&repo, "initial"); + + std::fs::rename(_dir.path().join("old.txt"), _dir.path().join("new.txt")).expect("rename"); + commit_all(&repo, "rename"); + + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); + let file = diff + .files + .iter() + .find(|f| f.path == "new.txt") + .expect("file"); + assert_eq!(file.status, DiffStatus::Renamed); + assert_eq!(file.old_path.as_deref(), Some("old.txt")); + // A pure rename has no content change; the file is still listed. + assert!(file.hunks.is_empty()); + assert_eq!(file.insertions, 0); + assert_eq!(file.deletions, 0); + } } diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index c1b051f..bd5107b 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -3,7 +3,7 @@ //! as a badge on the tab. use gpui::prelude::*; -use gpui::{AnyElement, App, Context, div, px}; +use gpui::{AnyElement, App, Context, WeakEntity, div, px}; use gpui_component::scroll::Scrollbar; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list}; @@ -17,7 +17,16 @@ use super::helpers::placeholder; pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.; /// One row of the commit list: id, summary, author and relative time. -fn commit_row(ix: usize, commit: &FileCommit, cx: &App) -> AnyElement { +/// Clicking a row opens the diff of that commit in a new panel. +fn commit_row( + ix: usize, + commit: &FileCommit, + view: &WeakEntity, + cx: &App, +) -> AnyElement { + let view = view.clone(); + let commit = commit.clone(); + h_flex() .id(ix) .px_4() @@ -65,6 +74,11 @@ fn commit_row(ix: usize, commit: &FileCommit, cx: &App) -> AnyElement { .child(relative_time_secs(commit.time)), ), ) + .on_click(move |_event, window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.open_commit_diff(&commit, window, cx)); + } + }) .into_any_element() } @@ -114,7 +128,10 @@ impl RepoDetailView { .as_ref() .map(|list| list.commits.as_slice()) .unwrap_or(&[]); - range.map(|ix| commit_row(ix, &commits[ix], cx)).collect() + let view = cx.entity().downgrade(); + range + .map(|ix| commit_row(ix, &commits[ix], &view, cx)) + .collect() }, ) .track_scroll(&scroll_handle) diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs new file mode 100644 index 0000000..d6e034f --- /dev/null +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -0,0 +1,544 @@ +//! Commit diff viewer: a panel showing every file a commit changed, with a +//! tree of the changed files on the left and the line diff of the selected +//! file on the right. Opened from the repository detail view by clicking a +//! commit in the Commits tab or the latest-commit button in the header. + +use std::path::PathBuf; + +use gpui::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, + WeakEntity, Window, div, px, +}; +use gpui_component::clipboard::Clipboard; +use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::list::ListItem; +use gpui_component::spinner::Spinner; +use gpui_component::tag::Tag; +use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; +use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; +use utils::relative_time_secs; + +use super::helpers::{build_tree_items, placeholder, tree_items}; + +/// Width of the changed-files column. +const TREE_WIDTH: f32 = 260.; +/// Width of one line-number gutter in a diff row. +const GUTTER_WIDTH: f32 = 44.; + +/// Detail panel showing the diff of one commit. +pub struct CommitDiffView { + focus_handle: FocusHandle, + /// Local clone the commit lives in. + worktree: PathBuf, + /// Display name of the repository the commit belongs to. + repo_name: SharedString, + /// The commit being shown (header and tab title). + commit: FileCommit, + /// Loaded diff; `None` while loading or after a failure. + diff: Option, + /// The diff is being computed on a background task. + loading: bool, + error: Option, + /// Changed-files explorer state. + tree_state: Entity, + /// Path of the file whose diff is shown in the detail column. + selected_file: Option, + /// In-flight tasks; pruned on every push (see [`Self::track`]). + tasks: Vec>>, +} + +impl CommitDiffView { + pub fn new( + worktree: PathBuf, + repo_name: SharedString, + commit: FileCommit, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let tree_state = cx.new(|cx| TreeState::new(cx)); + + // Defer until the window is ready, like the repository detail view. + cx.defer_in(window, |this, window, cx| { + this.load(window, cx); + }); + + Self { + focus_handle: cx.focus_handle(), + worktree, + repo_name, + commit, + diff: None, + loading: true, + error: None, + tree_state, + selected_file: None, + tasks: Vec::new(), + } + } + + /// Load the commit diff on a background task and populate the tree. + fn load(&mut self, window: &mut Window, cx: &mut Context) { + self.loading = true; + self.error = None; + cx.notify(); + + let worktree = self.worktree.clone(); + let id = self.commit.id.clone(); + + let task = cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn(async move { signed_git::worktree_commit_diff(&worktree, &id) }) + .await; + + this.update_in(cx, |this, _window, cx| { + this.loading = false; + match result { + Ok(diff) => { + let mut paths: Vec = diff + .files + .iter() + .map(|file| PathBuf::from(&file.path)) + .collect(); + paths.sort(); + let items = tree_items(build_tree_items(&paths), true); + let first = diff + .files + .first() + .map(|file| SharedString::from(file.path.as_str())); + this.tree_state.update(cx, |state, cx| { + state.set_items(items.clone(), cx); + let item = find_item(&items, first.as_deref()); + state.set_selected_item(item, cx); + }); + this.selected_file = first; + this.diff = Some(diff); + } + Err(error) => { + this.error = Some(error.to_string().into()); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.track(task); + } + + /// Show the diff of the file at `path` (selected in the tree). + fn select_file(&mut self, path: &str, cx: &mut Context) { + self.selected_file = Some(path.into()); + cx.notify(); + } + + /// Track `task` until it completes; finished tasks are pruned on every + /// push so the vec stays bounded by the number of in-flight loads. + fn track(&mut self, task: gpui::Task>) { + self.tasks.retain(|task| !task.is_ready()); + self.tasks.push(task); + } + + /// One row of the changed-files tree: icon + name, indented by depth. + fn render_tree_item( + ix: usize, + entry: &TreeEntry, + selected: bool, + view: &WeakEntity, + ) -> ListItem { + let item = entry.item(); + let id = item.id.clone(); + let is_folder = entry.is_folder(); + + let icon = if is_folder { + if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::FolderClosed + } + } else { + IconName::File + }; + + let view = view.clone(); + + ListItem::new(ix) + .pl(px(8.) + px(14.) * entry.depth() as f32) + .selected(selected) + .child( + h_flex() + .gap_2() + .overflow_hidden() + .child(Icon::new(icon).small()) + .child(div().text_sm().text_ellipsis().child(item.label.clone())), + ) + .on_click(move |_event, _window, cx| { + // Folders expand/collapse via the tree itself. + if is_folder { + return; + } + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.select_file(&id, cx)); + } + }) + } + + /// Left column: the changed-files tree. + fn render_tree_column(&mut self, cx: &mut Context) -> AnyElement { + let tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + + v_flex() + .h_full() + .w(px(TREE_WIDTH)) + .flex_none() + .border_r_1() + .border_color(cx.theme().border) + .child( + div() + .flex_1() + .min_h_0() + .when(self.diff.is_some(), |this| { + this.child(tree( + &tree_state, + move |ix, entry, selected, _window, _cx| { + Self::render_tree_item(ix, entry, selected, &view) + }, + )) + }) + .when(self.diff.is_none() && !self.loading, |this| { + this.child( + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Failed to load diff"), + ), + ) + }), + ) + .into_any_element() + } + + /// Right column: header of the selected file plus its diff. + fn render_detail_column(&mut self, cx: &mut Context) -> AnyElement { + if self.loading { + return v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element(); + } + if let Some(error) = self.error.clone() { + return placeholder(&error, cx); + } + let Some(diff) = self.diff.as_ref() else { + return placeholder("Failed to load diff", cx); + }; + let Some(path) = self.selected_file.clone() else { + return if diff.files.is_empty() { + placeholder("No files changed in this commit", cx) + } else { + placeholder("Select a file", cx) + }; + }; + let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else { + return placeholder("File not found", cx); + }; + self.render_file_diff(file, cx) + } + + /// The diff of one file: a header with status and stats, then the hunks. + fn render_file_diff(&self, file: &FileDiff, cx: &App) -> AnyElement { + let status_label = match file.status { + DiffStatus::Added => "A", + DiffStatus::Modified => "M", + DiffStatus::Deleted => "D", + DiffStatus::Renamed => "R", + DiffStatus::Copied => "C", + }; + let status_color = match file.status { + DiffStatus::Added => cx.theme().success, + DiffStatus::Modified => cx.theme().info, + DiffStatus::Deleted => cx.theme().danger, + DiffStatus::Renamed | DiffStatus::Copied => cx.theme().muted_foreground, + }; + let title = match &file.old_path { + Some(old) => format!("{old} → {}", file.path), + None => file.path.clone(), + }; + + let body: AnyElement = if file.binary { + placeholder("Binary file — diff not available", cx) + } else if file.hunks.is_empty() { + placeholder("No content changes", cx) + } else { + v_flex() + .w_full() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .children(file.hunks.iter().map(|hunk| Self::render_hunk(hunk, cx))) + .into_any_element() + }; + + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child( + h_flex() + .px_3() + .h_9() + .gap_2() + .items_center() + .child( + div() + .text_xs() + .font_semibold() + .text_color(status_color) + .child(status_label), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .font_semibold() + .text_ellipsis() + .whitespace_nowrap() + .child(title), + ) + .when(!file.binary, |this| { + this.child( + h_flex() + .gap_2() + .text_xs() + .child( + div() + .text_color(cx.theme().success) + .child(format!("+{}", file.insertions)), + ) + .child( + div() + .text_color(cx.theme().danger) + .child(format!("-{}", file.deletions)), + ), + ) + }), + ) + .child( + div() + .id("commit-diff-body") + .flex_1() + .min_h_0() + .overflow_scroll() + .child(body), + ) + .into_any_element() + } + + /// One hunk: the `@@ -a,b +c,d @@` header row followed by its lines. + fn render_hunk(hunk: &DiffHunk, cx: &App) -> AnyElement { + v_flex() + .w_full() + .child( + div() + .px_2() + .py_0p5() + .w_full() + .bg(cx.theme().muted) + .border_y(px(1.)) + .border_color(cx.theme().border) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!( + "@@ -{},{} +{},{} @@", + hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines + ))), + ) + .children( + hunk.lines + .iter() + .map(|line| Self::render_diff_line(line, cx)), + ) + .into_any_element() + } + + /// One diff line: old and new line numbers in gutters, then the content, + /// tinted by kind (addition / deletion / context). + fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { + let bg = match line.kind { + DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), + DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)), + DiffLineKind::Context => None, + }; + let gutter = cx.theme().muted_foreground; + + h_flex() + .w_full() + .when_some(bg, |this, bg| this.bg(bg)) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.old.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.new.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_color(cx.theme().foreground) + .child(line.text.clone()), + ) + .into_any_element() + } + + /// Header: commit id, summary, author/time and overall change stats. + fn render_header(&mut self, cx: &mut Context) -> AnyElement { + let commit = &self.commit; + let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| { + ( + diff.files.len(), + diff.files.iter().map(|file| file.insertions).sum(), + diff.files.iter().map(|file| file.deletions).sum(), + ) + }); + + v_flex() + .px_4() + .py_2() + .w_full() + .gap_4() + .border_b_1() + .border_color(cx.theme().border) + .child( + v_flex() + .gap_2() + .child( + div() + .font_semibold() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ) + .child( + h_flex() + .gap_2p5() + .text_sm() + .child(h_flex().child(format!("{} committed", commit.author))) + .child( + h_flex() + .gap_0p5() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&commit.id)) + .child(Clipboard::new("commit").value(&commit.id)), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(relative_time_secs(commit.time)), + ), + ), + ) + .when_some(commit.description.as_ref(), |this, description| { + this.child(div().text_sm().child(SharedString::from(description))) + }) + .child( + h_flex() + .gap_2() + .text_xs() + .child( + Tag::primary() + .outline() + .small() + .child(format!("{files} files changed")), + ) + .when(insertions > 0, |this| { + this.child( + Tag::success() + .outline() + .small() + .child(format!("+ {insertions}")), + ) + }) + .when(deletions > 0, |this| { + this.child( + Tag::success() + .outline() + .small() + .child(format!("- {insertions}")), + ) + }), + ) + .into_any_element() + } +} + +/// Find a tree item by id, searching into nested children. +fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { + let id = id?; + items.iter().find_map(|item| { + if item.id.as_ref() == id { + Some(item) + } else { + find_item(&item.children, Some(id)) + } + }) +} + +impl Panel for CommitDiffView { + fn panel_name(&self) -> &'static str { + "commit_diff" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().text_sm().child(SharedString::from(format!( + "{}/{}", + self.repo_name, self.commit.id + ))) + } +} + +impl EventEmitter for CommitDiffView {} + +impl Focusable for CommitDiffView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for CommitDiffView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("commit-diff") + .size_full() + .child(self.render_header(cx)) + .child( + h_flex() + .flex_1() + .w_full() + .min_h_0() + .child(self.render_tree_column(cx)) + .child(self.render_detail_column(cx)), + ) + } +} diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index 93b6a6e..050771d 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -28,6 +28,32 @@ impl From for TreeItem { } } +/// Convert tree seeds into [`TreeItem`]s, expanding every folder when +/// `expand_folders` is set. +/// +/// The commit diff explorer shows only changed files, which is typically a +/// handful of paths, so its folders start expanded; the worktree explorer +/// starts collapsed instead. +pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { + fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { + let mut item = TreeItem::new(seed.id, seed.label); + if expand_folders && !seed.children.is_empty() { + item = item.expanded(true); + } + item.children = seed + .children + .into_iter() + .map(|seed| convert(seed, expand_folders)) + .collect(); + item + } + + seeds + .into_iter() + .map(|seed| convert(seed, expand_folders)) + .collect() +} + /// Build nested tree items from a flat, sorted (dirs-first) entry list. /// /// Returns [`TreeItemSeed`]s so the build can run off the main thread; a diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 46a00e8..1558ff4 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -1,17 +1,18 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Component, Path, PathBuf}; use std::rc::Rc; +use std::sync::Arc; use anyhow::Error; use assets::CustomIconName; use gpui::prelude::*; use gpui::{ AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, - Render, SharedString, Size, Subscription, Task, Window, div, px, size, + Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, }; use gpui_component::button::{Button, ButtonVariants, DropdownButton}; use gpui_component::combobox::{Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerCtx}; -use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; use gpui_component::menu::PopupMenuItem; use gpui_component::searchable_list::SearchableVec; use gpui_component::tab::{Tab, TabBar}; @@ -26,6 +27,7 @@ use signed_state::{GitStore, RepoStore}; mod browser; mod commits; +mod diff; mod helpers; use browser::{ @@ -33,6 +35,7 @@ use browser::{ MarkdownView, }; use commits::COMMIT_ROW_HEIGHT; +use diff::CommitDiffView; use helpers::{build_tree_items, is_markdown_path}; /// What kind of ref the header selectors switch to. @@ -47,6 +50,10 @@ enum RefKind { /// Detail view of a repository: header, stats, a file explorer with README /// preview (cloned from the announcement's `clone` URLs), and metadata. pub struct RepoDetailView { + focus_handle: FocusHandle, + /// Dock area the detail view lives in; new panels (commit diffs) are + /// added there. + dock_area: WeakEntity, /// Snapshot taken at open time, shown until the store's first refresh /// completes (and as a fallback while the store has no announcement). initial: Announcement, @@ -107,7 +114,6 @@ pub struct RepoDetailView { /// Bumped on every branch/tag switch; in-flight loads tagged with an /// older generation are discarded when they complete. ref_generation: u64, - focus_handle: FocusHandle, /// In-flight tasks; finished tasks are pruned on every push, so the vec /// stays bounded by the number of concurrent loads. tasks: Vec>>, @@ -117,7 +123,12 @@ pub struct RepoDetailView { } impl RepoDetailView { - pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + dock_area: WeakEntity, + initial: Announcement, + window: &mut Window, + cx: &mut Context, + ) -> Self { let store = cx.new(|cx| RepoStore::new(initial.addr(), cx)); let tree_state = cx.new(|cx| TreeState::new(cx)); @@ -215,6 +226,7 @@ impl RepoDetailView { Self { initial, + dock_area, announcement: None, relays, web, @@ -568,6 +580,36 @@ impl RepoDetailView { self.track(task); } + /// Open a new panel showing the diff of `commit` (all files it changed, + /// with the line diff of each). Called from the Commits tab rows and the + /// latest-commit button in the header. + fn open_commit_diff( + &mut self, + commit: &FileCommit, + window: &mut Window, + cx: &mut Context, + ) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + // Same display name as the repo detail panel's title. + let announcement = self.announcement.as_ref().unwrap_or(&self.initial); + let repo_name = announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())); + + let panel = + cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit.clone(), window, cx)); + if let Some(dock_area) = self.dock_area.upgrade() { + dock_area.update(cx, |dock_area, cx| { + // The diff viewer lives in the bottom dock, leaving the + // central explorer open while browsing a commit. + dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx); + }); + } + } + /// Check out `name` (a branch or tag picked in the header) and refresh /// the explorer once the switch completes. fn switch_ref( @@ -1057,7 +1099,13 @@ impl Render for RepoDetailView { .map_or_else(SharedString::default, |commit| { commit.summary.clone().into() }), - ), + ) + .on_click(cx.listener(|this, _event, window, cx| { + if let Some(commit) = &this.head_commit { + let commit = commit.clone(); + this.open_commit_diff(&commit, window, cx); + } + })), ), ), ), diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 3f3ae21..656449e 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -63,7 +63,8 @@ impl RepoListView { cx: &mut Context, ) { let dock_area = self.dock_area.clone(); - let detail = cx.new(|cx| RepoDetailView::new(announcement.clone(), window, cx)); + let detail = + cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx)); if let Some(dock_area) = dock_area.upgrade() { dock_area.update(cx, |dock_area, cx| { @@ -171,7 +172,7 @@ impl Panel for RepoListView { } fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - "Explore" + div().text_sm().child(SharedString::from("Explore")) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 7ea4d16..c4808c5 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4,7 +4,7 @@ use assets::CustomIconName; use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::dock::{DockArea, DockItem, PanelStyle}; +use gpui_component::dock::{DockArea, DockItem}; use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex}; use signed_state::{Backend, BackendEvent}; @@ -21,8 +21,7 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let style = PanelStyle::TabBar; - let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(style)); + let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx)); let weak_dock = dock.downgrade(); let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); diff --git a/desktop/src/main.rs b/desktop/src/main.rs index c289c7d..2bc535b 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -31,7 +31,7 @@ fn main() { signed_state::GitStore::set_global(paths::repos_dir().clone(), cx); // Set up the window bounds - let bounds = Bounds::centered(None, size(px(1120.0), px(720.0)), cx); + let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx); // Set up the window options let opts = WindowOptions { @@ -50,7 +50,7 @@ fn main() { }; cx.spawn(async move |cx| { - let _ = cx.open_window(opts, workspace::root); + cx.open_window(opts, workspace::root).ok(); }) .detach(); -- 2.54.0 From 2de810003d75127c541d5d2d942e052b5f4470f8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 14 Aug 2026 09:42:41 +0700 Subject: [PATCH 35/64] . --- .../workspace/src/views/repo_detail/diff.rs | 48 +++++++++++-------- crates/workspace/src/views/repo_detail/mod.rs | 5 +- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index d6e034f..7f9c154 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -13,6 +13,7 @@ use gpui::{ use gpui_component::clipboard::Clipboard; use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::list::ListItem; +use gpui_component::resizable::{resizable_panel, v_resizable}; use gpui_component::spinner::Spinner; use gpui_component::tag::Tag; use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; @@ -201,12 +202,12 @@ impl CommitDiffView { .flex_1() .min_h_0() .when(self.diff.is_some(), |this| { - this.child(tree( - &tree_state, - move |ix, entry, selected, _window, _cx| { + this.child( + tree(&tree_state, move |ix, entry, selected, _window, _cx| { Self::render_tree_item(ix, entry, selected, &view) - }, - )) + }) + .p_2(), + ) }) .when(self.diff.is_none() && !self.loading, |this| { this.child( @@ -425,14 +426,12 @@ impl CommitDiffView { v_flex() .px_4() - .py_2() + .pt_2() + .pb_4() .w_full() .gap_4() - .border_b_1() - .border_color(cx.theme().border) .child( v_flex() - .gap_2() .child( div() .font_semibold() @@ -442,7 +441,7 @@ impl CommitDiffView { ) .child( h_flex() - .gap_2p5() + .gap_2() .text_sm() .child(h_flex().child(format!("{} committed", commit.author))) .child( @@ -482,7 +481,7 @@ impl CommitDiffView { }) .when(deletions > 0, |this| { this.child( - Tag::success() + Tag::danger() .outline() .small() .child(format!("- {insertions}")), @@ -528,17 +527,24 @@ impl Focusable for CommitDiffView { impl Render for CommitDiffView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .id("commit-diff") - .size_full() - .child(self.render_header(cx)) + v_resizable("commit-diff") .child( - h_flex() - .flex_1() - .w_full() - .min_h_0() - .child(self.render_tree_column(cx)) - .child(self.render_detail_column(cx)), + resizable_panel() + .size(px(170.)) + .size_range(px(100.)..px(400.)) + .flex_none() + .bg(cx.theme().background) + .child(self.render_header(cx)), + ) + .child( + resizable_panel().child( + h_flex() + .size_full() + .min_h_0() + .bg(cx.theme().background) + .child(self.render_tree_column(cx)) + .child(self.render_detail_column(cx)), + ), ) } } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 1558ff4..d602cb1 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -592,19 +592,18 @@ impl RepoDetailView { let Some(worktree) = self.worktree.clone() else { return; }; + // Same display name as the repo detail panel's title. let announcement = self.announcement.as_ref().unwrap_or(&self.initial); let repo_name = announcement .name .clone() .unwrap_or_else(|| SharedString::from(announcement.id.clone())); - let panel = cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit.clone(), window, cx)); + if let Some(dock_area) = self.dock_area.upgrade() { dock_area.update(cx, |dock_area, cx| { - // The diff viewer lives in the bottom dock, leaving the - // central explorer open while browsing a commit. dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx); }); } -- 2.54.0 From 6b3e2945e045695a6fb7bfce86e5565e715a2d7d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 14 Aug 2026 09:55:43 +0700 Subject: [PATCH 36/64] update --- Cargo.lock | 178 ++++++++++-------- .../src/views/repo_detail/browser.rs | 12 +- 2 files changed, 104 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9868ad..182dc49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -744,9 +744,9 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitcoin-consensus-encoding" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" dependencies = [ "bitcoin-internals", "hex-conservative 1.2.0", @@ -1244,7 +1244,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "gpui_util", "indexmap", @@ -1701,7 +1701,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "proc-macro2", "quote", @@ -2203,9 +2203,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39" dependencies = [ "bytemuck", ] @@ -2851,7 +2851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" dependencies = [ "bstr", - "hashbrown 0.15.5", + "hashbrown 0.17.1", ] [[package]] @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "accesskit", "anyhow", @@ -3518,18 +3518,47 @@ dependencies = [ ] [[package]] -name = "gpui-component" +name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#6e3f241136ed9a60d0f38d53ac3bdf285a3a13f7" +source = "git+https://github.com/longbridge/gpui-component#f43332677dd18ba2ce00f8d7d13645128da34eb0" dependencies = [ "aho-corasick", "anyhow", "async-channel", "chrono", + "gpui", + "gpui_macros", + "instant", + "lsp-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "regex", + "ropey", + "schemars", + "serde", + "serde_json", + "smallvec", + "smol", + "tracing", + "unicode-segmentation", + "web-time", + "zed-sum-tree", +] + +[[package]] +name = "gpui-component" +version = "0.5.2" +source = "git+https://github.com/longbridge/gpui-component#f43332677dd18ba2ce00f8d7d13645128da34eb0" +dependencies = [ + "anyhow", + "chrono", "core-text", "enum-iterator", "futures", "gpui", + "gpui-base", "gpui-component-assets", "gpui-component-macros", "gpui_macros", @@ -3548,7 +3577,6 @@ dependencies = [ "once_cell", "paste", "raw-window-handle", - "regex", "resvg 0.45.1", "ropey", "rust-i18n", @@ -3594,7 +3622,6 @@ dependencies = [ "tree-sitter-typescript", "tree-sitter-yaml", "tree-sitter-zig", - "unicode-segmentation", "uuid", "windows 0.58.0", "zed-sum-tree", @@ -3603,7 +3630,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#6e3f241136ed9a60d0f38d53ac3bdf285a3a13f7" +source = "git+https://github.com/longbridge/gpui-component#f43332677dd18ba2ce00f8d7d13645128da34eb0" dependencies = [ "anyhow", "gpui", @@ -3617,7 +3644,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#6e3f241136ed9a60d0f38d53ac3bdf285a3a13f7" +source = "git+https://github.com/longbridge/gpui-component#f43332677dd18ba2ce00f8d7d13645128da34eb0" dependencies = [ "proc-macro2", "quote", @@ -3627,7 +3654,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "accesskit", "accesskit_unix", @@ -3679,7 +3706,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "accesskit", "accesskit_macos", @@ -3728,7 +3755,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3739,7 +3766,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "console_error_panic_hook", "gpui", @@ -3752,7 +3779,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "schemars", "serde", @@ -3762,7 +3789,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "anyhow", "log", @@ -3772,7 +3799,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3795,7 +3822,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "anyhow", "bytemuck", @@ -3825,7 +3852,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "accesskit", "accesskit_windows", @@ -3860,12 +3887,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "grid" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" - [[package]] name = "h2" version = "0.4.15" @@ -4130,9 +4151,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -4144,7 +4165,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "anyhow", "async-compression", @@ -4164,7 +4185,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -4271,9 +4292,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -4285,9 +4306,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -4298,9 +4319,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -4312,16 +4333,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -4332,15 +4354,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -4916,9 +4938,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -5139,7 +5161,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "anyhow", "bindgen", @@ -6187,7 +6209,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "collections", "serde", @@ -6444,9 +6466,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -6767,9 +6789,9 @@ checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" [[package]] name = "rangemap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" [[package]] name = "rav1e" @@ -6886,7 +6908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.2", + "font-types 0.12.3", "once_cell", ] @@ -6939,7 +6961,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "derive_refineable", ] @@ -7022,7 +7044,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "anyhow", "bytes", @@ -7423,7 +7445,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "async-task", "backtrace", @@ -8146,7 +8168,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "heapless 0.9.3", "log", @@ -8368,14 +8390,14 @@ dependencies = [ [[package]] name = "taffy" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4" +checksum = "c034e05f6ee85a12daa63863c2245797715075c70649947aa0da54f3f2ab1d0f" dependencies = [ "arrayvec", - "grid", "serde", "slotmap", + "smallvec", ] [[package]] @@ -8553,9 +8575,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -9495,7 +9517,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "perf", "quote", @@ -10779,9 +10801,9 @@ checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x11" @@ -11238,9 +11260,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -11249,9 +11271,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -11260,13 +11282,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -11278,7 +11300,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "anyhow", "chrono", @@ -11295,7 +11317,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" dependencies = [ "tracing", "tracing-subscriber", @@ -11306,7 +11328,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#6bd93fc3195242834f4999f3b3daab294df6b253" +source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" [[package]] name = "zune-core" diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 2df4c47..93b243d 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -6,7 +6,7 @@ use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::{Input, InputState}; +use gpui_component::input::{Editor, EditorState}; use gpui_component::list::ListItem; use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; @@ -61,7 +61,7 @@ pub(super) struct MarkdownView { pub(super) struct CodeView { /// Source path, relative to the worktree root. pub(super) path: SharedString, - pub(super) state: Entity, + pub(super) state: Entity, } /// Spinner shown while a document is being loaded/parsed. @@ -319,11 +319,7 @@ impl RepoDetailView { cx: &mut Context, ) { let language = code_language(path.as_ref()).unwrap_or("text"); - let state = cx.new(|cx| { - InputState::new(window, cx) - .code_editor(language) - .default_value(text) - }); + let state = cx.new(|cx| EditorState::new(language, window, cx).default_value(text)); self.code = Some(CodeView { path, state }); } @@ -340,7 +336,7 @@ impl RepoDetailView { // Disabled: the preview is read-only. Selection, copy and the // built-in search (Cmd/Ctrl+F) still work; editing is blocked by the // component's disabled state. - Input::new(&code.state) + Editor::new(&code.state) .disabled(true) .h_full() .into_any_element() -- 2.54.0 From c1c7ddbca212a31b4fe117f9cb36eb30d9b4fd3d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 14 Aug 2026 10:55:42 +0700 Subject: [PATCH 37/64] optimize --- Cargo.lock | 1 + crates/signed_git/src/lib.rs | 72 +- crates/workspace/Cargo.toml | 1 + .../src/views/repo_detail/browser.rs | 77 +- .../src/views/repo_detail/commits.rs | 8 +- .../workspace/src/views/repo_detail/diff.rs | 295 +++--- .../src/views/repo_detail/helpers.rs | 66 +- crates/workspace/src/views/repo_detail/mod.rs | 855 ++++++++++-------- 8 files changed, 796 insertions(+), 579 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 182dc49..ce7cb52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10785,6 +10785,7 @@ version = "1.0.0" dependencies = [ "anyhow", "assets", + "gix", "gpui", "gpui-component", "signed_core", diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index e0684d2..064b6db 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -240,17 +240,24 @@ fn open_with_cache(workdir: &Path) -> Result { Ok(repo) } -/// A [`FileCommit`] from a walk commit: author, message title and shortened id. -fn file_commit(commit: &gix::Commit<'_>) -> Result { +/// A [`FileCommit`] from a walk commit: author, message title and shortened +/// id. `include_description` controls whether the message body is copied; +/// history lists never display it, so skipping it saves a string allocation +/// per listed commit (the diff panel fetches the full commit on demand). +fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result { let author = commit.author()?; let message = commit.message()?; Ok(FileCommit { id: commit.id().shorten_or_id().to_string(), summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: message - .body - .map(|body| String::from_utf8_lossy(body).trim().to_string()) - .filter(|body| !body.is_empty()), + description: if include_description { + message + .body + .map(|body| String::from_utf8_lossy(body).trim().to_string()) + .filter(|body| !body.is_empty()) + } else { + None + }, author: String::from_utf8_lossy(author.name).trim().to_string(), time: author.time()?.seconds, }) @@ -332,7 +339,7 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result Result { let info = info?; total += 1; if commits.len() < MAX_LISTED_COMMITS { - commits.push(file_commit(&info.object()?)?); + commits.push(file_commit(&info.object()?, false)?); } } Ok(CommitList { total, commits }) @@ -681,23 +688,29 @@ pub fn head_commit(repo: &gix::Repository) -> Result> { return Ok(None); }; let commit = head.object()?.into_commit(); - let author = commit.author()?; - let message = commit.message()?; - Ok(Some(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: message - .body - .map(|body| String::from_utf8_lossy(body).trim().to_string()) - .filter(|body| !body.is_empty()), - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time()?.seconds, - })) + Ok(Some(file_commit(&commit, true)?)) } -/// Short names of local branches (`refs/heads/*`), sorted alphabetically. -pub fn worktree_branches(workdir: &Path) -> Result> { +/// Full metadata of the commit `id` (short or full) in the repository at +/// `workdir`, like [`head_commit`] for an arbitrary commit. Returns +/// `Ok(None)` when the id cannot be resolved. +/// +/// The commit list ([`all_commits`]) omits message bodies to keep the walk +/// cheap; the diff panel uses this to fetch the full commit on demand. +pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { let repo = open_with_cache(workdir)?; + match repo.rev_parse_single(id.as_bytes()) { + Ok(commit_id) => { + let commit = commit_id.object()?.into_commit(); + Ok(Some(file_commit(&commit, true)?)) + } + Err(_) => Ok(None), + } +} + +/// Short names of local branches (`refs/heads/*`) of `repo`, sorted +/// alphabetically. +pub fn repo_branches(repo: &gix::Repository) -> Result> { let mut names = Vec::new(); for reference in repo.references()?.local_branches()? { let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; @@ -707,9 +720,8 @@ pub fn worktree_branches(workdir: &Path) -> Result> { Ok(names) } -/// Short names of tags (`refs/tags/*`), sorted alphabetically. -pub fn worktree_tags(workdir: &Path) -> Result> { - let repo = open_with_cache(workdir)?; +/// Short names of tags (`refs/tags/*`) of `repo`, sorted alphabetically. +pub fn repo_tags(repo: &gix::Repository) -> Result> { let mut names = Vec::new(); for reference in repo.references()?.tags()? { let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; @@ -719,6 +731,16 @@ pub fn worktree_tags(workdir: &Path) -> Result> { Ok(names) } +/// Short names of local branches (`refs/heads/*`), sorted alphabetically. +pub fn worktree_branches(workdir: &Path) -> Result> { + repo_branches(&open_with_cache(workdir)?) +} + +/// Short names of tags (`refs/tags/*`), sorted alphabetically. +pub fn worktree_tags(workdir: &Path) -> Result> { + repo_tags(&open_with_cache(workdir)?) +} + /// Short name of the branch HEAD points to, or `None` when detached (e.g. /// after checking out a tag or a commit directly). pub fn current_branch(repo: &gix::Repository) -> Result> { diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index e961ebb..59949fb 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -13,5 +13,6 @@ utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true +gix.workspace = true anyhow.workspace = true diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 93b243d..3f55550 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -11,10 +11,10 @@ use gpui_component::list::ListItem; use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; use gpui_component::tree::{TreeEntry, TreeState, tree}; -use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; use super::RepoDetailView; -use super::helpers::{code_language, is_markdown_path, placeholder}; +use super::helpers::{code_language, is_markdown_path, placeholder, tree_row}; /// Width of the file explorer column. const TREE_WIDTH: f32 = 240.; @@ -82,46 +82,18 @@ impl RepoDetailView { selected: bool, view: &WeakEntity, ) -> ListItem { - let item = entry.item(); - let id = item.id.clone(); - let is_folder = entry.is_folder(); - - let icon = if is_folder { - if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::FolderClosed - } - } else { - IconName::File - }; - let view = view.clone(); + let id = entry.item().id.clone(); - ListItem::new(ix) - .pl(px(8.) + px(14.) * entry.depth() as f32) - .selected(selected) - .child( - h_flex() - .gap_2() - .overflow_hidden() - .child(Icon::new(icon).small()) - .child(div().text_sm().text_ellipsis().child(item.label.clone())), - ) - .on_click(move |_event, window, cx| { - // Folders expand/collapse via the tree itself. - if is_folder { - return; - } - if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| this.open_file(&id, window, cx)); - } - }) + tree_row(ix, entry, selected, move |window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.open_file(&id, window, cx)); + } + }) } /// Left column: the file tree. pub(super) fn render_tree_column( - &mut self, tree_state: Entity, view: WeakEntity, cx: &mut Context, @@ -143,7 +115,7 @@ impl RepoDetailView { /// Right column: README, selected file preview, or status text. pub(super) fn render_content_column( - &mut self, + &self, pane_title: SharedString, cx: &mut Context, ) -> impl IntoElement { @@ -190,12 +162,7 @@ impl RepoDetailView { Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx), Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx), Some(FileContent::Failed(message)) => placeholder(message, cx), - None => v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element(), + None => preview_spinner(), } } else if self.readme_name.is_some() { self.markdown_element(None, cx) @@ -206,19 +173,13 @@ impl RepoDetailView { // Latest commit for the current pane: the selected file, or the README // while nothing is selected. Computed after the body above, which // needs `&mut self`. - let commit = self - .selected_file - .as_ref() - .and_then(|path| self.commits.get(path.as_ref())) - .or_else(|| { - if self.selected_file.is_none() { - self.readme_name - .as_ref() - .and_then(|name| self.commits.get(name.as_ref())) - } else { - None - } - }); + 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() @@ -285,7 +246,7 @@ impl RepoDetailView { /// The persistent markdown TextView for `path` (`None` = README), or a /// spinner while the document is being loaded/parsed. - fn markdown_element(&mut self, path: Option<&str>, _cx: &mut Context) -> AnyElement { + fn markdown_element(&self, path: Option<&str>, _cx: &mut Context) -> AnyElement { let Some(md) = &self.md else { return preview_spinner(); }; @@ -325,7 +286,7 @@ impl RepoDetailView { /// The persistent code editor for `path`, or a spinner while the file is /// being loaded/parsed. - fn code_element(&mut self, path: &str, _cx: &mut Context) -> AnyElement { + fn code_element(&self, path: &str, _cx: &mut Context) -> AnyElement { let Some(code) = &self.code else { return preview_spinner(); }; diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index bd5107b..6900101 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -25,7 +25,9 @@ fn commit_row( cx: &App, ) -> AnyElement { let view = view.clone(); - let commit = commit.clone(); + // Only the id is needed by the click handler: the diff panel fetches + // the full commit itself. + let id = commit.id.clone(); h_flex() .id(ix) @@ -76,7 +78,7 @@ fn commit_row( ) .on_click(move |_event, window, cx| { if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| this.open_commit_diff(&commit, window, cx)); + view.update(cx, |this, cx| this.open_commit_diff(&id, window, cx)); } }) .into_any_element() @@ -85,7 +87,7 @@ fn commit_row( impl RepoDetailView { /// Full-height body of the Commits tab: all commits in a virtual /// list, or a status message while loading / when there are none. - pub(super) fn render_commits_tab(&mut self, cx: &mut Context) -> AnyElement { + pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { let Some(list) = self.all_commits.as_ref() else { return if self.loading_all_commits { v_flex() diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index 7f9c154..d8c3301 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -4,29 +4,48 @@ //! commit in the Commits tab or the latest-commit button in the header. use std::path::PathBuf; +use std::rc::Rc; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - WeakEntity, Window, div, px, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + ScrollStrategy, SharedString, Size, WeakEntity, Window, div, px, size, }; use gpui_component::clipboard::Clipboard; use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::list::ListItem; use gpui_component::resizable::{resizable_panel, v_resizable}; +use gpui_component::scroll::{ScrollableElement, Scrollbar}; use gpui_component::spinner::Spinner; use gpui_component::tag::Tag; use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; -use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; +use gpui_component::{ + ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, +}; use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; use utils::relative_time_secs; -use super::helpers::{build_tree_items, placeholder, tree_items}; +use super::helpers::{build_tree_items, placeholder, track, tree_items, tree_row}; /// Width of the changed-files column. const TREE_WIDTH: f32 = 260.; /// Width of one line-number gutter in a diff row. const GUTTER_WIDTH: f32 = 44.; +/// Height of one row in the virtual diff list. +const DIFF_ROW_HEIGHT: f32 = 20.; + +/// One row of the virtual diff list: a hunk header, or a line of a hunk. +#[derive(Clone, Copy)] +enum DiffRow { + Hunk { + old_start: u32, + old_lines: u32, + new_start: u32, + new_lines: u32, + }, + /// Line `line` of hunk `hunk` of the selected file's diff. + Line { hunk: usize, line: usize }, +} /// Detail panel showing the diff of one commit. pub struct CommitDiffView { @@ -35,7 +54,9 @@ pub struct CommitDiffView { worktree: PathBuf, /// Display name of the repository the commit belongs to. repo_name: SharedString, - /// The commit being shown (header and tab title). + /// The commit being shown (header and tab title). Starts as an id-only + /// stub; [`Self::load`] replaces it with the full metadata, which the + /// history list intentionally omits. commit: FileCommit, /// Loaded diff; `None` while loading or after a failure. diff: Option, @@ -46,7 +67,14 @@ pub struct CommitDiffView { tree_state: Entity, /// Path of the file whose diff is shown in the detail column. selected_file: Option, - /// In-flight tasks; pruned on every push (see [`Self::track`]). + /// Rows of the selected file's diff (hunk headers + lines), backing the + /// virtual list in the detail column. + rows: Vec, + /// Per-row heights of [`Self::rows`]. + item_sizes: Rc>>, + /// Virtual list state of the diff rows. + scroll_handle: VirtualListScrollHandle, + /// In-flight tasks; pruned on every push (see [`helpers::track`]). tasks: Vec>>, } @@ -54,7 +82,7 @@ impl CommitDiffView { pub fn new( worktree: PathBuf, repo_name: SharedString, - commit: FileCommit, + commit_id: String, window: &mut Window, cx: &mut Context, ) -> Self { @@ -69,17 +97,27 @@ impl CommitDiffView { focus_handle: cx.focus_handle(), worktree, repo_name, - commit, + commit: FileCommit { + id: commit_id, + summary: String::new(), + description: None, + author: String::new(), + time: 0, + }, diff: None, loading: true, error: None, tree_state, selected_file: None, + rows: Vec::new(), + item_sizes: Rc::new(Vec::new()), + scroll_handle: VirtualListScrollHandle::new(), tasks: Vec::new(), } } - /// Load the commit diff on a background task and populate the tree. + /// Load the commit diff (and the full commit metadata) on a background + /// task and populate the tree. fn load(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; @@ -89,13 +127,27 @@ impl CommitDiffView { let id = self.commit.id.clone(); let task = cx.spawn_in(window, async move |this, cx| { - let result = cx - .background_spawn(async move { signed_git::worktree_commit_diff(&worktree, &id) }) + let commit = cx + .background_spawn({ + let worktree = worktree.clone(); + let id = id.clone(); + async move { signed_git::worktree_commit(&worktree, &id) } + }) + .await; + let diff = cx + .background_spawn({ + let worktree = worktree.clone(); + let id = id.clone(); + async move { signed_git::worktree_commit_diff(&worktree, &id) } + }) .await; this.update_in(cx, |this, _window, cx| { this.loading = false; - match result { + if let Ok(Some(commit)) = commit { + this.commit = commit; + } + match diff { Ok(diff) => { let mut paths: Vec = diff .files @@ -113,8 +165,11 @@ impl CommitDiffView { let item = find_item(&items, first.as_deref()); state.set_selected_item(item, cx); }); - this.selected_file = first; + this.selected_file = first.clone(); this.diff = Some(diff); + if let Some(path) = first { + this.set_diff_rows(path.as_ref()); + } } Err(error) => { this.error = Some(error.to_string().into()); @@ -126,20 +181,28 @@ impl CommitDiffView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } /// Show the diff of the file at `path` (selected in the tree). fn select_file(&mut self, path: &str, cx: &mut Context) { self.selected_file = Some(path.into()); + self.set_diff_rows(path); cx.notify(); } - /// Track `task` until it completes; finished tasks are pruned on every - /// push so the vec stays bounded by the number of in-flight loads. - fn track(&mut self, task: gpui::Task>) { - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); + /// Rebuild the virtual list state for the file at `path` and scroll back + /// to the top. + fn set_diff_rows(&mut self, path: &str) { + let Some(diff) = self.diff.as_ref() else { + return; + }; + let Some(file) = diff.files.iter().find(|file| file.path == path) else { + return; + }; + self.rows = diff_rows(file); + self.item_sizes = Rc::new(vec![size(px(0.), px(DIFF_ROW_HEIGHT)); self.rows.len()]); + self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); } /// One row of the changed-files tree: icon + name, indented by depth. @@ -149,45 +212,18 @@ impl CommitDiffView { selected: bool, view: &WeakEntity, ) -> ListItem { - let item = entry.item(); - let id = item.id.clone(); - let is_folder = entry.is_folder(); - - let icon = if is_folder { - if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::FolderClosed - } - } else { - IconName::File - }; - let view = view.clone(); + let id = entry.item().id.clone(); - ListItem::new(ix) - .pl(px(8.) + px(14.) * entry.depth() as f32) - .selected(selected) - .child( - h_flex() - .gap_2() - .overflow_hidden() - .child(Icon::new(icon).small()) - .child(div().text_sm().text_ellipsis().child(item.label.clone())), - ) - .on_click(move |_event, _window, cx| { - // Folders expand/collapse via the tree itself. - if is_folder { - return; - } - if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| this.select_file(&id, cx)); - } - }) + tree_row(ix, entry, selected, move |_window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.select_file(&id, cx)); + } + }) } /// Left column: the changed-files tree. - fn render_tree_column(&mut self, cx: &mut Context) -> AnyElement { + fn render_tree_column(&self, cx: &mut Context) -> AnyElement { let tree_state = self.tree_state.clone(); let view = cx.entity().downgrade(); @@ -210,26 +246,14 @@ impl CommitDiffView { ) }) .when(self.diff.is_none() && !self.loading, |this| { - this.child( - v_flex() - .size_full() - .items_center() - .justify_center() - .p_4() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Failed to load diff"), - ), - ) + this.child(placeholder("Failed to load diff", cx)) }), ) .into_any_element() } /// Right column: header of the selected file plus its diff. - fn render_detail_column(&mut self, cx: &mut Context) -> AnyElement { + fn render_detail_column(&self, cx: &mut Context) -> AnyElement { if self.loading { return v_flex() .size_full() @@ -254,11 +278,12 @@ impl CommitDiffView { let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else { return placeholder("File not found", cx); }; - self.render_file_diff(file, cx) + self.render_file_diff(file, cx.entity(), cx) } - /// The diff of one file: a header with status and stats, then the hunks. - fn render_file_diff(&self, file: &FileDiff, cx: &App) -> AnyElement { + /// The diff of one file: a header with status and stats, then the hunks + /// in a virtual list (a large diff is never materialized per frame). + fn render_file_diff(&self, file: &FileDiff, view: Entity, cx: &App) -> AnyElement { let status_label = match file.status { DiffStatus::Added => "A", DiffStatus::Modified => "M", @@ -282,11 +307,44 @@ impl CommitDiffView { } else if file.hunks.is_empty() { placeholder("No content changes", cx) } else { + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); v_flex() - .w_full() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .children(file.hunks.iter().map(|hunk| Self::render_hunk(hunk, cx))) + .size_full() + .relative() + .child( + v_virtual_list( + view, + "commit-diff-rows", + sizes, + move |this, range, _window, cx| { + let Some(diff) = this.diff.as_ref() else { + return Vec::new(); + }; + let Some(path) = this.selected_file.as_deref() else { + return Vec::new(); + }; + let Some(file) = diff.files.iter().find(|file| file.path == path) + else { + return Vec::new(); + }; + range + .map(|ix| Self::render_diff_row(&file.hunks, this.rows[ix], cx)) + .collect() + }, + ) + .track_scroll(&scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&scroll_handle)), + ) .into_any_element() }; @@ -335,41 +393,35 @@ impl CommitDiffView { ) }), ) - .child( - div() - .id("commit-diff-body") - .flex_1() - .min_h_0() - .overflow_scroll() - .child(body), - ) + .child(div().id("commit-diff-body").flex_1().min_h_0().child(body)) .into_any_element() } - /// One hunk: the `@@ -a,b +c,d @@` header row followed by its lines. - fn render_hunk(hunk: &DiffHunk, cx: &App) -> AnyElement { - v_flex() - .w_full() - .child( - div() - .px_2() - .py_0p5() - .w_full() - .bg(cx.theme().muted) - .border_y(px(1.)) - .border_color(cx.theme().border) - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!( - "@@ -{},{} +{},{} @@", - hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines - ))), - ) - .children( - hunk.lines - .iter() - .map(|line| Self::render_diff_line(line, cx)), - ) - .into_any_element() + /// One row of the virtual diff list: a hunk header or a single line. + fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { + match row { + DiffRow::Hunk { + old_start, + old_lines, + new_start, + new_lines, + } => div() + .px_2() + .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .bg(cx.theme().muted) + .border_y(px(1.)) + .border_color(cx.theme().border) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!( + "@@ -{},{} +{},{} @@", + old_start, old_lines, new_start, new_lines + ))) + .into_any_element(), + DiffRow::Line { hunk, line } => Self::render_diff_line(&hunks[hunk].lines[line], cx), + } } /// One diff line: old and new line numbers in gutters, then the content, @@ -382,8 +434,14 @@ impl CommitDiffView { }; let gutter = cx.theme().muted_foreground; + // Fixed height and nowrap: the virtual list assumes every row has + // the same height, so long lines are clipped instead of wrapped. h_flex() .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .items_center() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() .when_some(bg, |this, bg| this.bg(bg)) .child( div() @@ -407,6 +465,8 @@ impl CommitDiffView { div() .flex_1() .min_w_0() + .overflow_hidden() + .whitespace_nowrap() .text_color(cx.theme().foreground) .child(line.text.clone()), ) @@ -414,7 +474,7 @@ impl CommitDiffView { } /// Header: commit id, summary, author/time and overall change stats. - fn render_header(&mut self, cx: &mut Context) -> AnyElement { + fn render_header(&self, cx: &mut Context) -> AnyElement { let commit = &self.commit; let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| { ( @@ -484,10 +544,11 @@ impl CommitDiffView { Tag::danger() .outline() .small() - .child(format!("- {insertions}")), + .child(format!("- {deletions}")), ) }), ) + .overflow_y_scrollbar() .into_any_element() } } @@ -504,6 +565,24 @@ fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem }) } +/// The rows of `file`'s diff: one header row per hunk, then its lines. +fn diff_rows(file: &FileDiff) -> Vec { + let mut rows = Vec::new(); + for (hunk_ix, hunk) in file.hunks.iter().enumerate() { + rows.push(DiffRow::Hunk { + old_start: hunk.old_start, + old_lines: hunk.old_lines, + new_start: hunk.new_start, + new_lines: hunk.new_lines, + }); + rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line { + hunk: hunk_ix, + line, + })); + } + rows +} + impl Panel for CommitDiffView { fn panel_name(&self) -> &'static str { "commit_diff" diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index 050771d..d4dbc8f 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -4,10 +4,12 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use anyhow::Error; use gpui::prelude::*; -use gpui::{AnyElement, App, div}; -use gpui_component::tree::TreeItem; -use gpui_component::{ActiveTheme, v_flex}; +use gpui::{AnyElement, App, Task, Window, div, px}; +use gpui_component::list::ListItem; +use gpui_component::tree::{TreeEntry, TreeItem}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; /// A `Send` file-tree node: the tree is built on a background thread and /// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot @@ -20,14 +22,6 @@ pub(super) struct TreeItemSeed { children: Vec, } -impl From for TreeItem { - fn from(seed: TreeItemSeed) -> Self { - let mut item = TreeItem::new(seed.id, seed.label); - item.children = seed.children.into_iter().map(Into::into).collect(); - item - } -} - /// Convert tree seeds into [`TreeItem`]s, expanding every folder when /// `expand_folders` is set. /// @@ -54,6 +48,51 @@ pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec< .collect() } +/// One row of a file tree: icon + name, indented by depth. Clicking a file +/// runs `on_click`; folders expand/collapse via the tree itself. +pub(super) fn tree_row(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem +where + F: Fn(&mut Window, &mut App) + 'static, +{ + let item = entry.item(); + let is_folder = entry.is_folder(); + + let icon = if is_folder { + if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::FolderClosed + } + } else { + IconName::File + }; + + ListItem::new(ix) + .pl(px(8.) + px(14.) * entry.depth() as f32) + .selected(selected) + .child( + h_flex() + .gap_2() + .overflow_hidden() + .child(Icon::new(icon).small()) + .child(div().text_sm().text_ellipsis().child(item.label.clone())), + ) + .on_click(move |_event, window, cx| { + // Folders expand/collapse via the tree itself. + if is_folder { + return; + } + on_click(window, cx); + }) +} + +/// Track `task` until it completes; finished tasks are pruned on every push +/// so the vec stays bounded by the number of in-flight loads. +pub(super) fn track(tasks: &mut Vec>>, task: Task>) { + tasks.retain(|task| !task.is_ready()); + tasks.push(task); +} + /// Build nested tree items from a flat, sorted (dirs-first) entry list. /// /// Returns [`TreeItemSeed`]s so the build can run off the main thread; a @@ -259,10 +298,7 @@ mod tests { PathBuf::from("README.md"), ]; - let items: Vec = build_tree_items(&entries) - .into_iter() - .map(Into::into) - .collect(); + let items: Vec = tree_items(build_tree_items(&entries), false); assert_eq!(items.len(), 2); assert_eq!(items[0].label, "src"); assert_eq!(items[0].children.len(), 1); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index d602cb1..c1274cb 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use anyhow::Error; use assets::CustomIconName; +use gix::Repository; use gpui::prelude::*; use gpui::{ AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, @@ -17,7 +18,7 @@ use gpui_component::menu::PopupMenuItem; use gpui_component::searchable_list::SearchableVec; use gpui_component::tab::{Tab, TabBar}; use gpui_component::tag::Tag; -use gpui_component::tree::{TreeItem, TreeState}; +use gpui_component::tree::TreeState; use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, }; @@ -36,7 +37,7 @@ use browser::{ }; use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; -use helpers::{build_tree_items, is_markdown_path}; +use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, track, tree_items}; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] @@ -47,6 +48,20 @@ enum RefKind { Tag, } +/// Everything loaded from the local clone for the explorer: the tree seeds, +/// README, refs and HEAD commit. Computed on a background thread (see +/// [`load_repo_data`]) and applied on the main thread. +struct RepoData { + tree: Vec, + readme_path: Option, + readme: Option>, + worktree: Option, + branches: Vec, + tags: Vec, + current_branch: Option, + head_commit: Option, +} + /// Detail view of a repository: header, stats, a file explorer with README /// preview (cloned from the announcement's `clone` URLs), and metadata. pub struct RepoDetailView { @@ -261,7 +276,11 @@ impl RepoDetailView { } } - /// Clone (or fetch) the repository and populate the file explorer. + /// Load the repository and populate the file explorer. The local clone + /// (if any) is loaded first without touching the network, so an + /// unreachable server can't block the panel; a background fetch then + /// refreshes the refs and commit list (a fetch never changes the + /// checked-out files, so the tree and previews are left alone). fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; @@ -270,115 +289,186 @@ impl RepoDetailView { let cache = GitStore::global(cx).cache().clone(); let addr = self.initial.addr(); let clone_urls: Vec = self.initial.clone.iter().map(ToString::to_string).collect(); + // Captured before the loads start: a branch/tag switch bumps it, and + // the refresh below is discarded when that happens. + let refresh_generation = self.ref_generation; - let load = cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - let entries = signed_git::worktree_entries(&repo)?; - // The tree is built off the main thread; the seeds are plain - // owned strings and convert to `TreeItem`s (which hold `Rc` - // state) on the main thread. - let tree = build_tree_items(&entries); - let readme_path = signed_git::find_readme(&repo)?; - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(&repo, path)?, - None => None, - }; - let worktree = repo.workdir().map(Path::to_path_buf); - // Ref listing is auxiliary UI: a broken ref must not prevent the - // explorer from loading, so failures degrade to empty selectors. - let (branches, tags, current_branch) = match &worktree { - Some(worktree) => ( - signed_git::worktree_branches(worktree).unwrap_or_default(), - signed_git::worktree_tags(worktree).unwrap_or_default(), - signed_git::current_branch(&repo).unwrap_or(None), - ), - None => (Vec::new(), Vec::new(), None), - }; - let head_commit = signed_git::head_commit(&repo).unwrap_or(None); - - Ok::<_, Error>(( - tree, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - )) - }); + let disk = { + let cache = cache.clone(); + let addr = addr.clone(); + cx.background_spawn(async move { + match cache.open(&addr)? { + Some(repo) => Ok(Some(load_repo_data(&repo)?)), + None => Ok(None), + } + }) + }; let task = cx.spawn_in(window, async move |this, cx| { - let result = load.await; + let disk = disk.await; + let had_clone = matches!(&disk, Ok(Some(_))); + + // No local clone yet: clone from the network (blocking), then load. + let data = match disk { + Ok(Some(data)) => Ok(data), + Ok(None) => { + let cache = cache.clone(); + let addr = addr.clone(); + let clone_urls = clone_urls.clone(); + cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; + load_repo_data(&repo) + }) + .await + } + Err(error) => Err(error), + }; this.update_in(cx, |this, window, cx| { - match result { - Ok(( - tree, - readme_path, - readme, - Some(worktree), - branches, - tags, - current_branch, - head_commit, - )) => { - this.worktree = Some(worktree); - this.head_commit = head_commit; - this.tree_state.update(cx, |state, cx| { - state.set_items( - tree.into_iter().map(Into::into).collect::>(), - cx, - ); - }); - - // Populate the branch/tag selectors with the local - // refs, selecting the branch HEAD points to. - let branches: Vec = - branches.into_iter().map(Into::into).collect(); - let tags: Vec = tags.into_iter().map(Into::into).collect(); - this.branch_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(branches), window, cx); - if let Some(branch) = current_branch { - let branch: SharedString = branch.into(); - state.set_selected_values(&[branch], window, cx); - } - }); - this.tag_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(tags), window, cx); - }); - - this.load_all_commits(cx); - if let Some((path, bytes)) = readme_path.zip(readme) { - this.readme_name = Some(path.to_string_lossy().into()); - this.load_commit(&path.to_string_lossy(), cx); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - } - Ok((_, _, _, None, _, _, _, _)) => { - this.error = Some("Repository has no worktree".into()); - } - Err(error) => { - this.error = Some(error.to_string().into()); - } + 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(); })?; + // Refresh the clone from the network in the background; when it + // completes, update the refs and commit list. Loads started + // before a branch/tag switch are discarded via the generation. + if !had_clone { + return Ok(()); + } + let refresh = { + let cache = cache.clone(); + let addr = addr.clone(); + cx.background_spawn(async move { + let Some(repo) = cache.open(&addr)? else { + return Ok::<_, Error>(None); + }; + // Best-effort: a failed fetch (e.g. offline) keeps the + // cached state, which is already shown. + signed_git::fetch_all(&repo).ok(); + let worktree = repo.workdir().map(Path::to_path_buf); + let (branches, tags) = match &worktree { + Some(_) => ( + signed_git::repo_branches(&repo).unwrap_or_default(), + signed_git::repo_tags(&repo).unwrap_or_default(), + ), + None => (Vec::new(), Vec::new()), + }; + let current_branch = signed_git::current_branch(&repo).unwrap_or(None); + let head_commit = signed_git::head_commit(&repo).unwrap_or(None); + Ok::<_, Error>(Some((branches, tags, current_branch, head_commit))) + }) + } + .await; + + this.update_in(cx, |this, window, cx| { + if refresh_generation != this.ref_generation { + return; + } + if let Ok(Some((branches, tags, current_branch, head_commit))) = refresh { + let branches: Vec = + branches.into_iter().map(Into::into).collect(); + let tags: Vec = tags.into_iter().map(Into::into).collect(); + this.branch_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(branches), window, cx); + if let Some(branch) = current_branch { + let branch: SharedString = branch.into(); + state.set_selected_values(&[branch], window, cx); + } + }); + this.tag_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(tags), window, cx); + }); + this.head_commit = head_commit; + // The fetch may have brought new commits: reload the list. + this.all_commits = None; + this.loading_all_commits = false; + this.load_all_commits(cx); + cx.notify(); + } + })?; + Ok(()) }); - self.track(task); + track(&mut self.tasks, task); + } + + /// Apply the loaded repository data: explorer tree, README preview, ref + /// selectors and HEAD commit, then start the commit-list walk. + fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { + let RepoData { + tree, + 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); + self.head_commit = head_commit; + self.tree_state.update(cx, |state, cx| { + state.set_items(tree_items(tree, false), cx); + }); + + // Populate the branch/tag selectors with the local refs, selecting + // the branch HEAD points to. + let branches: Vec = branches.into_iter().map(Into::into).collect(); + let tags: Vec = tags.into_iter().map(Into::into).collect(); + self.branch_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(branches), window, cx); + if let Some(branch) = current_branch { + let branch: SharedString = branch.into(); + state.set_selected_values(&[branch], window, cx); + } + }); + self.tag_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(tags), window, cx); + }); + + self.load_all_commits(cx); + if let Some((path, bytes)) = readme_path.zip(readme) { + self.readme_name = Some(path.to_string_lossy().into()); + self.load_commit(&path.to_string_lossy(), cx); + if let Ok(text) = String::from_utf8(bytes) { + self.set_markdown(None, &text, cx); + } + } } /// Preview the file at `path` (relative to the worktree root). 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) || self.loading_files.contains(path) { + if self.files.contains_key(path) { + // The file is cached, but the persistent markdown/code state may + // still hold a different file; re-point it at this one (the parse + // runs on a background task either way). Without this, the pane + // would show a spinner forever. + 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; } @@ -435,8 +525,11 @@ impl RepoDetailView { this.update_in(cx, |this, window, cx| { // The worktree was switched while this file was reading; - // the result belongs to the previous branch. + // the result belongs to the previous branch. Clear the + // in-flight marker either way, or the path could never be + // loaded again. if generation != this.ref_generation { + this.loading_files.remove(&path); return; } this.loading_files.remove(&path); @@ -473,7 +566,7 @@ impl RepoDetailView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } /// Queue `path` for the per-file commit query; requests are batched into @@ -517,18 +610,19 @@ impl RepoDetailView { .await; this.update(cx, |this, cx| { - if generation != this.ref_generation { - return; - } this.loading_commits = false; - if let Ok(found) = result { + if generation == this.ref_generation + && let Ok(found) = result + { for (path, commit) in found { this.commits .insert(path.to_string_lossy().into_owned(), commit); } } // Paths queued while the walk was in flight start the next - // batch. + // batch. A stale walk (branch switched mid-flight) must not + // strand them, so this runs under the current generation + // regardless of whether the result was applied. if !this.pending_commits.is_empty() { this.load_commits(cx); } @@ -538,7 +632,7 @@ impl RepoDetailView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } /// Walk all commits reachable from HEAD on a background task, for the @@ -562,7 +656,10 @@ impl RepoDetailView { .await; this.update(cx, |this, cx| { + // A stale walk (branch switched mid-flight) must not leave + // the flag set, or the Commits tab would spin forever. if generation != this.ref_generation { + this.loading_all_commits = false; return; } if let Ok(list) = result { @@ -577,36 +674,30 @@ impl RepoDetailView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } - /// Open a new panel showing the diff of `commit` (all files it changed, - /// with the line diff of each). Called from the Commits tab rows and the - /// latest-commit button in the header. - fn open_commit_diff( - &mut self, - commit: &FileCommit, - window: &mut Window, - cx: &mut Context, - ) { + /// Open a new panel showing the diff of `commit_id` (all files it + /// changed, with the line diff of each). Called from the Commits tab + /// rows and the latest-commit button in the header. + fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; }; - // Same display name as the repo detail panel's title. - let announcement = self.announcement.as_ref().unwrap_or(&self.initial); - let repo_name = announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())); - let panel = - cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit.clone(), window, cx)); + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; - if let Some(dock_area) = self.dock_area.upgrade() { - dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx); - }); - } + // Same display name as the repo detail panel's title. + let repo_name = self.display_name(); + + let panel = + cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); + + dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); + }); } /// Check out `name` (a branch or tag picked in the header) and refresh @@ -678,7 +769,7 @@ impl RepoDetailView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } /// Restore a selector to `previous`, or clear it (after a failed switch). @@ -760,10 +851,7 @@ impl RepoDetailView { // previous branch are gone, and with them the // expansion state. this.tree_state.update(cx, |state, cx| { - state.set_items( - tree.into_iter().map(Into::into).collect::>(), - cx, - ); + state.set_items(tree_items(tree, false), cx); }); // Drop cached previews and commits of the old branch. @@ -805,14 +893,7 @@ impl RepoDetailView { Ok(()) }); - self.track(task); - } - - /// Track `task` until it completes; finished tasks are pruned on every - /// push so the vec stays bounded by the number of in-flight loads. - fn track(&mut self, task: Task>) { - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); + track(&mut self.tasks, task); } /// Drop the oldest previews beyond the cache caps, keeping the currently @@ -844,6 +925,20 @@ impl RepoDetailView { self.commits.remove(&path); } } + + /// The latest announcement from the store, or the open-time snapshot. + fn announcement(&self) -> &Announcement { + self.announcement.as_ref().unwrap_or(&self.initial) + } + + /// Display name: the announcement's name, or the ID if no name is set. + fn display_name(&self) -> SharedString { + let announcement = self.announcement(); + announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + } } impl Panel for RepoDetailView { @@ -852,15 +947,46 @@ impl Panel for RepoDetailView { } fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let announcement = self.announcement.as_ref().unwrap_or(&self.initial); - - announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + self.display_name() } } +/// Read the worktree state of `repo` (no network): entries, README, refs +/// and HEAD commit. The tree is built off the main thread; the seeds are +/// plain owned strings and convert to `TreeItem`s (which hold `Rc` state) +/// on the main thread. +fn load_repo_data(repo: &Repository) -> Result { + let entries = signed_git::worktree_entries(repo)?; + let tree = build_tree_items(&entries); + let readme_path = signed_git::find_readme(repo)?; + let readme = match &readme_path { + Some(path) => signed_git::worktree_read(repo, path)?, + None => None, + }; + let worktree = repo.workdir().map(Path::to_path_buf); + // Ref listing is auxiliary UI: a broken ref must not prevent the + // explorer from loading, so failures degrade to empty selectors. + let (branches, tags, current_branch) = match &worktree { + Some(_) => ( + signed_git::repo_branches(repo).unwrap_or_default(), + signed_git::repo_tags(repo).unwrap_or_default(), + signed_git::current_branch(repo).unwrap_or(None), + ), + None => (Vec::new(), Vec::new(), None), + }; + let head_commit = signed_git::head_commit(repo).unwrap_or(None); + Ok(RepoData { + tree, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + }) +} + impl EventEmitter for RepoDetailView {} impl Focusable for RepoDetailView { @@ -874,250 +1000,239 @@ impl Render for RepoDetailView { let tree_state = self.tree_state.clone(); let view = cx.entity().downgrade(); - let announcement = self.announcement.as_ref().unwrap_or(&self.initial); - let relays = self.relays.clone(); - let web = self.web.clone(); - - let name = announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())); - - let description = announcement - .description - .clone() - .unwrap_or(SharedString::from("No description")); - let pane_title = self .selected_file .clone() .or_else(|| self.readme_name.clone()) .unwrap_or_else(|| "Overview".into()); - let commits_count = self.all_commits.as_ref().map(|list| list.total); - let worktree_empty = self.switching_ref || self.worktree.is_none(); - v_flex() .id("repo") .size_full() - .child( - v_flex() - .px_4() - .pt_2() - .pb_2() - .w_full() - .gap_8() - .border_b_1() - .border_color(cx.theme().border) - .child( - h_flex() - .w_full() - .gap_2() - .items_start() - .justify_between() - .child( - v_flex() - .flex_1() - .min_w_0() - .child(div().font_semibold().child(name)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .line_clamp(3) - .text_ellipsis() - .child(description), - ), - ) - .child( - h_flex() - .flex_none() - .gap_2() - .justify_end() - .child( - DropdownButton::new("relays") - .button( - Button::new("relay-trigger") - .label(format!("{} relays", relays.len())) - .ghost(), - ) - .dropdown_menu(move |menu, _window, _cx| { - let mut menu = menu; - if relays.is_empty() { - return menu.item( - PopupMenuItem::new("No relays") - .disabled(true), - ); - } - for relay in relays.iter() { - let url = relay.to_string(); - menu = menu.item( - PopupMenuItem::new(url.clone()).on_click( - move |_, _, cx| { - cx.write_to_clipboard( - ClipboardItem::new_string( - url.clone(), - ), - ); - }, - ), - ); - } - menu - }), - ) - .child( - DropdownButton::new("web") - .button( - Button::new("web-trigger") - .label("Websites") - .ghost(), - ) - .dropdown_menu(move |menu, _window, _cx| { - let mut menu = menu; - if web.is_empty() { - return menu.item( - PopupMenuItem::new("No web").disabled(true), - ); - } - for url in web.iter() { - let href = url.to_string(); - menu = menu.item( - PopupMenuItem::new(href.clone()).on_click( - move |_, _, cx| { - cx.open_url(&href); - }, - ), - ); - } - menu - }), - ) - .child( - Button::new("link") - .icon(IconName::ExternalLink) - .tooltip("Open in gitworkshop.dev") - .secondary(), - ) - .child( - Button::new("clone") - .icon(CustomIconName::GitClone) - .tooltip("Clone") - .primary(), - ), - ), - ) - .child( - h_flex() - .items_center() - .child( - TabBar::new("repo-tabs") - .segmented() - .selected_index(self.active_tab) - .child(Tab::new().label("Files")) - .child(Tab::new().label("Commits").when_some( - commits_count, - |this, count| { - this.suffix( - Tag::secondary() - .xsmall() - .mr_1() - .child(SharedString::from(count.to_string())), - ) - }, - )) - .on_click(cx.listener(|this, index, _window, cx| { - this.active_tab = *index; - cx.notify(); - })), - ) - .child( - h_flex() - .flex_1() - .gap_2() - .justify_end() - .child( - div().w(px(120.)).child( - Combobox::new(&self.branch_select) - .placeholder("Branch") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - Self::render_ref_trigger( - ctx, - CustomIconName::GitBranch, - cx, - ) - }), - ), - ) - .child( - div().w(px(120.)).child( - Combobox::new(&self.tag_select) - .placeholder("Tag") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - Self::render_ref_trigger( - ctx, - CustomIconName::Tag, - cx, - ) - }), - ), - ) - .child( - Button::new("enc") - .secondary() - .when_some(self.head_commit.as_ref(), |this, commit| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(&commit.id)), - ) - .child( - div() - .max_w(px(200.)) - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .text_xs() - .child(SharedString::from(&commit.summary)), - ) - }) - .tooltip( - self.head_commit - .as_ref() - .map_or_else(SharedString::default, |commit| { - commit.summary.clone().into() - }), - ) - .on_click(cx.listener(|this, _event, window, cx| { - if let Some(commit) = &this.head_commit { - let commit = commit.clone(); - this.open_commit_diff(&commit, window, cx); - } - })), - ), - ), - ), - ) + .child(self.render_header(cx)) .child(match self.active_tab { 0 => h_flex() .flex_1() .w_full() .overflow_hidden() - .child(self.render_tree_column(tree_state, view, cx)) + .child(Self::render_tree_column(tree_state, view, cx)) .child(self.render_content_column(pane_title, cx)) .into_any_element(), _ => self.render_commits_tab(cx), }) } } + +impl RepoDetailView { + /// Header: repository name and description, relay/web/clone buttons, the + /// Files/Commits tab bar and the branch/tag selectors with the + /// latest-commit button. + fn render_header(&self, cx: &mut Context) -> AnyElement { + let announcement = self.announcement(); + let relays = self.relays.clone(); + let web = self.web.clone(); + + let name = self.display_name(); + let description = announcement + .description + .clone() + .unwrap_or(SharedString::from("No description")); + + let commits_count = self.all_commits.as_ref().map(|list| list.total); + let worktree_empty = self.switching_ref || self.worktree.is_none(); + + v_flex() + .px_4() + .pt_2() + .pb_2() + .w_full() + .gap_8() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .w_full() + .gap_2() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .child(div().font_semibold().child(name)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .line_clamp(3) + .text_ellipsis() + .child(description), + ), + ) + .child( + h_flex() + .flex_none() + .gap_2() + .justify_end() + .child( + DropdownButton::new("relays") + .button( + Button::new("relay-trigger") + .label(format!("{} relays", relays.len())) + .ghost(), + ) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + if relays.is_empty() { + return menu.item( + PopupMenuItem::new("No relays").disabled(true), + ); + } + for relay in relays.iter() { + let url = relay.to_string(); + menu = menu.item( + PopupMenuItem::new(url.clone()).on_click( + move |_, _, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string(url.clone()), + ); + }, + ), + ); + } + menu + }), + ) + .child( + DropdownButton::new("web") + .button(Button::new("web-trigger").label("Websites").ghost()) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + if web.is_empty() { + return menu + .item(PopupMenuItem::new("No web").disabled(true)); + } + for url in web.iter() { + let href = url.to_string(); + menu = menu.item( + PopupMenuItem::new(href.clone()) + .on_click(move |_, _, cx| cx.open_url(&href)), + ); + } + menu + }), + ) + .child( + Button::new("link") + .icon(IconName::ExternalLink) + .tooltip("Open in gitworkshop.dev") + .secondary(), + ) + .child( + Button::new("clone") + .icon(CustomIconName::GitClone) + .tooltip("Clone") + .primary(), + ), + ), + ) + .child( + h_flex() + .items_center() + .child( + TabBar::new("repo-tabs") + .segmented() + .selected_index(self.active_tab) + .child(Tab::new().label("Files")) + .child(Tab::new().label("Commits").when_some( + commits_count, + |this, count| { + this.suffix( + Tag::secondary() + .xsmall() + .mr_1() + .child(SharedString::from(count.to_string())), + ) + }, + )) + .on_click(cx.listener(|this, index, _window, cx| { + this.active_tab = *index; + cx.notify(); + })), + ) + .child( + h_flex() + .flex_1() + .gap_2() + .justify_end() + .child( + div().w(px(120.)).child( + Combobox::new(&self.branch_select) + .placeholder("Branch") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + Self::render_ref_trigger( + ctx, + CustomIconName::GitBranch, + cx, + ) + }), + ), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.tag_select) + .placeholder("Tag") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + Self::render_ref_trigger(ctx, CustomIconName::Tag, cx) + }), + ), + ) + .child( + Button::new("enc") + .secondary() + .when_some(self.head_commit.as_ref(), |this, commit| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&commit.id)), + ) + .child( + div() + .max_w(px(200.)) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_xs() + .child(SharedString::from(&commit.summary)), + ) + }) + .tooltip( + self.head_commit + .as_ref() + .map_or_else(SharedString::default, |commit| { + commit.summary.clone().into() + }), + ) + .on_click(cx.listener(|this, _event, window, cx| { + if let Some(commit) = &this.head_commit { + let id = commit.id.clone(); + this.open_commit_diff(&id, window, cx); + } + })), + ), + ), + ) + .into_any_element() + } +} -- 2.54.0 From ce076bee1dd5de642db0f4c996534434d4c644ad Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 14 Aug 2026 18:04:34 +0700 Subject: [PATCH 38/64] update gpui component --- Cargo.lock | 60 +++++++++---------- .../src/views/repo_detail/browser.rs | 4 +- crates/workspace/src/views/repo_detail/mod.rs | 14 +++-- 3 files changed, 41 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce7cb52..0d4dba5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1244,7 +1244,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "gpui_util", "indexmap", @@ -1701,7 +1701,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "proc-macro2", "quote", @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "accesskit", "anyhow", @@ -3520,7 +3520,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#f43332677dd18ba2ce00f8d7d13645128da34eb0" +source = "git+https://github.com/longbridge/gpui-component#b83e4a3a950cfe28a30e4b5a73ad0dd06a821593" dependencies = [ "aho-corasick", "anyhow", @@ -3550,7 +3550,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#f43332677dd18ba2ce00f8d7d13645128da34eb0" +source = "git+https://github.com/longbridge/gpui-component#b83e4a3a950cfe28a30e4b5a73ad0dd06a821593" dependencies = [ "anyhow", "chrono", @@ -3630,7 +3630,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#f43332677dd18ba2ce00f8d7d13645128da34eb0" +source = "git+https://github.com/longbridge/gpui-component#b83e4a3a950cfe28a30e4b5a73ad0dd06a821593" dependencies = [ "anyhow", "gpui", @@ -3644,7 +3644,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#f43332677dd18ba2ce00f8d7d13645128da34eb0" +source = "git+https://github.com/longbridge/gpui-component#b83e4a3a950cfe28a30e4b5a73ad0dd06a821593" dependencies = [ "proc-macro2", "quote", @@ -3654,7 +3654,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "accesskit", "accesskit_unix", @@ -3706,7 +3706,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "accesskit", "accesskit_macos", @@ -3755,7 +3755,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3766,7 +3766,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "console_error_panic_hook", "gpui", @@ -3779,7 +3779,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "schemars", "serde", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "anyhow", "log", @@ -3799,7 +3799,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3822,7 +3822,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "anyhow", "bytemuck", @@ -3852,7 +3852,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "accesskit", "accesskit_windows", @@ -4165,7 +4165,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "anyhow", "async-compression", @@ -4185,7 +4185,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -5161,7 +5161,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "anyhow", "bindgen", @@ -6209,7 +6209,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "collections", "serde", @@ -6356,9 +6356,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -6961,7 +6961,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "derive_refineable", ] @@ -7044,7 +7044,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "anyhow", "bytes", @@ -7445,7 +7445,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "async-task", "backtrace", @@ -8168,7 +8168,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "heapless 0.9.3", "log", @@ -9517,7 +9517,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "perf", "quote", @@ -11301,7 +11301,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "anyhow", "chrono", @@ -11318,7 +11318,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" dependencies = [ "tracing", "tracing-subscriber", @@ -11329,7 +11329,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#0ad5441b5370428eaa353a36f63c50c5448eead5" +source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" [[package]] name = "zune-core" diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 3f55550..62468f0 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -298,7 +298,9 @@ impl RepoDetailView { // built-in search (Cmd/Ctrl+F) still work; editing is blocked by the // component's disabled state. Editor::new(&code.state) - .disabled(true) + .readonly(true) + .bordered(false) + .rounded_none() .h_full() .into_any_element() } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index c1274cb..adf904f 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -12,7 +12,9 @@ use gpui::{ Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, }; use gpui_component::button::{Button, ButtonVariants, DropdownButton}; -use gpui_component::combobox::{Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerCtx}; +use gpui_component::combobox::{ + Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, +}; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; use gpui_component::menu::PopupMenuItem; use gpui_component::searchable_list::SearchableVec; @@ -791,7 +793,7 @@ impl RepoDetailView { /// default trigger entirely, which is the only way to show an icon /// inside the trigger label. fn render_ref_trigger( - ctx: &ComboboxTriggerCtx>, + ctx: &ComboboxTriggerContext>, icon: CustomIconName, cx: &App, ) -> AnyElement { @@ -810,16 +812,16 @@ impl RepoDetailView { .overflow_hidden() .text_ellipsis() .whitespace_nowrap() - .when(ctx.selection.is_empty(), |this| this.text_color(muted)) + .when(ctx.selection().is_empty(), |this| this.text_color(muted)) .child( - ctx.selection + ctx.selection() .first() .map(|(_, item)| item.clone()) - .or_else(|| ctx.placeholder.cloned()) + .or_else(|| ctx.placeholder().cloned()) .unwrap_or_default(), ), ) - .child(Caret::new(ctx.size).text_color(muted)) + .child(Caret::new(ctx.size()).text_color(muted)) .into_any_element() } -- 2.54.0 From 2d281dfbbb3201056f43eb851d7cf61cf7e32688 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 15 Aug 2026 11:00:55 +0700 Subject: [PATCH 39/64] update --- .../src/views/repo_detail/browser.rs | 17 ++- crates/workspace/src/views/repo_detail/mod.rs | 141 +++++++++++++----- crates/workspace/src/views/sidebar/mod.rs | 2 +- desktop/src/main.rs | 2 +- 4 files changed, 119 insertions(+), 43 deletions(-) diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 62468f0..6720ed1 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -6,7 +6,7 @@ use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::{Editor, EditorState}; +use gpui_component::input::{Editor, EditorState, TabSize}; use gpui_component::list::ListItem; use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; @@ -280,7 +280,16 @@ impl RepoDetailView { cx: &mut Context, ) { let language = code_language(path.as_ref()).unwrap_or("text"); - let state = cx.new(|cx| EditorState::new(language, window, cx).default_value(text)); + let state = cx.new(|cx| { + EditorState::new(language, window, cx) + .default_value(text) + .tab_size(TabSize { + tab_size: 4, + hard_tabs: false, + }) + .line_number(true) + .folding(true) + }); self.code = Some(CodeView { path, state }); } @@ -294,14 +303,12 @@ impl RepoDetailView { return preview_spinner(); } - // Disabled: the preview is read-only. Selection, copy and the - // built-in search (Cmd/Ctrl+F) still work; editing is blocked by the - // component's disabled state. Editor::new(&code.state) .readonly(true) .bordered(false) .rounded_none() .h_full() + .text_sm() .into_any_element() } } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index adf904f..120ec77 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -11,6 +11,7 @@ use gpui::{ AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, }; +use gpui_component::avatar::{Avatar, AvatarGroup}; use gpui_component::button::{Button, ButtonVariants, DropdownButton}; use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, @@ -26,7 +27,7 @@ use gpui_component::{ }; use signed_core::Announcement; use signed_git::{CommitList, FileCommit}; -use signed_state::{GitStore, RepoStore}; +use signed_state::{GitStore, ProfileStore, RepoStore}; mod browser; mod commits; @@ -941,6 +942,54 @@ impl RepoDetailView { .clone() .unwrap_or_else(|| SharedString::from(announcement.id.clone())) } + + /// Horizontal list of everyone who maintains the repository: the owner + /// shown in full, and any additional maintainers as a compact overlapping avatar group. + fn render_maintainers(&self, cx: &mut Context) -> AnyElement { + let announcement = self.announcement(); + let profile_store = ProfileStore::global(cx); + + let mut seen = HashSet::new(); + let rest: Vec<_> = announcement + .maintainers + .iter() + .copied() + .filter(|key| *key != announcement.owner && seen.insert(*key)) + .collect(); + + let owner = profile_store.read(cx).get(&announcement.owner); + let owner_name = owner.name(); + let owner_picture = owner.picture(); + + h_flex() + .w_full() + .gap_3() + .items_center() + .flex_wrap() + .child( + h_flex() + .gap_1() + .items_center() + .child( + Avatar::new() + .name(owner_name.clone()) + .when_some(owner_picture, |this, url| this.src(url)) + .small(), + ) + .child(div().text_xs().whitespace_nowrap().child(owner_name)), + ) + .when(!rest.is_empty(), |this| { + this.child(AvatarGroup::new().small().limit(5).ellipsis().children( + rest.into_iter().map(|key| { + let profile = profile_store.read(cx).get(&key); + Avatar::new() + .name(profile.name()) + .when_some(profile.picture(), |this, url| this.src(url)) + }), + )) + }) + .into_any_element() + } } impl Panel for RepoDetailView { @@ -1064,11 +1113,25 @@ impl RepoDetailView { .child(div().font_semibold().child(name)) .child( div() + .min_w_0() .text_xs() .text_color(cx.theme().muted_foreground) - .line_clamp(3) + .line_clamp(2) .text_ellipsis() .child(description), + ) + .child( + h_flex() + .mt_2() + .gap_2() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .font_semibold() + .child("Maintainers:"), + ) + .child(self.render_maintainers(cx)), ), ) .child( @@ -1081,6 +1144,7 @@ impl RepoDetailView { .button( Button::new("relay-trigger") .label(format!("{} relays", relays.len())) + .small() .ghost(), ) .dropdown_menu(move |menu, _window, _cx| { @@ -1107,7 +1171,12 @@ impl RepoDetailView { ) .child( DropdownButton::new("web") - .button(Button::new("web-trigger").label("Websites").ghost()) + .button( + Button::new("web-trigger") + .label("Websites") + .small() + .ghost(), + ) .dropdown_menu(move |menu, _window, _cx| { let mut menu = menu; if web.is_empty() { @@ -1167,41 +1236,9 @@ impl RepoDetailView { .flex_1() .gap_2() .justify_end() - .child( - div().w(px(120.)).child( - Combobox::new(&self.branch_select) - .placeholder("Branch") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - Self::render_ref_trigger( - ctx, - CustomIconName::GitBranch, - cx, - ) - }), - ), - ) - .child( - div().w(px(120.)).child( - Combobox::new(&self.tag_select) - .placeholder("Tag") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - Self::render_ref_trigger(ctx, CustomIconName::Tag, cx) - }), - ), - ) .child( Button::new("enc") - .secondary() + .ghost() .when_some(self.head_commit.as_ref(), |this, commit| { this.child( div() @@ -1232,6 +1269,38 @@ impl RepoDetailView { this.open_commit_diff(&id, window, cx); } })), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.branch_select) + .placeholder("Branch") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + Self::render_ref_trigger( + ctx, + CustomIconName::GitBranch, + cx, + ) + }), + ), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.tag_select) + .placeholder("Tag") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + Self::render_ref_trigger(ctx, CustomIconName::Tag, cx) + }), + ), ), ), ) diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 72dec76..62a1575 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -210,7 +210,7 @@ impl Render for SidebarPanel { .child(NavItem::new("explore", "Browse", IconName::Globe).on_click( cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), )) - .child(NavItem::new("search", "Saerch", IconName::Search).on_click( + .child(NavItem::new("search", "Search", IconName::Search).on_click( cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), )) .child( diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 2bc535b..d7f1aae 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -42,7 +42,7 @@ fn main() { kind: WindowKind::Normal, app_id: Some("Signed".to_owned()), titlebar: Some(TitlebarOptions { - title: Some(SharedString::new_static("Signed Platform")), + title: Some(SharedString::new_static("Signed")), traffic_light_position: Some(point(px(9.0), px(9.0))), appears_transparent: true, }), -- 2.54.0 From 9f484ea98ded1e6e9f302a16334e2f4f2cdee2ac Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 17 Aug 2026 09:13:04 +0700 Subject: [PATCH 40/64] add issues and prs button --- Cargo.lock | 126 +++-- crates/signed_core/src/model.rs | 7 + .../src/views/repo_detail/browser.rs | 9 +- crates/workspace/src/views/repo_detail/mod.rs | 452 ++++++++---------- 4 files changed, 275 insertions(+), 319 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d4dba5..33f55ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1244,7 +1244,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "gpui_util", "indexmap", @@ -1701,7 +1701,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "proc-macro2", "quote", @@ -2094,9 +2094,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixedbitset" @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "accesskit", "anyhow", @@ -3520,7 +3520,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#b83e4a3a950cfe28a30e4b5a73ad0dd06a821593" +source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" dependencies = [ "aho-corasick", "anyhow", @@ -3550,7 +3550,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#b83e4a3a950cfe28a30e4b5a73ad0dd06a821593" +source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" dependencies = [ "anyhow", "chrono", @@ -3630,7 +3630,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#b83e4a3a950cfe28a30e4b5a73ad0dd06a821593" +source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" dependencies = [ "anyhow", "gpui", @@ -3644,17 +3644,40 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#b83e4a3a950cfe28a30e4b5a73ad0dd06a821593" +source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "gpui_apple" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +dependencies = [ + "anyhow", + "block", + "cbindgen", + "cocoa 0.26.0", + "collections", + "core-foundation 0.10.1", + "core-video", + "derive_more", + "etagere", + "foreign-types", + "gpui", + "image", + "log", + "metal", + "objc", + "parking_lot", +] + [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "accesskit", "accesskit_unix", @@ -3706,7 +3729,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "accesskit", "accesskit_macos", @@ -3714,21 +3737,18 @@ dependencies = [ "async-task", "block", "block2 0.6.2", - "cbindgen", "cocoa 0.26.0", "collections", "core-foundation 0.10.1", "core-foundation-sys", "core-graphics 0.24.0", "core-text", - "core-video", "ctor", - "derive_more", "dispatch2", - "etagere", "foreign-types", "futures", "gpui", + "gpui_apple", "gpui_util", "image", "itertools 0.14.0", @@ -3755,7 +3775,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3766,7 +3786,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "console_error_panic_hook", "gpui", @@ -3779,7 +3799,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "schemars", "serde", @@ -3789,7 +3809,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "anyhow", "log", @@ -3799,7 +3819,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3822,7 +3842,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "anyhow", "bytemuck", @@ -3852,7 +3872,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "accesskit", "accesskit_windows", @@ -4165,7 +4185,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "anyhow", "async-compression", @@ -4185,7 +4205,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -4899,9 +4919,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -5161,7 +5181,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "anyhow", "bindgen", @@ -5309,9 +5329,9 @@ dependencies = [ [[package]] name = "negentropy" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" +checksum = "81c353b400a5503efdcf398f11a83fb7aa84f59f5d76fc4bf5bbc1e4f5366caa" [[package]] name = "new_debug_unreachable" @@ -5376,8 +5396,8 @@ dependencies = [ [[package]] name = "nostr" -version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" +version = "0.45.2" +source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" dependencies = [ "aes", "base64", @@ -5403,7 +5423,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" +source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" dependencies = [ "async-utility", "futures-core", @@ -5417,7 +5437,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" +source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" dependencies = [ "nostr", "opaquerr", @@ -5426,7 +5446,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" +source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" dependencies = [ "nostr", "opaquerr", @@ -5435,7 +5455,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" +source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" dependencies = [ "indexmap", "lru", @@ -5447,7 +5467,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" +source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" dependencies = [ "async-utility", "flatbuffers", @@ -5462,7 +5482,7 @@ dependencies = [ [[package]] name = "nostr-memory" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" +source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" dependencies = [ "btreecap", "nostr", @@ -5473,7 +5493,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#f0f52cf82e04824b7d3c5ac2f0bfc7bbeef37b19" +source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" dependencies = [ "async-utility", "async-wsocket", @@ -6209,7 +6229,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "collections", "serde", @@ -6961,7 +6981,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "derive_refineable", ] @@ -7044,7 +7064,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "anyhow", "bytes", @@ -7445,7 +7465,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "async-task", "backtrace", @@ -8168,7 +8188,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "heapless 0.9.3", "log", @@ -9517,7 +9537,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "perf", "quote", @@ -9533,9 +9553,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -9744,9 +9764,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -11301,7 +11321,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "anyhow", "chrono", @@ -11318,7 +11338,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" dependencies = [ "tracing", "tracing-subscriber", @@ -11329,7 +11349,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#24e25552b1259d56a6fdd7956a419ed9e8a1a25e" +source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" [[package]] name = "zune-core" @@ -11401,9 +11421,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "4.0.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" dependencies = [ "proc-macro2", "quote", diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 60ada21..633f5f8 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -78,6 +78,13 @@ impl Announcement { pub fn addr(&self) -> crate::RepoAddr { crate::repo_addr(self.owner, self.id.clone()) } + + /// The description of the repository, or a default if none is provided. + pub fn description(&self) -> SharedString { + self.description + .clone() + .unwrap_or(SharedString::from("No description")) + } } #[cfg(test)] diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 6720ed1..b6b9f2b 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -6,7 +6,7 @@ use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::{Editor, EditorState, TabSize}; +use gpui_component::input::{Editor, EditorState}; use gpui_component::list::ListItem; use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; @@ -281,12 +281,9 @@ impl RepoDetailView { ) { let language = code_language(path.as_ref()).unwrap_or("text"); let state = cx.new(|cx| { - EditorState::new(language, window, cx) + EditorState::new(window, cx) + .language(language) .default_value(text) - .tab_size(TabSize { - tab_size: 4, - hard_tabs: false, - }) .line_number(true) .folding(true) }); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 120ec77..99fcf54 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -8,16 +8,15 @@ use assets::CustomIconName; use gix::Repository; use gpui::prelude::*; use gpui::{ - AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, - Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, }; use gpui_component::avatar::{Avatar, AvatarGroup}; -use gpui_component::button::{Button, ButtonVariants, DropdownButton}; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, }; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; -use gpui_component::menu::PopupMenuItem; use gpui_component::searchable_list::SearchableVec; use gpui_component::tab::{Tab, TabBar}; use gpui_component::tag::Tag; @@ -75,13 +74,8 @@ pub struct RepoDetailView { /// Snapshot taken at open time, shown until the store's first refresh /// completes (and as a fallback while the store has no announcement). initial: Announcement, - /// Latest announcement from the store, cached so `render` (which runs - /// every frame) does not re-read and re-clone the store's copy. - announcement: Option, - /// Relay/web URLs of [`Self::announcement`] as display strings, for the - /// header dropdowns; `Rc` so the menu builders clone cheaply per frame. - relays: Rc>, - web: Rc>, + /// Per-repository nostr store (announcement, issues, PRs, statuses). + store: Entity, /// File explorer state (worktree of the local clone). tree_state: Entity, /// Root of the local clone, for reading files on demand. @@ -135,8 +129,7 @@ pub struct RepoDetailView { /// In-flight tasks; finished tasks are pruned on every push, so the vec /// stays bounded by the number of concurrent loads. tasks: Vec>>, - /// Subscriptions keeping the selectors' confirm events and the store's - /// refreshes alive. + /// Subscriptions keeping the selectors' confirm events alive. _subscriptions: Vec, } @@ -170,36 +163,7 @@ impl RepoDetailView { .searchable(true) }); - // Cache the announcement for the header: the store only changes it - // during debounced refreshes, but `render` runs every frame. The - // observe subscription owns the store for the view's lifetime. - let subscription = cx.observe(&store, |this, store, cx| { - let fresh = store.read(cx).announcement.clone(); - if this.announcement == fresh { - return; - } - this.announcement = fresh; - // The header falls back to the open-time snapshot while the - // store has no announcement; keep its dropdown lists in sync. - let announcement = this.announcement.as_ref().unwrap_or(&this.initial); - this.relays = Rc::new( - announcement - .relays - .iter() - .map(|relay| relay.to_string().into()) - .collect(), - ); - this.web = Rc::new( - announcement - .web - .iter() - .map(|url| url.to_string().into()) - .collect(), - ); - cx.notify(); - }); - - let mut subscriptions = vec![ + let subscriptions = vec![ cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { // `Change` fires only when the selection actually changed // (picking the already-selected branch emits nothing), so a @@ -218,36 +182,16 @@ impl RepoDetailView { } }), ]; - subscriptions.push(subscription); // Defer loading the repository until the window is ready. cx.defer_in(window, |this, window, cx| { this.load_repo(window, cx); }); - // Header dropdowns of the open-time snapshot, until the store's - // first refresh replaces them. - let relays = Rc::new( - initial - .relays - .iter() - .map(|relay| relay.to_string().into()) - .collect(), - ); - let web = Rc::new( - initial - .web - .iter() - .map(|url| url.to_string().into()) - .collect(), - ); - Self { initial, dock_area, - announcement: None, - relays, - web, + store, tree_state, worktree: None, md: None, @@ -693,7 +637,7 @@ impl RepoDetailView { }; // Same display name as the repo detail panel's title. - let repo_name = self.display_name(); + let repo_name = self.display_name(cx); let panel = cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); @@ -703,6 +647,18 @@ impl RepoDetailView { }); } + /// Open the issue detail view (not implemented yet). + fn open_issue_detail(&mut self, _window: &mut Window, _cx: &mut Context) { + // TODO: open a per-issue detail view in the dock area, like + // [`Self::open_commit_diff`]. + } + + /// Open the pull request detail view (not implemented yet). + fn open_pull_request_detail(&mut self, _window: &mut Window, _cx: &mut Context) { + // TODO: open a per-PR detail view in the dock area, like + // [`Self::open_commit_diff`]. + } + /// Check out `name` (a branch or tag picked in the header) and refresh /// the explorer once the switch completes. fn switch_ref( @@ -930,164 +886,31 @@ impl RepoDetailView { } /// The latest announcement from the store, or the open-time snapshot. - fn announcement(&self) -> &Announcement { - self.announcement.as_ref().unwrap_or(&self.initial) + fn announcement<'a>(&'a self, cx: &'a App) -> &'a Announcement { + self.store + .read(cx) + .announcement + .as_ref() + .unwrap_or(&self.initial) } /// Display name: the announcement's name, or the ID if no name is set. - fn display_name(&self) -> SharedString { - let announcement = self.announcement(); + fn display_name(&self, cx: &App) -> SharedString { + let announcement = self.announcement(cx); announcement .name .clone() .unwrap_or_else(|| SharedString::from(announcement.id.clone())) } - /// Horizontal list of everyone who maintains the repository: the owner - /// shown in full, and any additional maintainers as a compact overlapping avatar group. - fn render_maintainers(&self, cx: &mut Context) -> AnyElement { - let announcement = self.announcement(); - let profile_store = ProfileStore::global(cx); - - let mut seen = HashSet::new(); - let rest: Vec<_> = announcement - .maintainers - .iter() - .copied() - .filter(|key| *key != announcement.owner && seen.insert(*key)) - .collect(); - - let owner = profile_store.read(cx).get(&announcement.owner); - let owner_name = owner.name(); - let owner_picture = owner.picture(); - - h_flex() - .w_full() - .gap_3() - .items_center() - .flex_wrap() - .child( - h_flex() - .gap_1() - .items_center() - .child( - Avatar::new() - .name(owner_name.clone()) - .when_some(owner_picture, |this, url| this.src(url)) - .small(), - ) - .child(div().text_xs().whitespace_nowrap().child(owner_name)), - ) - .when(!rest.is_empty(), |this| { - this.child(AvatarGroup::new().small().limit(5).ellipsis().children( - rest.into_iter().map(|key| { - let profile = profile_store.read(cx).get(&key); - Avatar::new() - .name(profile.name()) - .when_some(profile.picture(), |this, url| this.src(url)) - }), - )) - }) - .into_any_element() - } -} - -impl Panel for RepoDetailView { - fn panel_name(&self) -> &'static str { - "repo_detail" - } - - fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.display_name() - } -} - -/// Read the worktree state of `repo` (no network): entries, README, refs -/// and HEAD commit. The tree is built off the main thread; the seeds are -/// plain owned strings and convert to `TreeItem`s (which hold `Rc` state) -/// on the main thread. -fn load_repo_data(repo: &Repository) -> Result { - let entries = signed_git::worktree_entries(repo)?; - let tree = build_tree_items(&entries); - let readme_path = signed_git::find_readme(repo)?; - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(repo, path)?, - None => None, - }; - let worktree = repo.workdir().map(Path::to_path_buf); - // Ref listing is auxiliary UI: a broken ref must not prevent the - // explorer from loading, so failures degrade to empty selectors. - let (branches, tags, current_branch) = match &worktree { - Some(_) => ( - signed_git::repo_branches(repo).unwrap_or_default(), - signed_git::repo_tags(repo).unwrap_or_default(), - signed_git::current_branch(repo).unwrap_or(None), - ), - None => (Vec::new(), Vec::new(), None), - }; - let head_commit = signed_git::head_commit(repo).unwrap_or(None); - Ok(RepoData { - tree, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - }) -} - -impl EventEmitter for RepoDetailView {} - -impl Focusable for RepoDetailView { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for RepoDetailView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let tree_state = self.tree_state.clone(); - let view = cx.entity().downgrade(); - - let pane_title = self - .selected_file - .clone() - .or_else(|| self.readme_name.clone()) - .unwrap_or_else(|| "Overview".into()); - - v_flex() - .id("repo") - .size_full() - .child(self.render_header(cx)) - .child(match self.active_tab { - 0 => h_flex() - .flex_1() - .w_full() - .overflow_hidden() - .child(Self::render_tree_column(tree_state, view, cx)) - .child(self.render_content_column(pane_title, cx)) - .into_any_element(), - _ => self.render_commits_tab(cx), - }) - } -} - -impl RepoDetailView { - /// Header: repository name and description, relay/web/clone buttons, the - /// Files/Commits tab bar and the branch/tag selectors with the - /// latest-commit button. fn render_header(&self, cx: &mut Context) -> AnyElement { - let announcement = self.announcement(); - let relays = self.relays.clone(); - let web = self.web.clone(); + let store = self.store.read(cx); + let announcement = store.announcement.as_ref().unwrap_or(&self.initial); + let issue_count = store.issues.len(); + let pull_request_count = store.pull_requests.len(); - let name = self.display_name(); - let description = announcement - .description - .clone() - .unwrap_or(SharedString::from("No description")); + let name = self.display_name(cx); + let description = announcement.description(); let commits_count = self.all_commits.as_ref().map(|list| list.total); let worktree_empty = self.switching_ref || self.worktree.is_none(); @@ -1140,58 +963,36 @@ impl RepoDetailView { .gap_2() .justify_end() .child( - DropdownButton::new("relays") - .button( - Button::new("relay-trigger") - .label(format!("{} relays", relays.len())) - .small() - .ghost(), + Button::new("issues") + .child( + h_flex() + .gap_2() + .text_sm() + .child(SharedString::from("Issues")) + .child(Tag::new().xsmall().child(SharedString::from( + issue_count.to_string(), + ))), ) - .dropdown_menu(move |menu, _window, _cx| { - let mut menu = menu; - if relays.is_empty() { - return menu.item( - PopupMenuItem::new("No relays").disabled(true), - ); - } - for relay in relays.iter() { - let url = relay.to_string(); - menu = menu.item( - PopupMenuItem::new(url.clone()).on_click( - move |_, _, cx| { - cx.write_to_clipboard( - ClipboardItem::new_string(url.clone()), - ); - }, - ), - ); - } - menu - }), + .outline() + .on_click(cx.listener(|this, _event, window, cx| { + this.open_issue_detail(window, cx); + })), ) .child( - DropdownButton::new("web") - .button( - Button::new("web-trigger") - .label("Websites") - .small() - .ghost(), + Button::new("prs") + .child( + h_flex() + .gap_2() + .text_sm() + .child(SharedString::from("Pull Requests")) + .child(Tag::new().xsmall().child(SharedString::from( + pull_request_count.to_string(), + ))), ) - .dropdown_menu(move |menu, _window, _cx| { - let mut menu = menu; - if web.is_empty() { - return menu - .item(PopupMenuItem::new("No web").disabled(true)); - } - for url in web.iter() { - let href = url.to_string(); - menu = menu.item( - PopupMenuItem::new(href.clone()) - .on_click(move |_, _, cx| cx.open_url(&href)), - ); - } - menu - }), + .outline() + .on_click(cx.listener(|this, _event, window, cx| { + this.open_pull_request_detail(window, cx); + })), ) .child( Button::new("link") @@ -1306,4 +1107,135 @@ impl RepoDetailView { ) .into_any_element() } + + /// Horizontal list of everyone who maintains the repository: the owner + /// shown in full, and any additional maintainers as a compact overlapping avatar group. + fn render_maintainers(&self, cx: &mut Context) -> AnyElement { + let announcement = self.announcement(cx); + let profile_store = ProfileStore::global(cx); + + let mut seen = HashSet::new(); + let rest: Vec<_> = announcement + .maintainers + .iter() + .copied() + .filter(|key| *key != announcement.owner && seen.insert(*key)) + .collect(); + + let owner = profile_store.read(cx).get(&announcement.owner); + let owner_name = owner.name(); + let owner_picture = owner.picture(); + + h_flex() + .w_full() + .gap_3() + .items_center() + .flex_wrap() + .child( + h_flex() + .gap_1() + .items_center() + .child( + Avatar::new() + .name(owner_name.clone()) + .when_some(owner_picture, |this, url| this.src(url)) + .small(), + ) + .child(div().text_xs().whitespace_nowrap().child(owner_name)), + ) + .when(!rest.is_empty(), |this| { + this.child(AvatarGroup::new().small().limit(5).ellipsis().children( + rest.into_iter().map(|key| { + let profile = profile_store.read(cx).get(&key); + Avatar::new() + .name(profile.name()) + .when_some(profile.picture(), |this, url| this.src(url)) + }), + )) + }) + .into_any_element() + } +} + +impl Panel for RepoDetailView { + fn panel_name(&self) -> &'static str { + "repo_detail" + } + + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.display_name(cx) + } +} + +impl EventEmitter for RepoDetailView {} + +impl Focusable for RepoDetailView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RepoDetailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + + let pane_title = self + .selected_file + .clone() + .or_else(|| self.readme_name.clone()) + .unwrap_or_else(|| "Overview".into()); + + v_flex() + .id("repo") + .size_full() + .child(self.render_header(cx)) + .child(match self.active_tab { + 0 => h_flex() + .flex_1() + .w_full() + .overflow_hidden() + .child(Self::render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)) + .into_any_element(), + _ => self.render_commits_tab(cx), + }) + } +} + +/// Read the worktree state of `repo` (no network): entries, README, refs +/// and HEAD commit. The tree is built off the main thread; the seeds are +/// plain owned strings and convert to `TreeItem`s (which hold `Rc` state) +/// on the main thread. +fn load_repo_data(repo: &Repository) -> Result { + let entries = signed_git::worktree_entries(repo)?; + let tree = build_tree_items(&entries); + let readme_path = signed_git::find_readme(repo)?; + let readme = match &readme_path { + Some(path) => signed_git::worktree_read(repo, path)?, + None => None, + }; + let worktree = repo.workdir().map(Path::to_path_buf); + // Ref listing is auxiliary UI: a broken ref must not prevent the + // explorer from loading, so failures degrade to empty selectors. + let (branches, tags, current_branch) = match &worktree { + Some(_) => ( + signed_git::repo_branches(repo).unwrap_or_default(), + signed_git::repo_tags(repo).unwrap_or_default(), + signed_git::current_branch(repo).unwrap_or(None), + ), + None => (Vec::new(), Vec::new(), None), + }; + let head_commit = signed_git::head_commit(repo).unwrap_or(None); + + Ok(RepoData { + tree, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + }) } -- 2.54.0 From 1159252dda35d89af63ce4e7a35bffc170828f48 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 17 Aug 2026 11:22:52 +0700 Subject: [PATCH 41/64] update issue panel --- Cargo.lock | 1 + crates/assets/assets/icons/README.md | 115 --------- .../assets/assets/icons/git-issue-close.svg | 3 + .../assets/assets/icons/git-issue-ongoing.svg | 3 + crates/assets/assets/icons/git-issue-open.svg | 3 + crates/assets/src/lib.rs | 6 + crates/signed_core/src/lib.rs | 2 +- crates/signed_core/src/model.rs | 24 ++ crates/signed_state/src/repo.rs | 19 ++ crates/workspace/Cargo.toml | 1 + .../workspace/src/views/repo_detail/issues.rs | 227 ++++++++++++++++++ crates/workspace/src/views/repo_detail/mod.rs | 23 +- crates/workspace/src/workspace.rs | 5 +- 13 files changed, 308 insertions(+), 124 deletions(-) delete mode 100644 crates/assets/assets/icons/README.md create mode 100644 crates/assets/assets/icons/git-issue-close.svg create mode 100644 crates/assets/assets/icons/git-issue-ongoing.svg create mode 100644 crates/assets/assets/icons/git-issue-open.svg create mode 100644 crates/workspace/src/views/repo_detail/issues.rs diff --git a/Cargo.lock b/Cargo.lock index 33f55ac..315a19b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10808,6 +10808,7 @@ dependencies = [ "gix", "gpui", "gpui-component", + "nostr", "signed_core", "signed_git", "signed_state", diff --git a/crates/assets/assets/icons/README.md b/crates/assets/assets/icons/README.md deleted file mode 100644 index 62d9f35..0000000 --- a/crates/assets/assets/icons/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# Icons - -Icon set for the Signed app. Each file mirrors the name of the equivalent -[gpui-component](https://github.com/longbridge/gpui-component/tree/main/crates/assets/assets/icons) -icon, but the artwork comes from -[Remix Icon](https://remixicon.com/) v4.9.1 (Apache-2.0) unless noted. - -All SVGs are 24×24 (`viewBox="0 0 24 24"`) and use `fill="currentColor"`, so -gpui renders them with the requested color. - -## Mapping (gpui-component icon → Remix Icon) - -| File | Remix Icon | -| --- | --- | -| `a-large-small.svg` | `font-size` | -| `arrow-down.svg` | `arrow-down-line` | -| `arrow-left.svg` | `arrow-left-line` | -| `arrow-right.svg` | `arrow-right-line` | -| `arrow-up.svg` | `arrow-up-line` | -| `asterisk.svg` | `asterisk` | -| `battery.svg` | `battery-line` | -| `battery-charging.svg` | `battery-charge-line` | -| `battery-low.svg` | `battery-low-line` | -| `bell.svg` | `bell-line` | -| `book-open.svg` | `book-open-line` | -| `bot.svg` | `robot-line` | -| `building-2.svg` | `building-2-line` | -| `calendar.svg` | `calendar-line` | -| `chart-pie.svg` | `pie-chart-2-line` | -| `check.svg` | `check-line` | -| `chevron-down.svg` | `arrow-down-s-line` | -| `chevron-left.svg` | `arrow-left-s-line` | -| `chevron-right.svg` | `arrow-right-s-line` | -| `chevron-up.svg` | `arrow-up-s-line` | -| `chevrons-up-down.svg` | `expand-up-down-line` | -| `circle-check.svg` | `checkbox-circle-line` | -| `circle-user.svg` | `account-circle-line` | -| `circle-x.svg` | `close-circle-line` | -| `close.svg` | `close-line` | -| `copy.svg` | `file-copy-line` | -| `cpu.svg` | `cpu-line` | -| `dash.svg` | `subtract-line` | -| `delete.svg` | `delete-bin-line` | -| `ellipsis.svg` | `more-line` | -| `ellipsis-vertical.svg` | `more-2-line` | -| `external-link.svg` | `external-link-line` | -| `eye.svg` | `eye-line` | -| `eye-off.svg` | `eye-off-line` | -| `file.svg` | `file-line` | -| `folder.svg` | `folder-line` | -| `folder-closed.svg` | `folder-2-line` | -| `folder-open.svg` | `folder-open-line` | -| `github.svg` | `github-line` | -| `globe.svg` | `global-line` | -| `hard-drive.svg` | `hard-drive-2-line` | -| `heart.svg` | `heart-line` | -| `inbox.svg` | `inbox-line` | -| `info.svg` | `information-line` | -| `layout-dashboard.svg` | `dashboard-line` | -| `loader.svg` | `loader-line` | -| `loader-circle.svg` | `loader-4-line` | -| `map.svg` | `map-2-line` | -| `maximize.svg` | `fullscreen-line` | -| `menu.svg` | `menu-line` | -| `minimize.svg` | `fullscreen-exit-line` | -| `minus.svg` | `subtract-line` | -| `moon.svg` | `moon-line` | -| `network.svg` | `share-line` | -| `palette.svg` | `palette-line` | -| `panel-bottom.svg` | `layout-bottom-line` | -| `panel-left.svg` | `layout-left-line` | -| `panel-right.svg` | `layout-right-line` | -| `pause.svg` | `pause-line` | -| `play.svg` | `play-line` | -| `plus.svg` | `add-line` | -| `redo.svg` | `corner-up-right-line` | -| `redo-2.svg` | `arrow-go-forward-line` | -| `replace.svg` | `swap-box-line` | -| `resize-corner.svg` | `corner-right-down-line` | -| `search.svg` | `search-line` | -| `settings.svg` | `settings-line` | -| `settings-2.svg` | `equalizer-line` | -| `sort-ascending.svg` | `sort-asc` | -| `sort-descending.svg` | `sort-desc` | -| `square-terminal.svg` | `terminal-box-line` | -| `star.svg` | `star-line` | -| `star-fill.svg` | `star-fill` | -| `star-off.svg` | `star-off-line` | -| `sun.svg` | `sun-line` | -| `thumbs-down.svg` | `thumb-down-line` | -| `thumbs-up.svg` | `thumb-up-line` | -| `triangle-alert.svg` | `alert-line` | -| `undo.svg` | `corner-up-left-line` | -| `undo-2.svg` | `arrow-go-back-line` | -| `user.svg` | `user-line` | -| `window-close.svg` | `close-line` | -| `window-maximize.svg` | `checkbox-blank-line` | -| `window-minimize.svg` | `subtract-line` | -| `window-restore.svg` | `picture-in-picture-line` | - -## Kept as original (no Remix equivalent) - -These have no close Remix Icon counterpart and keep the original lucide -artwork from gpui-component: - -`battery-full.svg`, `battery-medium.svg`, `battery-warning.svg`, -`case-sensitive.svg`, `frame.svg`, `gallery-vertical-end.svg`, -`heart-off.svg`, `inspector.svg`, `memory-stick.svg`, -`panel-bottom-open.svg`, `panel-left-close.svg`, `panel-left-open.svg`, -`panel-right-close.svg`, `panel-right-open.svg` - -## Licensing - -- Remix Icon SVGs: [Apache-2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License) -- Lucide SVGs (kept originals): [ISC](https://github.com/lucide-icons/lucide/blob/main/LICENSE) diff --git a/crates/assets/assets/icons/git-issue-close.svg b/crates/assets/assets/icons/git-issue-close.svg new file mode 100644 index 0000000..e41c605 --- /dev/null +++ b/crates/assets/assets/icons/git-issue-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-issue-ongoing.svg b/crates/assets/assets/icons/git-issue-ongoing.svg new file mode 100644 index 0000000..b98dd2f --- /dev/null +++ b/crates/assets/assets/icons/git-issue-ongoing.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-issue-open.svg b/crates/assets/assets/icons/git-issue-open.svg new file mode 100644 index 0000000..7f8a402 --- /dev/null +++ b/crates/assets/assets/icons/git-issue-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 75fa882..959bccf 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -52,6 +52,9 @@ pub enum CustomIconName { Filter, GlobalOn, GlobalOff, + GitIssueOpen, + GitIssueClosed, + GitIssueOngoing, GitClone, GitBranch, Tag, @@ -64,6 +67,9 @@ impl IconNamed for CustomIconName { CustomIconName::Filter => "icons/filter.svg", CustomIconName::GlobalOn => "icons/global-on.svg", CustomIconName::GlobalOff => "icons/global-off.svg", + CustomIconName::GitIssueOpen => "icons/git-issue-open.svg", + CustomIconName::GitIssueClosed => "icons/git-issue-close.svg", + CustomIconName::GitIssueOngoing => "icons/git-issue-ongoing.svg", CustomIconName::GitClone => "icons/git-clone.svg", CustomIconName::GitBranch => "icons/git-branch.svg", CustomIconName::Tag => "icons/tag.svg", diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 6736a64..338bee3 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -9,6 +9,6 @@ pub mod status; pub use addr::{RepoAddr, repo_addr}; pub use clone_url::{CloneTarget, parse_clone_url}; pub use deletions::Deletions; -pub use model::Announcement; +pub use model::{activity_subject, Announcement}; pub use state::parse_state; pub use status::{RepoStatus, references_root, resolve_status}; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 633f5f8..0be9773 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -26,6 +26,30 @@ pub struct Announcement { pub hashtags: Vec, } +/// Subject of a NIP-34 issue or pull request event: the `subject` tag, +/// falling back to the first non-empty line of the content. +pub fn activity_subject(event: &Event) -> SharedString { + let subject = event + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Subject(subject)) => Some(subject), + _ => None, + }); + + subject + .map(SharedString::from) + .or_else(|| { + event + .content + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(SharedString::from) + }) + .unwrap_or(SharedString::from("Untitled")) +} + impl Announcement { /// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing. pub fn from_event(event: &Event) -> Option { diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 0202456..8a06b37 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -265,6 +265,25 @@ impl RepoStore { signed_core::resolve_status(events, &root.pubkey, maintainers) } + /// Number of open issues: issues whose resolved status is + /// [`RepoStatus::Open`] (issues without status events default to open). + pub fn open_issue_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| self.status_of(issue) == RepoStatus::Open) + .count() + } + + /// Number of open pull requests: root PR events (not PR updates, whose + /// status is carried by the root) with a resolved status of + /// [`RepoStatus::Open`]. + pub fn open_pull_request_count(&self) -> usize { + self.pull_requests + .iter() + .filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open) + .count() + } + /// Open an issue on this repository. pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { let builder = GitIssue { diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 59949fb..933f140 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -14,5 +14,6 @@ utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true gix.workspace = true +nostr.workspace = true anyhow.workspace = true diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs new file mode 100644 index 0000000..0d115f4 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -0,0 +1,227 @@ +//! Issues panel: a bottom panel listing every issue of the repository with +//! its title, event id, author, age and status. Minimal placeholder UI; the +//! presentation is expected to be redesigned later. + +use std::rc::Rc; + +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Window, div, px, size, +}; +use gpui_component::avatar::Avatar; +use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::scroll::Scrollbar; +use gpui_component::tooltip::Tooltip; +use gpui_component::{ + ActiveTheme, Icon, Sizable, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, +}; +use nostr::prelude::Event; +use signed_core::{RepoStatus, activity_subject}; +use signed_state::{ProfileStore, RepoStore}; +use utils::relative_time; + +use super::helpers::placeholder; + +/// Height of one issue row in the virtual list: two stacked text lines +/// (14px title + 12px meta, ~1.4x line height each) plus a little padding. +const ISSUE_ROW_HEIGHT: f32 = 40.; + +/// Panel listing all issues of a repository (no filters). The list stays +/// live by reading the store during `render`. +pub struct IssuesView { + focus_handle: FocusHandle, + /// Repo store holding the issues and their statuses. + store: Entity, + /// Display name of the repository, for the panel title. + repo_name: SharedString, + /// Per-row heights of the virtual list. + item_sizes: Rc>>, + /// Issue count [`Self::item_sizes`] was built for; rebuilt on change. + issue_len: usize, + /// Virtual list state of the issues list. + scroll_handle: VirtualListScrollHandle, +} + +impl IssuesView { + pub fn new(store: Entity, repo_name: SharedString, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + store, + repo_name, + item_sizes: Rc::new(Vec::new()), + issue_len: 0, + scroll_handle: VirtualListScrollHandle::new(), + } + } + + /// One issue row: title, event id, author, age and status. + fn render_row(&self, ix: usize, issue: &Event, cx: &App) -> AnyElement { + let title = activity_subject(issue); + let id_hex = issue.id.to_hex(); + let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey); + let author = profile.name(); + let picture = profile.picture(); + let age = relative_time(issue.created_at); + let status = self.store.read(cx).status_of(issue); + + h_flex() + .id(ix) + .h(px(ISSUE_ROW_HEIGHT)) + .w_full() + .gap_4() + .px_3() + .items_start() + .child(Self::render_status(status, cx)) + .child( + v_flex() + .flex_1() + .child( + div() + .min_w_0() + .text_ellipsis() + .whitespace_nowrap() + .line_clamp(1) + .text_sm() + .child(title), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .child( + h_flex() + .gap_1() + .items_center() + .child( + Avatar::new() + .name(author.clone()) + .when_some(picture, |this, url| this.src(url)) + .xsmall(), + ) + .child(div().child(author)), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&id_hex[..8])), + ) + .child(div().child(age)), + ), + ) + .into_any_element() + } + + /// Small status tag: open (green), closed (red), applied (blue), draft (yellow). + fn render_status(status: RepoStatus, cx: &App) -> AnyElement { + let (icon, label, tooltip, bg, fg) = match status { + RepoStatus::Open => ( + CustomIconName::GitIssueOpen, + "open", + "Issue is open", + cx.theme().secondary, + cx.theme().secondary_foreground, + ), + RepoStatus::Closed => ( + CustomIconName::GitIssueClosed, + "closed", + "Issue is closed", + cx.theme().warning, + cx.theme().warning_foreground, + ), + RepoStatus::Draft => ( + CustomIconName::GitIssueOngoing, + "draft", + "Issue is draft", + cx.theme().accent, + cx.theme().accent_foreground, + ), + RepoStatus::Applied => ( + CustomIconName::GitIssueOpen, + "applied", + "Issue is completed", + cx.theme().primary, + cx.theme().primary_foreground, + ), + }; + + v_flex() + .id(label) + .flex_shrink_0() + .size_6() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(bg) + .child(Icon::new(icon).xsmall().text_color(fg)) + .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx)) + .into_any_element() + } +} + +impl Panel for IssuesView { + fn panel_name(&self) -> &'static str { + "issues" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .text_sm() + .child(SharedString::from(format!("{}/issues", self.repo_name))) + } +} + +impl EventEmitter for IssuesView {} + +impl Focusable for IssuesView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for IssuesView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let store = self.store.read(cx); + let count = store.issues.len(); + + if count == 0 { + return placeholder("No issues", cx).into_any_element(); + } + + // The virtual list's item count comes from `item_sizes`; rebuild it + // whenever the store's issue count changes. + if count != self.issue_len { + self.issue_len = count; + self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]); + } + + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); + let view = cx.entity().clone(); + + v_flex() + .relative() + .size_full() + .child( + v_virtual_list(view, "issues", sizes, move |this, range, _window, cx| { + let issues = &this.store.read(cx).issues; + range + .map(|ix| this.render_row(ix, &issues[ix], cx)) + .collect() + }) + .track_scroll(&scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&scroll_handle)), + ) + .into_any_element() + } +} diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 99fcf54..b5c11a4 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -32,6 +32,7 @@ mod browser; mod commits; mod diff; mod helpers; +mod issues; use browser::{ CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, @@ -40,6 +41,7 @@ use browser::{ use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, track, tree_items}; +use issues::IssuesView; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] @@ -217,9 +219,9 @@ impl RepoDetailView { tag_select, switching_ref: false, ref_generation: 0, - _subscriptions: subscriptions, focus_handle: cx.focus_handle(), tasks: Vec::new(), + _subscriptions: subscriptions, } } @@ -647,10 +649,17 @@ impl RepoDetailView { }); } - /// Open the issue detail view (not implemented yet). - fn open_issue_detail(&mut self, _window: &mut Window, _cx: &mut Context) { - // TODO: open a per-issue detail view in the dock area, like - // [`Self::open_commit_diff`]. + /// Open the issues panel at the bottom of the dock area. + fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let panel = cx.new(|cx| IssuesView::new(self.store.clone(), self.display_name(cx), cx)); + + dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx); + }); } /// Open the pull request detail view (not implemented yet). @@ -906,8 +915,8 @@ impl RepoDetailView { fn render_header(&self, cx: &mut Context) -> AnyElement { let store = self.store.read(cx); let announcement = store.announcement.as_ref().unwrap_or(&self.initial); - let issue_count = store.issues.len(); - let pull_request_count = store.pull_requests.len(); + let issue_count = store.open_issue_count(); + let pull_request_count = store.open_pull_request_count(); let name = self.display_name(cx); let description = announcement.description(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index c4808c5..5bca10b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -21,7 +21,10 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx)); + let dock = cx.new(|cx| { + DockArea::new("dock", Some(1), window, cx) + .panel_style(gpui_component::dock::PanelStyle::TabBar) + }); let weak_dock = dock.downgrade(); let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); -- 2.54.0 From 37d6c3ccd688f64694f60569211444ef64662d04 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 17 Aug 2026 20:27:31 +0700 Subject: [PATCH 42/64] add image cache --- Cargo.lock | 2 + crates/workspace/Cargo.toml | 2 + crates/workspace/src/image_cache.rs | 189 ++++++++++++++++++ crates/workspace/src/lib.rs | 5 +- .../workspace/src/views/repo_detail/issues.rs | 29 ++- crates/workspace/src/views/repo_detail/mod.rs | 9 +- crates/workspace/src/workspace.rs | 5 + desktop/src/main.rs | 4 + 8 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 crates/workspace/src/image_cache.rs diff --git a/Cargo.lock b/Cargo.lock index 315a19b..764ce2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10805,9 +10805,11 @@ version = "1.0.0" dependencies = [ "anyhow", "assets", + "futures", "gix", "gpui", "gpui-component", + "log", "nostr", "signed_core", "signed_git", diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 933f140..9ddc66f 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -17,3 +17,5 @@ gix.workspace = true nostr.workspace = true anyhow.workspace = true +futures.workspace = true +log.workspace = true diff --git a/crates/workspace/src/image_cache.rs b/crates/workspace/src/image_cache.rs new file mode 100644 index 0000000..ef093b8 --- /dev/null +++ b/crates/workspace/src/image_cache.rs @@ -0,0 +1,189 @@ +//! The shared image cache used by every `img` element in the app. +//! +//! Without an explicit cache, images (the profile pictures shown by +//! `Avatar`s) fall back to the window's asset cache and are retained for the +//! lifetime of the window: every avatar that was ever shown stays decoded in +//! memory. This module installs one bounded LRU cache that the app controls +//! instead: +//! +//! * The cache holds at most [`MAX_IMAGES`] entries; loading a new image +//! evicts the least recently used one. Entries remember the [`Resource`] +//! they were loaded from, so eviction drops the image from the sprite +//! atlas *and* removes the asset from the asset system — freeing the raw +//! fetched bytes alongside the decoded image. +//! * [`clear_on_release`] drops the whole cache when an image-heavy view +//! (`RepoDetailView`, `IssuesView`) is released, i.e. its panel closes. +//! * [`clear`] drops everything on demand from anywhere in the app. +//! +//! Images that get cleared are re-fetched and re-decoded the next time they +//! are rendered, so clearing trades a little bandwidth/CPU for memory. + +use std::collections::{HashMap, VecDeque}; +use std::mem::take; +use std::sync::Arc; + +use futures::FutureExt; +use gpui::{ + App, AppContext, Asset, AssetLogger, Entity, Global, ImageAssetLoader, ImageCache, + ImageCacheError, ImageCacheItem, ImageSource, RenderImage, Resource, Window, hash, +}; + +/// Upper bound on the number of images the shared cache retains. Loading a +/// new image evicts the least recently used entry once this is reached. +const MAX_IMAGES: usize = 128; + +/// Global handle to the shared image cache, installed by [`init`]. +struct SharedImageCache(Entity); + +impl Global for SharedImageCache {} + +/// Create the shared image cache and install it as a global. Call once at +/// startup (see `desktop/src/main.rs`), before any window opens. +pub fn init(cx: &mut App) -> Entity { + let cache = LruImageCache::new(MAX_IMAGES, cx); + cx.set_global(SharedImageCache(cache.clone())); + cache +} + +/// The shared image cache. Panics if [`init`] hasn't been called. +pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() +} + +/// Drop every cached image, freeing the decoded image data, the GPU textures +/// and the raw fetched bytes. Images currently on screen are re-fetched on +/// the next frame. +pub fn clear(cx: &mut App, window: &mut Window) { + let cache = global(cx); + cache.update(cx, |cache, cx| cache.clear(window, cx)); +} + +/// Clear the shared cache when `view` is released — its last strong +/// reference is gone, e.g. the user closed the panel it was rendered in. +pub fn clear_on_release(view: &Entity, window: &Window, cx: &mut App) { + let cache = global(cx).downgrade(); + cx.observe_release_in(view, window, move |_view, window, cx| { + if let Some(cache) = cache.upgrade() { + cache.update(cx, |cache, cx| cache.clear(window, cx)); + } + }) + .detach(); +} + +/// A bounded LRU image cache. +/// +/// Entries keep the [`Resource`] they were loaded from so that eviction, +/// clearing and release can also remove the asset from the asset system, +/// freeing the raw fetched bytes that would otherwise stay in memory. +pub struct LruImageCache { + max_items: usize, + /// Most recently used hashes first. + usage: VecDeque, + cache: HashMap, +} + +impl LruImageCache { + /// Create a cache that holds at most `max_items` images. Cached images + /// are dropped from every window when the cache is released. + pub fn new(max_items: usize, cx: &mut App) -> Entity { + let max_items = max_items.max(1); + cx.new(|cx| { + cx.on_release(|this: &mut Self, cx| { + for (_, entry) in take(&mut this.cache) { + unload(entry, None, cx); + } + }) + .detach(); + Self { + max_items, + usage: VecDeque::with_capacity(max_items), + cache: HashMap::with_capacity(max_items), + } + }) + } + + /// Drop every cached image and remove every cached asset. + pub fn clear(&mut self, window: &mut Window, cx: &mut App) { + self.usage.clear(); + for (_, entry) in take(&mut self.cache) { + unload(entry, Some(window), cx); + } + } +} + +/// Drop a cache entry from the sprite atlas and remove its resource from the +/// asset system, so both the decoded image and the raw fetched bytes are +/// freed. `window` restricts the atlas removal to the current window; `None` +/// removes it from all windows. +fn unload( + (mut item, resource): (ImageCacheItem, Resource), + window: Option<&mut Window>, + cx: &mut App, +) { + if let Some(Ok(image)) = item.get() { + cx.drop_image(image, window); + } + ImageSource::Resource(resource).remove_asset(cx); +} + +impl ImageCache for LruImageCache { + fn load( + &mut self, + resource: &Resource, + window: &mut Window, + cx: &mut App, + ) -> Option, ImageCacheError>> { + debug_assert_eq!(self.usage.len(), self.cache.len()); + debug_assert!(self.cache.len() <= self.max_items); + + let hash = hash(resource); + + if let Some((item, _)) = self.cache.get_mut(&hash) { + let current_ix = self + .usage + .iter() + .position(|used| *used == hash) + .expect("cache and usage list must stay in sync"); + self.usage.remove(current_ix); + self.usage.push_front(hash); + return item.get(); + } + + let fut = AssetLogger::::load(resource.clone(), cx); + let task = cx.background_executor().spawn(fut).shared(); + + if self.usage.len() >= self.max_items { + let oldest = self + .usage + .pop_back() + .expect("usage list and cache must stay in sync"); + let entry = self + .cache + .remove(&oldest) + .expect("usage list and cache must stay in sync"); + unload(entry, Some(window), cx); + } + + self.cache.insert( + hash, + (ImageCacheItem::Loading(task.clone()), resource.clone()), + ); + self.usage.push_front(hash); + + let entity = window.current_view(); + window + .spawn(cx, { + async move |cx| { + if let Err(error) = task.await { + log::error!("failed to load image into cache: {:?}", error); + } + cx.on_next_frame(move |_, cx| { + cx.notify(entity); + }); + } + }) + .detach(); + + None + } +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 7ee8808..04a39f9 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -1,11 +1,12 @@ mod views; mod workspace; -pub use views::{RepoListView, SidebarPanel}; -pub use workspace::Workspace; +pub mod image_cache; use gpui::{App, AppContext, Entity, Window}; use gpui_component::Root; +pub use views::{RepoListView, SidebarPanel}; +pub use workspace::Workspace; /// Build the root view tree. Requires `signed_state::init` and /// `gpui_component::init` to have been called first. diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index 0d115f4..f1cf180 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -24,9 +24,11 @@ use utils::relative_time; use super::helpers::placeholder; -/// Height of one issue row in the virtual list: two stacked text lines -/// (14px title + 12px meta, ~1.4x line height each) plus a little padding. -const ISSUE_ROW_HEIGHT: f32 = 40.; +/// Height of one issue row in the virtual list: 12px padding on top and +/// bottom, a 14px title line and a 24px meta line (the small avatar is the +/// tallest item). Gpui's default line height is phi (~1.62x), so the title +/// line is ~22.7px; the row totals ~71px. +const ISSUE_ROW_HEIGHT: f32 = 71.; /// Panel listing all issues of a repository (no filters). The list stays /// live by reading the store during `render`. @@ -45,7 +47,16 @@ pub struct IssuesView { } impl IssuesView { - pub fn new(store: Entity, repo_name: SharedString, cx: &mut Context) -> Self { + pub fn new( + store: Entity, + repo_name: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // Issue author avatars stay in the shared cache until the panel + // closes; free them then. + crate::image_cache::clear_on_release(&cx.entity(), window, cx); + Self { focus_handle: cx.focus_handle(), store, @@ -71,7 +82,9 @@ impl IssuesView { .h(px(ISSUE_ROW_HEIGHT)) .w_full() .gap_4() - .px_3() + .p_3() + .border_b_1() + .border_color(cx.theme().border) .items_start() .child(Self::render_status(status, cx)) .child( @@ -93,7 +106,6 @@ impl IssuesView { .child( h_flex() .gap_1() - .items_center() .child( Avatar::new() .name(author.clone()) @@ -102,6 +114,7 @@ impl IssuesView { ) .child(div().child(author)), ) + .child(SharedString::from("opened")) .child( div() .text_color(cx.theme().muted_foreground) @@ -166,9 +179,7 @@ impl Panel for IssuesView { } fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - .text_sm() - .child(SharedString::from(format!("{}/issues", self.repo_name))) + div().child(SharedString::from(format!("{}/issues", self.repo_name))) } } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index b5c11a4..7f252aa 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -142,6 +142,10 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) -> Self { + // Owner/maintainer avatars shown by this view stay in the shared + // cache until the panel closes; free them then. + crate::image_cache::clear_on_release(&cx.entity(), window, cx); + let store = cx.new(|cx| RepoStore::new(initial.addr(), cx)); let tree_state = cx.new(|cx| TreeState::new(cx)); @@ -655,10 +659,11 @@ impl RepoDetailView { return; }; - let panel = cx.new(|cx| IssuesView::new(self.store.clone(), self.display_name(cx), cx)); + let panel = + cx.new(|cx| IssuesView::new(self.store.clone(), self.display_name(cx), window, cx)); dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx); + dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); }); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 5bca10b..97fbc5b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -112,6 +112,11 @@ impl Render for Workspace { let notification_layer = Root::render_notification_layer(window, cx); div() + // All `img` elements below (avatars, …) load through the shared + // bounded LRU cache instead of the window-global asset cache, + // so images can be freed when views close or when the cache is + // full. + .image_cache(crate::image_cache::global(cx)) .id("workspace") .v_flex() .size_full() diff --git a/desktop/src/main.rs b/desktop/src/main.rs index d7f1aae..10dd8e7 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -22,6 +22,10 @@ fn main() { // Initialize theme theme::init(cx); + // Install the shared image cache so avatars can be freed when + // their views close, instead of staying in memory forever. + workspace::image_cache::init(cx); + // Initialize backend and stores (connects relays, restores session) std::fs::create_dir_all(paths::nostr_dir()).ok(); signed_state::init(paths::nostr_dir(), cx); -- 2.54.0 From 48357cfd88382474b1c9be57a0da19206ef934e6 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 18 Aug 2026 08:22:19 +0700 Subject: [PATCH 43/64] . --- .../workspace/src/views/repo_detail/issues.rs | 258 +++++++++++++++--- 1 file changed, 226 insertions(+), 32 deletions(-) diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index f1cf180..f375cba 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -1,6 +1,6 @@ //! Issues panel: a bottom panel listing every issue of the repository with -//! its title, event id, author, age and status. Minimal placeholder UI; the -//! presentation is expected to be redesigned later. +//! its title, event id, author, age and status, filterable by status via +//! the header's All/Open/Closed filter. use std::rc::Rc; @@ -11,11 +11,16 @@ use gpui::{ SharedString, Size, Window, div, px, size, }; use gpui_component::avatar::Avatar; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::form::{field, v_form}; +use gpui_component::input::{Input, InputState, Textarea, TextareaState}; use gpui_component::scroll::Scrollbar; use gpui_component::tooltip::Tooltip; use gpui_component::{ - ActiveTheme, Icon, Sizable, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, + ActiveTheme, Icon, IconName, Selectable, Sizable, StyledExt, VirtualListScrollHandle, + WindowExt, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::Event; use signed_core::{RepoStatus, activity_subject}; @@ -30,18 +35,44 @@ use super::helpers::placeholder; /// line is ~22.7px; the row totals ~71px. const ISSUE_ROW_HEIGHT: f32 = 71.; -/// Panel listing all issues of a repository (no filters). The list stays -/// live by reading the store during `render`. +/// Status filter of the issues list, chosen via the header's filter buttons. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IssueFilter { + /// Every issue, regardless of status. + All, + /// Issues whose resolved status is [`RepoStatus::Open`]. + Open, + /// Issues whose resolved status is [`RepoStatus::Closed`]. + Closed, +} + +impl IssueFilter { + /// Whether `issue` (of `store`) is included by this filter. + fn matches(self, store: &RepoStore, issue: &Event) -> bool { + match self { + Self::All => true, + Self::Open => store.status_of(issue) == RepoStatus::Open, + Self::Closed => store.status_of(issue) == RepoStatus::Closed, + } + } +} + pub struct IssuesView { focus_handle: FocusHandle, /// Repo store holding the issues and their statuses. store: Entity, /// Display name of the repository, for the panel title. repo_name: SharedString, + /// Filter selected in the header filter buttons. + filter: IssueFilter, /// Per-row heights of the virtual list. item_sizes: Rc>>, - /// Issue count [`Self::item_sizes`] was built for; rebuilt on change. + /// Number of rows [`Self::item_sizes`] was built for (the filtered + /// issue count); rebuilt on change. issue_len: usize, + /// Indices into the store's `issues` matching [`Self::filter`], rebuilt + /// every render; the virtual list renders this slice. + visible_issues: Vec, /// Virtual list state of the issues list. scroll_handle: VirtualListScrollHandle, } @@ -61,13 +92,14 @@ impl IssuesView { focus_handle: cx.focus_handle(), store, repo_name, + filter: IssueFilter::Open, item_sizes: Rc::new(Vec::new()), issue_len: 0, + visible_issues: Vec::new(), scroll_handle: VirtualListScrollHandle::new(), } } - /// One issue row: title, event id, author, age and status. fn render_row(&self, ix: usize, issue: &Event, cx: &App) -> AnyElement { let title = activity_subject(issue); let id_hex = issue.id.to_hex(); @@ -126,7 +158,6 @@ impl IssuesView { .into_any_element() } - /// Small status tag: open (green), closed (red), applied (blue), draft (yellow). fn render_status(status: RepoStatus, cx: &App) -> AnyElement { let (icon, label, tooltip, bg, fg) = match status { RepoStatus::Open => ( @@ -171,6 +202,139 @@ impl IssuesView { .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx)) .into_any_element() } + + fn render_header(&self, cx: &mut Context) -> AnyElement { + h_flex() + .w_full() + .items_center() + .gap_3() + .px_3() + .pb_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .font_semibold() + .child("Issues"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("all") + .icon(CustomIconName::GitIssueOpen) + .label("All") + .ghost() + .selected(self.filter == IssueFilter::All) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = IssueFilter::All; + cx.notify(); + })), + ) + .child( + Button::new("open") + .icon(CustomIconName::GitIssueOpen) + .label("Open") + .ghost() + .selected(self.filter == IssueFilter::Open) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = IssueFilter::Open; + cx.notify(); + })), + ) + .child( + Button::new("closed") + .icon(CustomIconName::GitIssueClosed) + .label("Closed") + .ghost() + .selected(self.filter == IssueFilter::Closed) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = IssueFilter::Closed; + cx.notify(); + })), + ), + ) + // Spacer: pushes the button to the right edge. + .child(div().flex_1()) + .child( + Button::new("new-issue") + .icon(IconName::Plus) + .label("New issue") + .primary() + .on_click(cx.listener(|this, _event, window, cx| { + open_new_issue_dialog(this.store.clone(), window, cx); + })), + ) + .into_any_element() + } +} + +/// Open the "new issue" dialog: a title and a content input that submit +/// through [`RepoStore::open_issue`] when confirmed. +fn open_new_issue_dialog(store: Entity, window: &mut Window, cx: &mut App) { + let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title")); + let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue…")); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let subject = subject.clone(); + let content = content.clone(); + let store = store.clone(); + + dialog + .width(px(520.)) + .margin_top(px(50.)) + .content(move |body, _window, _cx| { + body.child( + DialogHeader::new() + .child(DialogTitle::new().child("New issue")) + .child( + DialogDescription::new() + .child("Report a bug, ask a question, or propose a change."), + ), + ) + .child( + v_form() + .child( + field() + .label("Title") + .required(true) + .child(Input::new(&subject)), + ) + .child( + field() + .label("Content") + .child(Textarea::new(&content).h(px(160.))), + ), + ) + .child( + DialogFooter::new().justify_end().child( + Button::new("submit") + .primary() + .label("Create issue") + .tooltip("Create issue") + .on_click({ + let subject = subject.clone(); + let content = content.clone(); + let store = store.clone(); + + move |_event, window, cx| { + let subject = subject.read(cx).value().to_string(); + let content = content.read(cx).value().to_string(); + let subject = (!subject.is_empty()).then_some(subject); + + store.update(cx, |store, cx| { + store.open_issue(subject, content, cx); + }); + + window.close_dialog(cx); + } + }), + ), + ) + }) + }); } impl Panel for IssuesView { @@ -193,15 +357,25 @@ impl Focusable for IssuesView { impl Render for IssuesView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let store = self.store.read(cx); - let count = store.issues.len(); + let filter = self.filter; - if count == 0 { - return placeholder("No issues", cx).into_any_element(); - } + // Indices of the issues matching the active filter; the virtual + // list renders this filtered slice. + self.visible_issues = { + let store = self.store.read(cx); + store + .issues + .iter() + .enumerate() + .filter(|(_, issue)| filter.matches(store, issue)) + .map(|(ix, _)| ix) + .collect() + }; + + let count = self.visible_issues.len(); // The virtual list's item count comes from `item_sizes`; rebuild it - // whenever the store's issue count changes. + // whenever the filtered issue count changes. if count != self.issue_len { self.issue_len = count; self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]); @@ -212,26 +386,46 @@ impl Render for IssuesView { let view = cx.entity().clone(); v_flex() - .relative() .size_full() + .child(self.render_header(cx)) .child( - v_virtual_list(view, "issues", sizes, move |this, range, _window, cx| { - let issues = &this.store.read(cx).issues; - range - .map(|ix| this.render_row(ix, &issues[ix], cx)) - .collect() - }) - .track_scroll(&scroll_handle) - .size_full(), - ) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::vertical(&scroll_handle)), + v_flex() + .relative() + .flex_1() + .min_h_0() + .w_full() + .when(count > 0, |this| { + this.child( + v_virtual_list(view, "il", sizes, move |this, range, _window, cx| { + let issues = &this.store.read(cx).issues; + range + .map(|ix| { + let issue_ix = this.visible_issues[ix]; + this.render_row(issue_ix, &issues[issue_ix], cx) + }) + .collect() + }) + .track_scroll(&scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&scroll_handle)), + ) + }) + .when(count == 0, |this| { + let message = match filter { + IssueFilter::All => "No issues", + IssueFilter::Open => "No open issues", + IssueFilter::Closed => "No closed issues", + }; + this.child(placeholder(message, cx)) + }), ) .into_any_element() } -- 2.54.0 From 7912dd0a039e8b801807f818450e829b2e1a49b8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 18 Aug 2026 10:07:40 +0700 Subject: [PATCH 44/64] add pull request panel --- Cargo.lock | 1 + .../assets/icons/git-pull-request-closed.svg | 3 + .../assets/icons/git-pull-request-draft.svg | 3 + .../assets/icons/git-pull-request-merged.svg | 3 + .../assets/assets/icons/git-pull-request.svg | 3 + crates/assets/src/lib.rs | 8 + crates/signed_state/Cargo.toml | 2 + crates/signed_state/src/repo.rs | 62 +++ crates/workspace/src/image_cache.rs | 20 - crates/workspace/src/views/repo_detail/mod.rs | 18 +- .../src/views/repo_detail/pull_requests.rs | 470 ++++++++++++++++++ 11 files changed, 569 insertions(+), 24 deletions(-) create mode 100644 crates/assets/assets/icons/git-pull-request-closed.svg create mode 100644 crates/assets/assets/icons/git-pull-request-draft.svg create mode 100644 crates/assets/assets/icons/git-pull-request-merged.svg create mode 100644 crates/assets/assets/icons/git-pull-request.svg create mode 100644 crates/workspace/src/views/repo_detail/pull_requests.rs diff --git a/Cargo.lock b/Cargo.lock index 764ce2a..37ce9d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7902,6 +7902,7 @@ name = "signed_state" version = "1.0.0" dependencies = [ "anyhow", + "bitcoin_hashes 1.2.0", "flume 0.11.1", "gpui", "log", diff --git a/crates/assets/assets/icons/git-pull-request-closed.svg b/crates/assets/assets/icons/git-pull-request-closed.svg new file mode 100644 index 0000000..dc3def8 --- /dev/null +++ b/crates/assets/assets/icons/git-pull-request-closed.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-pull-request-draft.svg b/crates/assets/assets/icons/git-pull-request-draft.svg new file mode 100644 index 0000000..26b93ae --- /dev/null +++ b/crates/assets/assets/icons/git-pull-request-draft.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-pull-request-merged.svg b/crates/assets/assets/icons/git-pull-request-merged.svg new file mode 100644 index 0000000..cb3e92b --- /dev/null +++ b/crates/assets/assets/icons/git-pull-request-merged.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-pull-request.svg b/crates/assets/assets/icons/git-pull-request.svg new file mode 100644 index 0000000..404abd6 --- /dev/null +++ b/crates/assets/assets/icons/git-pull-request.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 959bccf..751b9f4 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -55,6 +55,10 @@ pub enum CustomIconName { GitIssueOpen, GitIssueClosed, GitIssueOngoing, + GitPullRequest, + GitPullRequestClosed, + GitPullRequestDraft, + GitPullRequestMerged, GitClone, GitBranch, Tag, @@ -70,6 +74,10 @@ impl IconNamed for CustomIconName { CustomIconName::GitIssueOpen => "icons/git-issue-open.svg", CustomIconName::GitIssueClosed => "icons/git-issue-close.svg", CustomIconName::GitIssueOngoing => "icons/git-issue-ongoing.svg", + CustomIconName::GitPullRequest => "icons/git-pull-request.svg", + CustomIconName::GitPullRequestClosed => "icons/git-pull-request-closed.svg", + CustomIconName::GitPullRequestDraft => "icons/git-pull-request-draft.svg", + CustomIconName::GitPullRequestMerged => "icons/git-pull-request-merged.svg", CustomIconName::GitClone => "icons/git-clone.svg", CustomIconName::GitBranch => "icons/git-branch.svg", CustomIconName::Tag => "icons/tag.svg", diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index 5192a08..0bd56f0 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -14,6 +14,8 @@ nostr.workspace = true nostr-sdk.workspace = true nostr-connect.workspace = true +bitcoin_hashes = "1" + gpui.workspace = true flume.workspace = true anyhow.workspace = true diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 8a06b37..a4eec77 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -297,6 +297,39 @@ impl RepoStore { self.send(builder, cx); } + /// Open a pull request on this repository: a root PR event whose content + /// is the `git format-patch` output of the proposed changes. + /// + /// The branch metadata (branch name, clone URL, merge base, root patch) + /// isn't known to the UI yet and is left empty; the proposed commit is + /// parsed from the patch's `From ` header, falling back to an + /// empty hash for hand-written content. + pub fn open_pull_request( + &mut self, + subject: Option, + content: String, + cx: &mut Context, + ) { + let current_commit = patch_current_commit(&content) + .and_then(|hex| hex.parse().ok()) + .unwrap_or_else(|| bitcoin_hashes::Sha1::from_byte_array([0u8; 20])); + + let builder = GitPullRequest { + repository: self.addr.clone(), + content, + subject, + labels: Vec::new(), + branch_name: None, + clone: Vec::new(), + current_commit, + root_patch_event: None, + merge_base: None, + } + .into_event_builder(); + + self.send(builder, cx); + } + /// Send a root patch (`git format-patch` output) to this repository. pub fn send_root_patch(&mut self, patch: String, cx: &mut Context) { let Ok(root_marker) = Tag::parse(["t", "root"]) else { @@ -356,3 +389,32 @@ where fn sort_newest_first(events: &mut [Event]) { events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); } + +/// The proposed commit of a `git format-patch` output: the `From ` +/// header on its first line. +fn patch_current_commit(patch: &str) -> Option<&str> { + let line = patch.lines().next()?; + let hex = line.strip_prefix("From ")?; + hex.split_whitespace().next().filter(|hex| hex.len() == 40) +} + +#[cfg(test)] +mod tests { + use super::patch_current_commit; + + #[test] + fn parses_format_patch_header() { + let patch = "From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001\nFrom: A \nSubject: [PATCH] fix\n\n---\n"; + assert_eq!( + patch_current_commit(patch), + Some("1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a") + ); + } + + #[test] + fn no_commit_without_header() { + assert_eq!(patch_current_commit(""), None); + assert_eq!(patch_current_commit("Subject: [PATCH] x\n\n---\n"), None); + assert_eq!(patch_current_commit("From short\n"), None); + } +} diff --git a/crates/workspace/src/image_cache.rs b/crates/workspace/src/image_cache.rs index ef093b8..a846f5f 100644 --- a/crates/workspace/src/image_cache.rs +++ b/crates/workspace/src/image_cache.rs @@ -1,23 +1,3 @@ -//! The shared image cache used by every `img` element in the app. -//! -//! Without an explicit cache, images (the profile pictures shown by -//! `Avatar`s) fall back to the window's asset cache and are retained for the -//! lifetime of the window: every avatar that was ever shown stays decoded in -//! memory. This module installs one bounded LRU cache that the app controls -//! instead: -//! -//! * The cache holds at most [`MAX_IMAGES`] entries; loading a new image -//! evicts the least recently used one. Entries remember the [`Resource`] -//! they were loaded from, so eviction drops the image from the sprite -//! atlas *and* removes the asset from the asset system — freeing the raw -//! fetched bytes alongside the decoded image. -//! * [`clear_on_release`] drops the whole cache when an image-heavy view -//! (`RepoDetailView`, `IssuesView`) is released, i.e. its panel closes. -//! * [`clear`] drops everything on demand from anywhere in the app. -//! -//! Images that get cleared are re-fetched and re-decoded the next time they -//! are rendered, so clearing trades a little bandwidth/CPU for memory. - use std::collections::{HashMap, VecDeque}; use std::mem::take; use std::sync::Arc; diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 7f252aa..5beb9e7 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -33,6 +33,7 @@ mod commits; mod diff; mod helpers; mod issues; +mod pull_requests; use browser::{ CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, @@ -42,6 +43,7 @@ use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, track, tree_items}; use issues::IssuesView; +use pull_requests::PullRequestsView; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] @@ -667,10 +669,18 @@ impl RepoDetailView { }); } - /// Open the pull request detail view (not implemented yet). - fn open_pull_request_detail(&mut self, _window: &mut Window, _cx: &mut Context) { - // TODO: open a per-PR detail view in the dock area, like - // [`Self::open_commit_diff`]. + /// Open the pull requests panel at the bottom of the dock area. + fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let panel = cx + .new(|cx| PullRequestsView::new(self.store.clone(), self.display_name(cx), window, cx)); + + dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); + }); } /// Check out `name` (a branch or tag picked in the header) and refresh diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs new file mode 100644 index 0000000..2da897a --- /dev/null +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -0,0 +1,470 @@ +//! Pull requests panel: a bottom panel listing every pull request of the +//! repository with its title, event id, author, age and status, filterable +//! by status via the header's All/Open/Closed/Draft/Merged filter. + +use std::rc::Rc; + +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Window, div, px, size, +}; +use gpui_component::avatar::Avatar; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; +use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::form::{field, v_form}; +use gpui_component::input::{Input, InputState, Textarea, TextareaState}; +use gpui_component::scroll::Scrollbar; +use gpui_component::tooltip::Tooltip; +use gpui_component::{ + ActiveTheme, Icon, IconName, Selectable, Sizable, StyledExt, VirtualListScrollHandle, + WindowExt, h_flex, v_flex, v_virtual_list, +}; +use nostr::prelude::{Event, Kind}; +use signed_core::{RepoStatus, activity_subject}; +use signed_state::{ProfileStore, RepoStore}; +use utils::relative_time; + +use super::helpers::placeholder; + +/// Height of one pull request row in the virtual list: same layout as an +/// issue row (12px padding on top and bottom, a 14px title line and a 24px +/// meta line), so the row totals ~71px. +const PR_ROW_HEIGHT: f32 = 71.; + +/// Status filter of the pull request list, chosen via the header's filter +/// buttons. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PullRequestFilter { + /// Every pull request, regardless of status. + All, + /// Pull requests whose resolved status is [`RepoStatus::Open`]. + Open, + /// Pull requests whose resolved status is [`RepoStatus::Closed`]. + Closed, + /// Pull requests whose resolved status is [`RepoStatus::Draft`]. + Draft, + /// Pull requests whose resolved status is [`RepoStatus::Applied`] + /// (i.e. merged). + Merged, +} + +impl PullRequestFilter { + /// Whether `pr` (of `store`) is included by this filter. + fn matches(self, store: &RepoStore, pr: &Event) -> bool { + match self { + Self::All => true, + Self::Open => store.status_of(pr) == RepoStatus::Open, + Self::Closed => store.status_of(pr) == RepoStatus::Closed, + Self::Draft => store.status_of(pr) == RepoStatus::Draft, + Self::Merged => store.status_of(pr) == RepoStatus::Applied, + } + } +} + +pub struct PullRequestsView { + focus_handle: FocusHandle, + /// Repo store holding the pull requests and their statuses. + store: Entity, + /// Display name of the repository, for the panel title. + repo_name: SharedString, + /// Filter selected in the header filter buttons. + filter: PullRequestFilter, + /// Per-row heights of the virtual list. + item_sizes: Rc>>, + /// Number of rows [`Self::item_sizes`] was built for (the filtered + /// pull request count); rebuilt on change. + pr_len: usize, + /// Indices into the store's `pull_requests` matching [`Self::filter`] + /// (root PR events only; updates are revisions of the root and are not + /// listed separately), rebuilt every render; the virtual list renders + /// this slice. + visible_prs: Vec, + /// Virtual list state of the pull requests list. + scroll_handle: VirtualListScrollHandle, +} + +impl PullRequestsView { + pub fn new( + store: Entity, + repo_name: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // PR author avatars stay in the shared cache until the panel + // closes; free them then. + crate::image_cache::clear_on_release(&cx.entity(), window, cx); + + Self { + focus_handle: cx.focus_handle(), + store, + repo_name, + filter: PullRequestFilter::Open, + item_sizes: Rc::new(Vec::new()), + pr_len: 0, + visible_prs: Vec::new(), + scroll_handle: VirtualListScrollHandle::new(), + } + } + + fn render_row(&self, ix: usize, pr: &Event, cx: &App) -> AnyElement { + let title = activity_subject(pr); + let id_hex = pr.id.to_hex(); + let profile = ProfileStore::global(cx).read(cx).get(&pr.pubkey); + let author = profile.name(); + let picture = profile.picture(); + let age = relative_time(pr.created_at); + let status = self.store.read(cx).status_of(pr); + + h_flex() + .id(ix) + .h(px(PR_ROW_HEIGHT)) + .w_full() + .gap_4() + .p_3() + .border_b_1() + .border_color(cx.theme().border) + .items_start() + .child(Self::render_status(status, cx)) + .child( + v_flex() + .flex_1() + .child( + div() + .min_w_0() + .text_ellipsis() + .whitespace_nowrap() + .line_clamp(1) + .text_sm() + .child(title), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .child( + h_flex() + .gap_1() + .child( + Avatar::new() + .name(author.clone()) + .when_some(picture, |this, url| this.src(url)) + .xsmall(), + ) + .child(div().child(author)), + ) + .child(SharedString::from("opened")) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&id_hex[..8])), + ) + .child(div().child(age)), + ), + ) + .into_any_element() + } + + fn render_status(status: RepoStatus, cx: &App) -> AnyElement { + let (icon, label, tooltip, bg, fg) = match status { + RepoStatus::Open => ( + CustomIconName::GitPullRequest, + "open", + "Pull request is open", + cx.theme().secondary, + cx.theme().secondary_foreground, + ), + RepoStatus::Closed => ( + CustomIconName::GitPullRequestClosed, + "closed", + "Pull request is closed", + cx.theme().warning, + cx.theme().warning_foreground, + ), + RepoStatus::Draft => ( + CustomIconName::GitPullRequestDraft, + "draft", + "Pull request is a draft", + cx.theme().accent, + cx.theme().accent_foreground, + ), + RepoStatus::Applied => ( + CustomIconName::GitPullRequestMerged, + "merged", + "Pull request is merged", + cx.theme().primary, + cx.theme().primary_foreground, + ), + }; + + v_flex() + .id(label) + .flex_shrink_0() + .size_6() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(bg) + .child(Icon::new(icon).xsmall().text_color(fg)) + .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx)) + .into_any_element() + } + + fn render_header(&self, cx: &mut Context) -> AnyElement { + h_flex() + .w_full() + .items_center() + .gap_3() + .px_3() + .pb_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .font_semibold() + .child("Pull Requests"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("all") + .icon(CustomIconName::GitPullRequest) + .label("All") + .ghost() + .selected(self.filter == PullRequestFilter::All) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::All; + cx.notify(); + })), + ) + .child( + Button::new("open") + .icon(CustomIconName::GitPullRequest) + .label("Open") + .ghost() + .selected(self.filter == PullRequestFilter::Open) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::Open; + cx.notify(); + })), + ) + .child( + Button::new("closed") + .icon(CustomIconName::GitPullRequestClosed) + .label("Closed") + .ghost() + .selected(self.filter == PullRequestFilter::Closed) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::Closed; + cx.notify(); + })), + ) + .child( + Button::new("draft") + .icon(CustomIconName::GitPullRequestDraft) + .label("Draft") + .ghost() + .selected(self.filter == PullRequestFilter::Draft) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::Draft; + cx.notify(); + })), + ) + .child( + Button::new("merged") + .icon(CustomIconName::GitPullRequestMerged) + .label("Merged") + .ghost() + .selected(self.filter == PullRequestFilter::Merged) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::Merged; + cx.notify(); + })), + ), + ) + // Spacer: pushes the button to the right edge. + .child(div().flex_1()) + .child( + Button::new("new-pr") + .icon(IconName::Plus) + .label("New pull request") + .primary() + .on_click(cx.listener(|this, _event, window, cx| { + open_new_pull_request_dialog(this.store.clone(), window, cx); + })), + ) + .into_any_element() + } +} + +/// Open the "new pull request" dialog: a title and a patch input that +/// submit through [`RepoStore::open_pull_request`] when confirmed. +fn open_new_pull_request_dialog(store: Entity, window: &mut Window, cx: &mut App) { + let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title")); + let patch = + cx.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output…")); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let subject = subject.clone(); + let patch = patch.clone(); + let store = store.clone(); + + dialog + .width(px(520.)) + .margin_top(px(50.)) + .content(move |body, _window, _cx| { + body.child( + DialogHeader::new() + .child(DialogTitle::new().child("New pull request")) + .child( + DialogDescription::new() + .child("Propose a change with the output of `git format-patch`."), + ), + ) + .child( + v_form() + .child( + field() + .label("Title") + .required(true) + .child(Input::new(&subject)), + ) + .child( + field() + .label("Patch") + .child(Textarea::new(&patch).h(px(160.))), + ), + ) + .child( + DialogFooter::new().justify_end().child( + Button::new("submit") + .primary() + .label("Create pull request") + .tooltip("Create pull request") + .on_click({ + let subject = subject.clone(); + let patch = patch.clone(); + let store = store.clone(); + + move |_event, window, cx| { + let subject = subject.read(cx).value().to_string(); + let patch = patch.read(cx).value().to_string(); + let subject = (!subject.is_empty()).then_some(subject); + + store.update(cx, |store, cx| { + store.open_pull_request(subject, patch, cx); + }); + + window.close_dialog(cx); + } + }), + ), + ) + }) + }); +} + +impl Panel for PullRequestsView { + fn panel_name(&self) -> &'static str { + "pull-requests" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().child(SharedString::from(format!( + "{}/pull-requests", + self.repo_name + ))) + } +} + +impl EventEmitter for PullRequestsView {} + +impl Focusable for PullRequestsView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PullRequestsView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let filter = self.filter; + + // Indices of the root pull requests matching the active filter + // (updates are revisions of the root and are not listed + // separately); the virtual list renders this filtered slice. + self.visible_prs = { + let store = self.store.read(cx); + store + .pull_requests + .iter() + .enumerate() + .filter(|(_, pr)| pr.kind == Kind::GitPullRequest && filter.matches(store, pr)) + .map(|(ix, _)| ix) + .collect() + }; + + let count = self.visible_prs.len(); + + // The virtual list's item count comes from `item_sizes`; rebuild it + // whenever the filtered pull request count changes. + if count != self.pr_len { + self.pr_len = count; + self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]); + } + + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); + let view = cx.entity().clone(); + + v_flex() + .size_full() + .child(self.render_header(cx)) + .child( + v_flex() + .relative() + .flex_1() + .min_h_0() + .w_full() + .when(count > 0, |this| { + this.child( + v_virtual_list(view, "prl", sizes, move |this, range, _window, cx| { + let prs = &this.store.read(cx).pull_requests; + range + .map(|ix| { + let pr_ix = this.visible_prs[ix]; + this.render_row(pr_ix, &prs[pr_ix], cx) + }) + .collect() + }) + .track_scroll(&scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&scroll_handle)), + ) + }) + .when(count == 0, |this| { + let message = match filter { + PullRequestFilter::All => "No pull requests", + PullRequestFilter::Open => "No open pull requests", + PullRequestFilter::Closed => "No closed pull requests", + PullRequestFilter::Draft => "No draft pull requests", + PullRequestFilter::Merged => "No merged pull requests", + }; + this.child(placeholder(message, cx)) + }), + ) + .into_any_element() + } +} -- 2.54.0 From 6a3e9604c90a3b528ec8ff64c4040497a6d8b59c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 19 Aug 2026 08:03:07 +0700 Subject: [PATCH 45/64] add custom dock --- Cargo.lock | 286 ++- Cargo.toml | 2 + crates/dock/Cargo.toml | 19 + crates/dock/src/dock.rs | 509 ++++ crates/dock/src/fixtures/layout.json | 261 ++ crates/dock/src/invalid_panel.rs | 58 + crates/dock/src/lib.rs | 1305 ++++++++++ crates/dock/src/panel.rs | 386 +++ crates/dock/src/resize_handle.rs | 232 ++ crates/dock/src/stack_panel.rs | 429 ++++ crates/dock/src/state.rs | 282 +++ crates/dock/src/tab_panel.rs | 2102 +++++++++++++++++ crates/dock/src/tiles.rs | 1440 +++++++++++ crates/workspace/Cargo.toml | 1 + .../workspace/src/views/repo_detail/diff.rs | 2 +- .../workspace/src/views/repo_detail/issues.rs | 2 +- crates/workspace/src/views/repo_detail/mod.rs | 2 +- .../src/views/repo_detail/pull_requests.rs | 2 +- crates/workspace/src/views/repo_list.rs | 2 +- crates/workspace/src/views/sidebar/mod.rs | 2 +- crates/workspace/src/workspace.rs | 5 +- desktop/Cargo.toml | 1 + desktop/src/main.rs | 4 + 23 files changed, 7267 insertions(+), 67 deletions(-) create mode 100644 crates/dock/Cargo.toml create mode 100644 crates/dock/src/dock.rs create mode 100644 crates/dock/src/fixtures/layout.json create mode 100644 crates/dock/src/invalid_panel.rs create mode 100644 crates/dock/src/lib.rs create mode 100644 crates/dock/src/panel.rs create mode 100644 crates/dock/src/resize_handle.rs create mode 100644 crates/dock/src/stack_panel.rs create mode 100644 crates/dock/src/state.rs create mode 100644 crates/dock/src/tab_panel.rs create mode 100644 crates/dock/src/tiles.rs diff --git a/Cargo.lock b/Cargo.lock index 37ce9d0..54b2bef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,6 +675,15 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -721,15 +730,30 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -1244,7 +1268,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "gpui_util", "indexmap", @@ -1339,6 +1363,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1690,7 +1723,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -1701,7 +1734,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "proc-macro2", "quote", @@ -1790,6 +1823,19 @@ dependencies = [ "libloading", ] +[[package]] +name = "dock" +version = "1.0.0" +dependencies = [ + "anyhow", + "gpui", + "gpui-component", + "itertools 0.13.0", + "serde", + "serde_json", + "smallvec", +] + [[package]] name = "document-features" version = "0.2.12" @@ -2037,6 +2083,17 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -3436,12 +3493,13 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "accesskit", "anyhow", "async-channel", "async-task", + "backtrace", "bindgen", "bitflags 2.13.1", "block", @@ -3466,6 +3524,7 @@ dependencies = [ "gpui_macros", "gpui_shared_string", "gpui_util", + "hdrhistogram", "heapless 0.9.3", "http_client", "image", @@ -3485,6 +3544,7 @@ dependencies = [ "pollster 0.4.0", "postage", "profiling", + "proptest", "rand 0.9.5", "raw-window-handle", "refineable", @@ -3520,7 +3580,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" +source = "git+https://github.com/longbridge/gpui-component#9e3a29dcbdebc318632bf68203f26c33e9f0e902" dependencies = [ "aho-corasick", "anyhow", @@ -3528,6 +3588,7 @@ dependencies = [ "chrono", "gpui", "gpui_macros", + "gpui_platform", "instant", "lsp-types", "objc2 0.6.4", @@ -3541,6 +3602,7 @@ dependencies = [ "serde_json", "smallvec", "smol", + "syntect", "tracing", "unicode-segmentation", "web-time", @@ -3550,7 +3612,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" +source = "git+https://github.com/longbridge/gpui-component#9e3a29dcbdebc318632bf68203f26c33e9f0e902" dependencies = [ "anyhow", "chrono", @@ -3630,7 +3692,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" +source = "git+https://github.com/longbridge/gpui-component#9e3a29dcbdebc318632bf68203f26c33e9f0e902" dependencies = [ "anyhow", "gpui", @@ -3644,7 +3706,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" +source = "git+https://github.com/longbridge/gpui-component#9e3a29dcbdebc318632bf68203f26c33e9f0e902" dependencies = [ "proc-macro2", "quote", @@ -3654,7 +3716,7 @@ dependencies = [ [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "block", @@ -3677,7 +3739,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "accesskit", "accesskit_unix", @@ -3729,7 +3791,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "accesskit", "accesskit_macos", @@ -3775,7 +3837,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3786,7 +3848,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "console_error_panic_hook", "gpui", @@ -3799,7 +3861,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "schemars", "serde", @@ -3809,7 +3871,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "log", @@ -3819,7 +3881,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3831,6 +3893,7 @@ dependencies = [ "log", "parking_lot", "raw-window-handle", + "scheduler", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -3842,7 +3905,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "bytemuck", @@ -3872,7 +3935,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "accesskit", "accesskit_windows", @@ -3909,9 +3972,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -3997,6 +4060,20 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hdrhistogram" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" +dependencies = [ + "base64", + "byteorder", + "crossbeam-channel", + "flate2", + "nom 8.0.0", + "num-traits", +] + [[package]] name = "heapless" version = "0.8.0" @@ -4185,7 +4262,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "async-compression", @@ -4205,7 +4282,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -4460,7 +4537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -5181,7 +5258,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "bindgen", @@ -5293,7 +5370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.1", "cfg-if", "cfg_aliases", @@ -5397,7 +5474,7 @@ dependencies = [ [[package]] name = "nostr" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "aes", "base64", @@ -5423,7 +5500,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "async-utility", "futures-core", @@ -5437,7 +5514,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "nostr", "opaquerr", @@ -5446,7 +5523,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "nostr", "opaquerr", @@ -5455,7 +5532,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "indexmap", "lru", @@ -5467,7 +5544,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "async-utility", "flatbuffers", @@ -5482,7 +5559,7 @@ dependencies = [ [[package]] name = "nostr-memory" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "btreecap", "nostr", @@ -5493,7 +5570,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "async-utility", "async-wsocket", @@ -6229,7 +6306,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "collections", "serde", @@ -6576,6 +6653,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proptest" +version = "1.10.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "proptest-macro", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-macro" +version = "0.5.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "psm" version = "0.1.32" @@ -6623,6 +6730,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -6660,9 +6773,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -6801,6 +6914,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -6857,7 +6979,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -6981,7 +7103,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "derive_refineable", ] @@ -7064,7 +7186,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "bytes", @@ -7410,6 +7532,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.20.1" @@ -7465,7 +7599,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "async-task", "backtrace", @@ -7474,6 +7608,7 @@ dependencies = [ "futures", "parking_lot", "rand 0.9.5", + "wasm_thread", "web-time", ] @@ -7849,6 +7984,7 @@ name = "signed" version = "1.0.0" dependencies = [ "assets", + "dock", "gpui", "gpui-component", "gpui_linux", @@ -8189,7 +8325,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "heapless 0.9.3", "log", @@ -8365,6 +8501,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.20", + "walkdir", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -8535,7 +8689,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg 0.5.15", ] @@ -9341,6 +9495,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -9538,7 +9698,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "perf", "quote", @@ -9644,6 +9804,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -9976,8 +10145,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.9.1", + "bit-vec 0.9.1", "bitflags 2.13.1", "bytemuck", "cfg_aliases", @@ -10048,7 +10217,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.1", "block2 0.6.2", "bytemuck", @@ -10806,6 +10975,7 @@ version = "1.0.0" dependencies = [ "anyhow", "assets", + "dock", "futures", "gix", "gpui", @@ -11307,9 +11477,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", @@ -11325,7 +11495,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "chrono", @@ -11342,7 +11512,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "tracing", "tracing-subscriber", @@ -11353,7 +11523,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" [[package]] name = "zune-core" @@ -11396,9 +11566,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", @@ -11412,9 +11582,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -11425,9 +11595,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9126736..05373d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } # code preview (fenced code blocks are highlighted with tree-sitter). gpui-component = { git = "https://github.com/longbridge/gpui-component", features = ["tree-sitter-languages"] } +dock = { path = "crates/dock" } + nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } nostr-memory = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/dock/Cargo.toml b/crates/dock/Cargo.toml new file mode 100644 index 0000000..423801a --- /dev/null +++ b/crates/dock/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "dock" +description = "Dock (DockArea / Dock / Panel) components vendored from gpui-component." +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +gpui.workspace = true +gpui-component.workspace = true + +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +itertools = "0.13.0" +smallvec = "1" + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/dock/src/dock.rs b/crates/dock/src/dock.rs new file mode 100644 index 0000000..87ca560 --- /dev/null +++ b/crates/dock/src/dock.rs @@ -0,0 +1,509 @@ +//! Dock is a fixed container that places at left, bottom, right of the Windows. + +use std::ops::Deref; +use std::sync::Arc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent, + MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _, + WeakEntity, Window, div, px, +}; +use gpui_component::{Side, StyledExt}; +use serde::{Deserialize, Serialize}; + +use super::{DockArea, DockEvent, DockItem, PanelView, TabPanel}; +use crate::resize_handle::{PANEL_MIN_SIZE, resize_handle}; + +#[derive(Clone)] +struct ResizePanel; + +impl Render for ResizePanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + Empty + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum DockPlacement { + #[serde(rename = "center")] + Center, + #[serde(rename = "left")] + Left, + #[serde(rename = "bottom")] + Bottom, + #[serde(rename = "right")] + Right, +} + +impl DockPlacement { + fn axis(&self) -> Axis { + match self { + Self::Left | Self::Right => Axis::Horizontal, + Self::Bottom => Axis::Vertical, + Self::Center => unreachable!(), + } + } + + pub fn is_left(&self) -> bool { + matches!(self, Self::Left) + } + + pub fn is_bottom(&self) -> bool { + matches!(self, Self::Bottom) + } + + pub fn is_right(&self) -> bool { + matches!(self, Self::Right) + } +} + +/// The Dock is a fixed container that places at left, bottom, right of the Windows. +/// +/// This is unlike Panel, it can't be move or add any other panel. +pub struct Dock { + pub(super) placement: DockPlacement, + dock_area: WeakEntity, + pub(crate) panel: DockItem, + /// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height. + pub(super) size: Pixels, + pub(super) open: bool, + /// Whether the Dock is collapsible, default: true + pub(super) collapsible: bool, + + // Runtime state + /// Whether the Dock is resizing + resizing: bool, +} + +impl Dock { + pub(crate) fn new( + dock_area: WeakEntity, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let panel = cx.new(|cx| { + let mut tab = TabPanel::new(None, dock_area.clone(), window, cx); + tab.closable = false; + tab + }); + + let panel = DockItem::Tabs { + size: None, + items: Vec::new(), + active_ix: 0, + view: panel.clone(), + }; + + Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx); + + Self { + placement, + dock_area, + panel, + open: true, + collapsible: true, + size: px(200.0), + resizing: false, + } + } + + pub fn left( + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new(dock_area, DockPlacement::Left, window, cx) + } + + pub fn bottom( + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new(dock_area, DockPlacement::Bottom, window, cx) + } + + pub fn right( + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new(dock_area, DockPlacement::Right, window, cx) + } + + /// Update the Dock to be collapsible or not. + /// + /// And if the Dock is not collapsible, it will be open. + pub fn set_collapsible(&mut self, collapsible: bool, _: &mut Window, cx: &mut Context) { + self.collapsible = collapsible; + if !collapsible { + self.open = true + } + cx.notify(); + } + + pub(super) fn from_state( + dock_area: WeakEntity, + placement: DockPlacement, + size: Pixels, + panel: DockItem, + open: bool, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx); + + if !open { + match panel.clone() { + DockItem::Tabs { view, .. } => { + view.update(cx, |panel, cx| { + panel.set_collapsed(true, window, cx); + }); + } + DockItem::Split { items, .. } => { + for item in items { + item.set_collapsed(true, window, cx); + } + } + _ => {} + } + } + + Self { + placement, + dock_area, + panel, + open, + size, + collapsible: true, + resizing: false, + } + } + + fn subscribe_panel_events( + dock_area: WeakEntity, + panel: &DockItem, + window: &mut Window, + cx: &mut Context, + ) { + match panel { + DockItem::Tabs { view, .. } => { + window.defer(cx, { + let view = view.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&view, window, cx); + }); + } + }); + } + DockItem::Split { items, view, .. } => { + for item in items { + Self::subscribe_panel_events(dock_area.clone(), item, window, cx); + } + window.defer(cx, { + let view = view.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&view, window, cx); + }); + } + }); + } + DockItem::Tiles { view, .. } => { + window.defer(cx, { + let view = view.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&view, window, cx); + }); + } + }); + } + DockItem::Panel { .. } => { + // Not supported + } + } + } + + pub fn set_panel(&mut self, panel: DockItem, _: &mut Window, cx: &mut Context) { + self.panel = panel; + cx.notify(); + } + + pub fn panel(&self) -> &DockItem { + &self.panel + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { + self.set_open(!self.open, window, cx); + } + + /// Returns the size of the Dock, the size is means the width or height of + /// the Dock, if the placement is left or right, the size is width, + /// otherwise the size is height. + pub fn size(&self) -> Pixels { + self.size + } + + /// Set the size of the Dock. + pub fn set_size(&mut self, size: Pixels, _: &mut Window, cx: &mut Context) { + self.size = size.max(PANEL_MIN_SIZE); + cx.notify(); + } + + /// Set the open state of the Dock. + pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context) { + self.open = open; + let item = self.panel.clone(); + cx.defer_in(window, move |_, window, cx| { + item.set_collapsed(!open, window, cx); + }); + cx.notify(); + } + + /// Add item to the Dock. + pub fn add_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.panel + .add_panel(panel, &self.dock_area, None, window, cx); + cx.notify(); + } + + /// Remove item from the Dock. + pub fn remove_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.panel.remove_panel(panel, window, cx); + cx.notify(); + } + + fn render_resize_handle(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let axis = self.placement.axis(); + let view = cx.entity().clone(); + + resize_handle("resize-handle", axis) + .when(self.placement == DockPlacement::Left, |this| { + this.placement(Side::Left) + }) + .on_drag(ResizePanel {}, move |info, _, _, cx| { + cx.stop_propagation(); + view.update(cx, |view, _| { + view.resizing = true; + }); + cx.new(|_| info.deref().clone()) + }) + } + + fn resize( + &mut self, + mouse_position: Point, + window: &mut Window, + cx: &mut Context, + ) { + if !self.resizing { + return; + } + + if !self.open { + self.set_open(true, window, cx); + } + + let dock_area = self + .dock_area + .upgrade() + .expect("DockArea is missing") + .read(cx); + let area_bounds = dock_area.bounds; + let mut left_dock_size = px(0.0); + let mut right_dock_size = px(0.0); + + // Get the size of the left dock if it's open and not the current dock + if let Some(left_dock) = &dock_area.left_dock + && left_dock.entity_id() != cx.entity().entity_id() + { + let left_dock_read = left_dock.read(cx); + if left_dock_read.is_open() { + left_dock_size = left_dock_read.size; + } + } + + // Get the size of the right dock if it's open and not the current dock + if let Some(right_dock) = &dock_area.right_dock + && right_dock.entity_id() != cx.entity().entity_id() + { + let right_dock_read = right_dock.read(cx); + if right_dock_read.is_open() { + right_dock_size = right_dock_read.size; + } + } + + let size = match self.placement { + DockPlacement::Left => mouse_position.x - area_bounds.left(), + DockPlacement::Right => area_bounds.right() - mouse_position.x, + DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y, + DockPlacement::Center => unreachable!(), + }; + match self.placement { + DockPlacement::Left => { + let max_size = + (area_bounds.size.width - PANEL_MIN_SIZE - right_dock_size).max(PANEL_MIN_SIZE); + self.size = size.clamp(PANEL_MIN_SIZE, max_size); + } + DockPlacement::Right => { + let max_size = + (area_bounds.size.width - PANEL_MIN_SIZE - left_dock_size).max(PANEL_MIN_SIZE); + self.size = size.clamp(PANEL_MIN_SIZE, max_size); + } + DockPlacement::Bottom => { + let max_size = (area_bounds.size.height - PANEL_MIN_SIZE).max(PANEL_MIN_SIZE); + self.size = size.clamp(PANEL_MIN_SIZE, max_size); + } + DockPlacement::Center => unreachable!(), + } + + cx.notify(); + } + + fn done_resizing(&mut self, _window: &mut Window, cx: &mut Context) { + if !self.resizing { + return; + } + self.resizing = false; + + // Dragging the dock's resize handle finished, bubble a layout change + // so subscribers can persist the new dock size. + _ = self.dock_area.update(cx, |_, cx| { + cx.emit(DockEvent::LayoutChanged); + }); + } +} + +impl Render for Dock { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl gpui::IntoElement { + if !self.open && !self.placement.is_bottom() { + return div(); + } + + let cache_style = StyleRefinement::default().absolute().size_full(); + + div() + .relative() + .overflow_hidden() + .map(|this| match self.placement { + DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size), + DockPlacement::Bottom => this.w_full().h(self.size), + DockPlacement::Center => unreachable!(), + }) + // Bottom Dock should keep the title bar, then user can click the Toggle button + .when(!self.open && self.placement.is_bottom(), |this| { + this.h(px(29.)) + }) + .map(|this| match &self.panel { + DockItem::Split { view, .. } => this.child(view.clone()), + DockItem::Tabs { view, .. } => this.child(view.clone()), + DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)), + // Not support to render Tiles and Tile into Dock + DockItem::Tiles { .. } => this, + }) + .child(self.render_resize_handle(window, cx)) + .child(DockElement { + view: cx.entity().clone(), + }) + } +} + +struct DockElement { + view: Entity, +} + +impl IntoElement for DockElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for DockElement { + type PrepaintState = (); + type RequestLayoutState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut gpui::Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), None, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _window: &mut gpui::Window, + _cx: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut gpui::Window, + cx: &mut App, + ) { + window.on_mouse_event({ + let view = self.view.clone(); + let resizing = view.read(cx).resizing; + move |e: &MouseMoveEvent, phase, window, cx| { + if !resizing { + return; + } + if !phase.bubble() { + return; + } + + view.update(cx, |view, cx| view.resize(e.position, window, cx)) + } + }); + + // When any mouse up, stop dragging + window.on_mouse_event({ + let view = self.view.clone(); + move |_: &MouseUpEvent, phase, window, cx| { + if phase.bubble() { + view.update(cx, |view, cx| view.done_resizing(window, cx)); + } + } + }) + } +} diff --git a/crates/dock/src/fixtures/layout.json b/crates/dock/src/fixtures/layout.json new file mode 100644 index 0000000..13b7a76 --- /dev/null +++ b/crates/dock/src/fixtures/layout.json @@ -0,0 +1,261 @@ +{ + "center": { + "panel_name": "StackPanel", + "children": [ + { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ButtonStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "InputStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "TextStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "SelectStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "DialogStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "SwitchStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ProgressStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "DataTableStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ImageStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "IconStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "TooltipStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ProgressStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "CalendarStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ResizableStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ScrollbarStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + }, + { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "PopupStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + } + ], + "info": { + "stack": { + "sizes": [704.0, 263.0], + "axis": 1 + } + } + }, + "left_dock": { + "panel": { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ListStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + }, + "placement": "left", + "size": 350.0, + "open": true, + "resizeable": true + }, + "right_dock": { + "panel": { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ImageStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + }, + "placement": "right", + "size": 320.0, + "open": true, + "resizeable": true + }, + "bottom_dock": { + "panel": { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "TextStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "IconStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + }, + "placement": "bottom", + "size": 200.0, + "open": true, + "resizeable": true + } +} diff --git a/crates/dock/src/invalid_panel.rs b/crates/dock/src/invalid_panel.rs new file mode 100644 index 0000000..ff5c8c7 --- /dev/null +++ b/crates/dock/src/invalid_panel.rs @@ -0,0 +1,58 @@ +use gpui::{ + App, EventEmitter, FocusHandle, Focusable, ParentElement as _, Render, SharedString, + Styled as _, Window, +}; +use gpui_component::ActiveTheme as _; + +use super::{Panel, PanelEvent, PanelState}; + +pub(crate) struct InvalidPanel { + name: SharedString, + focus_handle: FocusHandle, + old_state: PanelState, +} + +impl InvalidPanel { + pub(crate) fn new(name: &str, state: PanelState, _: &mut Window, cx: &mut App) -> Self { + Self { + focus_handle: cx.focus_handle(), + name: SharedString::from(name.to_owned()), + old_state: state, + } + } +} +impl Panel for InvalidPanel { + fn panel_name(&self) -> &'static str { + "InvalidPanel" + } + + fn dump(&self, _cx: &App) -> super::PanelState { + self.old_state.clone() + } +} +impl EventEmitter for InvalidPanel {} +impl Focusable for InvalidPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} +impl Render for InvalidPanel { + fn render( + &mut self, + _: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + gpui::div() + .size_full() + .my_6() + .flex() + .flex_col() + .items_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child(format!( + "The `{}` panel type is not registered in PanelRegistry.", + self.name.clone() + )) + } +} diff --git a/crates/dock/src/lib.rs b/crates/dock/src/lib.rs new file mode 100644 index 0000000..5f2227c --- /dev/null +++ b/crates/dock/src/lib.rs @@ -0,0 +1,1305 @@ +//! Dock (DockArea / Dock / Panel) components. +//! +//! Vendored from [`gpui-component`]'s `dock` module (v0.5.2, +//! rev 9e3a29dcbdebc318632bf68203f26c33e9f0e902) so it can be customized +//! in-tree. Everything else (Button, TabBar, menus, icons, ...) is imported +//! from `gpui-component` directly. + +mod dock; +mod invalid_panel; +mod panel; +mod resize_handle; +mod stack_panel; +mod state; +mod tab_panel; +mod tiles; + +use std::sync::Arc; + +use anyhow::Result; +pub use dock::*; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, EntityId, + EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render, + SharedString, Styled, Subscription, WeakEntity, Window, actions, div, +}; +use gpui_component::{ElementExt, Placement}; +pub use panel::*; +pub use stack_panel::*; +pub use state::*; +pub use tab_panel::*; +pub use tiles::*; + +/// Initialize the dock, registering the [`PanelRegistry`] global. +/// +/// Call this from your app entry point, before building any [`DockArea`]. +/// It is idempotent, so it is safe to call alongside `gpui_component::init`. +pub fn init(cx: &mut App) { + PanelRegistry::init(cx); +} + +// Note: the action group name must not collide with gpui-component's own +// `dock::` actions, which are linked into the same binary while the app still +// depends on gpui-component (action names are registered globally per App). +actions!(signed_dock, [ToggleZoom, ClosePanel]); + +/// Minimal i18n shim replacing gpui-component's `rust_i18n::t!()`. +/// +/// The upstream dock used `t!("Dock.*")` keys; we keep the same keys but +/// resolve them to the English strings so the crate has no i18n dependency. +pub(crate) fn t(key: &'static str) -> &'static str { + match key { + "Dock.Unnamed" => "Unnamed", + "Dock.Close" => "Close", + "Dock.Zoom In" => "Zoom In", + "Dock.Zoom Out" => "Zoom Out", + "Dock.Collapse" => "Collapse", + "Dock.Expand" => "Expand", + _ => key, + } +} + +pub enum DockEvent { + /// The layout of the dock has changed, subscribers this to save the layout. + /// + /// This event is emitted when every time the layout of the dock has changed, + /// So it emits may be too frequently, you may want to debounce the event. + LayoutChanged, + + /// A host-owned drag item ([`AnyDrag`]) was dropped inside the dock. + DragDrop { item: AnyDrag, target: DropTarget }, +} + +/// Where a host-owned drag landed, and how much the container can say about it. +#[derive(Clone, Debug)] +pub enum DropTarget { + /// Dropped on a [`Tiles`] canvas, where the landing position is just the + /// cursor position and the host can read it directly. + Canvas, + + /// Dropped on a [`TabPanel`] in a split layout. A split layout has no free + /// coordinates, so the container reports the panel and the edge it resolved + /// from the cursor instead. + /// + /// `placement` is `None` for the centre zone, meaning merge into the tab + /// group rather than split. + Panel { + tab_panel: Entity, + placement: Option, + }, +} + +/// The main area of the dock. +pub struct DockArea { + id: SharedString, + /// The version is used to special the default layout, this is like the `panel_version` in [`Panel`](Panel). + version: Option, + pub(crate) bounds: Bounds, + + /// The center view of the dock_area. + center: DockItem, + /// The left dock of the dock_area. + left_dock: Option>, + /// The bottom dock of the dock_area. + bottom_dock: Option>, + /// The right dock of the dock_area. + right_dock: Option>, + + /// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed, + toggle_button_panels: Edges>, + + /// Whether to show the toggle button. + toggle_button_visible: bool, + /// The top zoom view of the dock_area, if any. + zoom_view: Option, + + /// Lock panels layout, but allow to resize. + locked: bool, + + /// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default). + pub(crate) panel_style: PanelStyle, + + _subscriptions: Vec, +} + +/// DockItem is a tree structure that represents the layout of the dock. +#[derive(Clone)] +pub enum DockItem { + /// Split layout + Split { + axis: Axis, + /// Self size, only used for build split panels + size: Option, + items: Vec, + /// Items sizes + sizes: Vec>, + view: Entity, + }, + /// Tab layout + Tabs { + /// Self size, only used for build split panels + size: Option, + items: Vec>, + active_ix: usize, + view: Entity, + }, + /// Panel layout + Panel { + /// Self size, only used for build split panels + size: Option, + view: Arc, + }, + /// Tiles layout + Tiles { + /// Self size, only used for build split panels + size: Option, + items: Vec, + view: Entity, + }, +} + +impl std::fmt::Debug for DockItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DockItem::Split { + axis, items, sizes, .. + } => f + .debug_struct("Split") + .field("axis", axis) + .field("items", &items.len()) + .field("sizes", sizes) + .finish(), + DockItem::Tabs { + items, active_ix, .. + } => f + .debug_struct("Tabs") + .field("items", &items.len()) + .field("active_ix", active_ix) + .finish(), + DockItem::Panel { .. } => f.debug_struct("Panel").finish(), + DockItem::Tiles { .. } => f.debug_struct("Tiles").finish(), + } + } +} + +impl DockItem { + /// Get the size of the DockItem. + fn get_size(&self) -> Option { + match self { + Self::Split { size, .. } => *size, + Self::Tabs { size, .. } => *size, + Self::Panel { size, .. } => *size, + Self::Tiles { size, .. } => *size, + } + } + + /// Set size for the DockItem. + pub fn size(mut self, new_size: impl Into) -> Self { + let new_size: Option = Some(new_size.into()); + match self { + Self::Split { ref mut size, .. } => *size = new_size, + Self::Tabs { ref mut size, .. } => *size = new_size, + Self::Tiles { ref mut size, .. } => *size = new_size, + Self::Panel { ref mut size, .. } => *size = new_size, + } + self + } + + /// Set active index for the DockItem, only valid for [`DockItem::Tabs`]. + pub fn active_index(mut self, new_active_ix: usize, cx: &mut App) -> Self { + debug_assert!( + matches!(self, Self::Tabs { .. }), + "active_ix can only be set for DockItem::Tabs" + ); + + if let Self::Tabs { + ref mut active_ix, + ref mut view, + .. + } = self + { + *active_ix = new_active_ix; + view.update(cx, |tab_panel, _| { + tab_panel.active_ix = new_active_ix; + }); + } + self + } + + /// Create DockItem::Split with given split layout. + pub fn split( + axis: Axis, + items: Vec, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + let sizes = items.iter().map(|item| item.get_size()).collect(); + Self::split_with_sizes(axis, items, sizes, dock_area, window, cx) + } + + /// Create DockItem with vertical split layout. + pub fn v_split( + items: Vec, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + Self::split(Axis::Vertical, items, dock_area, window, cx) + } + + /// Create DockItem with horizontal split layout. + pub fn h_split( + items: Vec, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + Self::split(Axis::Horizontal, items, dock_area, window, cx) + } + + /// Create DockItem with split layout, each item of panel have specified size. + /// + /// Please note that the `items` and `sizes` must have the same length. + /// Set `None` in `sizes` to make the index of panel have auto size. + pub fn split_with_sizes( + axis: Axis, + items: Vec, + sizes: Vec>, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + let stack_panel = cx.new(|cx| { + let mut stack_panel = StackPanel::new(axis, window, cx); + for (i, item) in items.iter().enumerate() { + let view = item.view(); + let size = sizes.get(i).copied().flatten(); + stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx) + } + + stack_panel + }); + + window.defer(cx, { + let stack_panel = stack_panel.clone(); + let dock_area = dock_area.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&stack_panel, window, cx); + }); + } + }); + + Self::Split { + axis, + size: None, + items, + sizes, + view: stack_panel, + } + } + + /// Create DockItem with panel layout + pub fn panel(panel: Arc) -> Self { + Self::Panel { + size: None, + view: panel, + } + } + + /// Create DockItem with tiles layout + /// + /// This items and metas should have the same length. + pub fn tiles( + items: Vec, + metas: Vec + Copy>, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + assert!(items.len() == metas.len()); + + let tile_panel = cx.new(|cx| { + let mut tiles = Tiles::new(window, cx); + for (ix, item) in items.clone().into_iter().enumerate() { + match item { + DockItem::Tabs { view, .. } => { + let meta: TileMeta = metas[ix].into(); + let tile_item = + TileItem::new(Arc::new(view), meta.bounds).z_index(meta.z_index); + tiles.add_item(tile_item, dock_area, window, cx); + } + DockItem::Panel { view, .. } => { + let meta: TileMeta = metas[ix].into(); + let tile_item = + TileItem::new(view.clone(), meta.bounds).z_index(meta.z_index); + tiles.add_item(tile_item, dock_area, window, cx); + } + _ => { + // Ignore non-tabs items + } + } + } + tiles + }); + + window.defer(cx, { + let tile_panel = tile_panel.clone(); + let dock_area = dock_area.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&tile_panel, window, cx); + this.subscribe_tiles_item_drop(&tile_panel, window, cx); + }); + } + }); + + Self::Tiles { + size: None, + items: tile_panel.read(cx).panels.clone(), + view: tile_panel, + } + } + + /// Create DockItem with tabs layout, items are displayed as tabs. + /// + /// The `active_ix` is the index of the active tab, if `None` the first tab is active. + pub fn tabs( + items: Vec>, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + let mut new_items: Vec> = vec![]; + for item in items.into_iter() { + new_items.push(item) + } + Self::new_tabs(new_items, None, dock_area, window, cx) + } + + pub fn tab( + item: Entity

, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + Self::new_tabs(vec![Arc::new(item.clone())], None, dock_area, window, cx) + } + + fn new_tabs( + items: Vec>, + active_ix: Option, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + let active_ix = active_ix.unwrap_or(0); + let tab_panel = cx.new(|cx| { + let mut tab_panel = TabPanel::new(None, dock_area.clone(), window, cx); + for item in items.iter() { + tab_panel.add_panel(item.clone(), window, cx) + } + tab_panel.active_ix = active_ix; + tab_panel + }); + + Self::Tabs { + size: None, + items, + active_ix, + view: tab_panel, + } + } + + /// Returns the views of the dock item. + pub fn view(&self) -> Arc { + match self { + Self::Split { view, .. } => Arc::new(view.clone()), + Self::Tabs { view, .. } => Arc::new(view.clone()), + Self::Tiles { view, .. } => Arc::new(view.clone()), + Self::Panel { view, .. } => view.clone(), + } + } + + /// Whether this dock item currently holds no visible panel. + /// + /// Walks the live panel entities, not `items`: [`Self::add_panel`] only + /// pushes into them, nothing ever removes, and splitting does not touch + /// them at all. + /// + /// A container is empty when every child is, so a fresh one is empty. A + /// leaf counts as empty while it is hidden, matching the render path, which + /// skips panels whose [`Panel::visible`] is `false`. + pub fn is_empty(&self, cx: &App) -> bool { + fn is_empty(panel: &Arc, cx: &App) -> bool { + let view = panel.view(); + + if let Ok(stack) = view.clone().downcast::() { + return stack + .read(cx) + .panels + .iter() + .all(|panel| is_empty(panel, cx)); + } + if let Ok(tabs) = view.clone().downcast::() { + return tabs.read(cx).panels.iter().all(|panel| is_empty(panel, cx)); + } + if let Ok(tiles) = view.downcast::() { + return tiles + .read(cx) + .panels() + .iter() + .all(|item| is_empty(&item.panel, cx)); + } + + !panel.visible(cx) + } + + is_empty(&self.view(), cx) + } + + /// Find existing panel in the dock item. + pub fn find_panel(&self, panel: Arc) -> Option> { + match self { + Self::Split { items, .. } => { + items.iter().find_map(|item| item.find_panel(panel.clone())) + } + Self::Tabs { items, .. } => items.iter().find(|item| *item == &panel).cloned(), + Self::Panel { view, .. } => Some(view.clone()), + Self::Tiles { items, .. } => items.iter().find_map(|item| { + // `==` on `Arc` moves the captured `panel` + // inside the closure; `eq` borrows both sides. + if item.panel.eq(&panel) { + Some(item.panel.clone()) + } else { + None + } + }), + } + } + + /// Add a panel to the dock item. + pub fn add_panel( + &mut self, + panel: Arc, + dock_area: &WeakEntity, + bounds: Option>, + window: &mut Window, + cx: &mut App, + ) { + match self { + Self::Tabs { view, items, .. } => { + items.push(panel.clone()); + view.update(cx, |tab_panel, cx| { + tab_panel.add_panel(panel, window, cx); + }); + } + Self::Split { view, items, .. } => { + // Iter items to add panel to the first tabs + for item in items.iter_mut() { + if let DockItem::Tabs { view, .. } = item { + view.update(cx, |tab_panel, cx| { + tab_panel.add_panel(panel.clone(), window, cx); + }); + return; + } + } + + // Unable to find tabs, create new tabs + let new_item = Self::tabs(vec![panel.clone()], dock_area, window, cx); + items.push(new_item.clone()); + view.update(cx, |stack_panel, cx| { + stack_panel.add_panel(new_item.view(), None, dock_area.clone(), window, cx); + }); + } + Self::Tiles { view, items, .. } => { + let tile_item = TileItem::new( + Arc::new(cx.new(|cx| { + let mut tab_panel = TabPanel::new(None, dock_area.clone(), window, cx); + tab_panel.add_panel(panel.clone(), window, cx); + tab_panel + })), + bounds.unwrap_or_else(|| TileMeta::default().bounds), + ); + + items.push(tile_item.clone()); + view.update(cx, |tiles, cx| { + tiles.add_item(tile_item, dock_area, window, cx); + }); + } + Self::Panel { .. } => {} + } + } + + /// Remove a panel from the dock item. + pub fn remove_panel(&self, panel: Arc, window: &mut Window, cx: &mut App) { + match self { + DockItem::Tabs { view, .. } => { + view.update(cx, |tab_panel, cx| { + tab_panel.remove_panel(panel, window, cx); + }); + } + DockItem::Split { items, view, .. } => { + // For each child item, set collapsed state + for item in items { + item.remove_panel(panel.clone(), window, cx); + } + view.update(cx, |split, cx| { + split.remove_panel(panel, window, cx); + }); + } + DockItem::Tiles { view, .. } => { + view.update(cx, |tiles, cx| { + tiles.remove(panel, window, cx); + }); + } + DockItem::Panel { .. } => {} + } + } + + pub fn set_collapsed(&self, collapsed: bool, window: &mut Window, cx: &mut App) { + match self { + DockItem::Tabs { view, .. } => { + view.update(cx, |tab_panel, cx| { + tab_panel.set_collapsed(collapsed, window, cx); + }); + } + DockItem::Split { items, .. } => { + // For each child item, set collapsed state + for item in items { + item.set_collapsed(collapsed, window, cx); + } + } + DockItem::Tiles { .. } => {} + DockItem::Panel { view, .. } => view.set_active(!collapsed, window, cx), + } + } + + /// Recursively traverses to find the left-most and top-most TabPanel. + pub(crate) fn left_top_tab_panel(&self, cx: &App) -> Option> { + match self { + DockItem::Tabs { view, .. } => Some(view.clone()), + DockItem::Split { view, .. } => view.read(cx).left_top_tab_panel(true, cx), + DockItem::Tiles { .. } => None, + DockItem::Panel { .. } => None, + } + } + + /// Recursively traverses to find the right-most and top-most TabPanel. + pub(crate) fn right_top_tab_panel(&self, cx: &App) -> Option> { + match self { + DockItem::Tabs { view, .. } => Some(view.clone()), + DockItem::Split { view, .. } => view.read(cx).right_top_tab_panel(true, cx), + DockItem::Tiles { .. } => None, + DockItem::Panel { .. } => None, + } + } +} + +impl DockArea { + pub fn new( + id: impl Into, + version: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let stack_panel = cx.new(|cx| StackPanel::new(Axis::Horizontal, window, cx)); + + let dock_item = DockItem::Split { + axis: Axis::Horizontal, + size: None, + items: vec![], + sizes: vec![], + view: stack_panel.clone(), + }; + + let mut this = Self { + id: id.into(), + version, + bounds: Bounds::default(), + center: dock_item, + left_dock: None, + right_dock: None, + bottom_dock: None, + zoom_view: None, + toggle_button_panels: Edges::default(), + toggle_button_visible: true, + locked: false, + panel_style: PanelStyle::default(), + _subscriptions: vec![], + }; + + this.subscribe_panel(&stack_panel, window, cx); + + this + } + + /// Return the bounds of the dock area. + pub fn bounds(&self) -> Bounds { + self.bounds + } + + /// Subscribe to the tiles item drag item drop event + fn subscribe_tiles_item_drop( + &mut self, + tile_panel: &Entity, + _: &mut Window, + cx: &mut Context, + ) { + self._subscriptions + .push(cx.subscribe(tile_panel, move |_, _, evt: &DragDrop, cx| { + cx.emit(DockEvent::DragDrop { + item: evt.0.clone(), + target: DropTarget::Canvas, + }); + })); + } + + /// Set the panel style of the dock area. + pub fn panel_style(mut self, style: PanelStyle) -> Self { + self.panel_style = style; + self + } + + /// Set version of the dock area. + pub fn set_version(&mut self, version: usize, _: &mut Window, cx: &mut Context) { + self.version = Some(version); + cx.notify(); + } + + /// Return the center dock item. + pub fn center(&self) -> &DockItem { + &self.center + } + + /// Whether the center area currently holds no visible panel. + /// + /// See [`DockItem::is_empty`]. Ask a dock the same question with + /// [`Dock::panel`]. + pub fn is_center_empty(&self, cx: &App) -> bool { + self.center.is_empty(cx) + } + + /// Return the left dock item. + pub fn left_dock(&self) -> Option<&Entity> { + self.left_dock.as_ref() + } + + /// Return the bottom dock item. + pub fn bottom_dock(&self) -> Option<&Entity> { + self.bottom_dock.as_ref() + } + + /// Return the right dock item. + pub fn right_dock(&self) -> Option<&Entity> { + self.right_dock.as_ref() + } + + /// Remove the left dock. + pub fn remove_left_dock(&mut self, _: &mut Window, _: &mut Context) { + self.left_dock = None; + } + + /// Remove the bottom dock. + pub fn remove_bottom_dock(&mut self, _: &mut Window, _: &mut Context) { + self.bottom_dock = None; + } + + /// Remove the right dock. + pub fn remove_right_dock(&mut self, _: &mut Window, _: &mut Context) { + self.right_dock = None; + } + + /// The the DockItem as the center of the dock area. + /// + /// This is used to render at the Center of the DockArea. + pub fn set_center(&mut self, center: DockItem, window: &mut Window, cx: &mut Context) { + self.subscribe_item(¢er, window, cx); + self.center = center; + self.update_toggle_button_tab_panels(window, cx); + cx.notify(); + } + + pub fn set_left_dock( + &mut self, + panel: DockItem, + size: Option, + open: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.subscribe_item(&panel, window, cx); + let weak_self = cx.entity().downgrade(); + self.left_dock = Some(cx.new(|cx| { + let mut dock = Dock::left(weak_self.clone(), window, cx); + if let Some(size) = size { + dock.set_size(size, window, cx); + } + dock.set_panel(panel, window, cx); + dock.set_open(open, window, cx); + dock + })); + self.update_toggle_button_tab_panels(window, cx); + } + + pub fn set_bottom_dock( + &mut self, + panel: DockItem, + size: Option, + open: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.subscribe_item(&panel, window, cx); + let weak_self = cx.entity().downgrade(); + self.bottom_dock = Some(cx.new(|cx| { + let mut dock = Dock::bottom(weak_self.clone(), window, cx); + if let Some(size) = size { + dock.set_size(size, window, cx); + } + dock.set_panel(panel, window, cx); + dock.set_open(open, window, cx); + dock + })); + self.update_toggle_button_tab_panels(window, cx); + } + + pub fn set_right_dock( + &mut self, + panel: DockItem, + size: Option, + open: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.subscribe_item(&panel, window, cx); + let weak_self = cx.entity().downgrade(); + self.right_dock = Some(cx.new(|cx| { + let mut dock = Dock::right(weak_self.clone(), window, cx); + if let Some(size) = size { + dock.set_size(size, window, cx); + } + dock.set_panel(panel, window, cx); + dock.set_open(open, window, cx); + dock + })); + self.update_toggle_button_tab_panels(window, cx); + } + + /// Set locked state of the dock area, if locked, the dock area cannot be split or move, but allows to resize panels. + pub fn set_locked(&mut self, locked: bool, _window: &mut Window, _cx: &mut App) { + self.locked = locked; + } + + /// Determine if the dock area is locked. + #[inline] + pub fn is_locked(&self) -> bool { + self.locked + } + + /// Determine if the dock area has a dock at the given placement. + pub fn has_dock(&self, placement: DockPlacement) -> bool { + match placement { + DockPlacement::Left => self.left_dock.is_some(), + DockPlacement::Bottom => self.bottom_dock.is_some(), + DockPlacement::Right => self.right_dock.is_some(), + DockPlacement::Center => false, + } + } + + /// Determine if the dock at the given placement is open. + pub fn is_dock_open(&self, placement: DockPlacement, cx: &App) -> bool { + match placement { + DockPlacement::Left => self + .left_dock + .as_ref() + .map(|dock| dock.read(cx).is_open()) + .unwrap_or(false), + DockPlacement::Bottom => self + .bottom_dock + .as_ref() + .map(|dock| dock.read(cx).is_open()) + .unwrap_or(false), + DockPlacement::Right => self + .right_dock + .as_ref() + .map(|dock| dock.read(cx).is_open()) + .unwrap_or(false), + DockPlacement::Center => false, + } + } + + /// Set the dock at the given placement to be open or closed. + /// + /// Only the left, bottom, right dock can be toggled. + pub fn set_dock_collapsible( + &mut self, + collapsible_edges: Edges, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(left_dock) = self.left_dock.as_ref() { + left_dock.update(cx, |dock, cx| { + dock.set_collapsible(collapsible_edges.left, window, cx); + }); + } + + if let Some(bottom_dock) = self.bottom_dock.as_ref() { + bottom_dock.update(cx, |dock, cx| { + dock.set_collapsible(collapsible_edges.bottom, window, cx); + }); + } + + if let Some(right_dock) = self.right_dock.as_ref() { + right_dock.update(cx, |dock, cx| { + dock.set_collapsible(collapsible_edges.right, window, cx); + }); + } + } + + /// Determine if the dock at the given placement is collapsible. + pub fn is_dock_collapsible(&self, placement: DockPlacement, cx: &App) -> bool { + match placement { + DockPlacement::Left => self + .left_dock + .as_ref() + .map(|dock| dock.read(cx).collapsible) + .unwrap_or(false), + DockPlacement::Bottom => self + .bottom_dock + .as_ref() + .map(|dock| dock.read(cx).collapsible) + .unwrap_or(false), + DockPlacement::Right => self + .right_dock + .as_ref() + .map(|dock| dock.read(cx).collapsible) + .unwrap_or(false), + DockPlacement::Center => false, + } + } + + /// Toggle the dock at the given placement. + pub fn toggle_dock( + &self, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) { + let dock = match placement { + DockPlacement::Left => &self.left_dock, + DockPlacement::Bottom => &self.bottom_dock, + DockPlacement::Right => &self.right_dock, + DockPlacement::Center => return, + }; + + if let Some(dock) = dock { + dock.update(cx, |view, cx| { + view.toggle_open(window, cx); + }) + } + } + + /// Set the visibility of the toggle button. + pub fn set_toggle_button_visible(&mut self, visible: bool, _: &mut Context) { + self.toggle_button_visible = visible; + } + + /// Add a panel item to the dock area at the given placement. + pub fn add_panel( + &mut self, + panel: Arc, + placement: DockPlacement, + bounds: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let weak_self = cx.entity().downgrade(); + match placement { + DockPlacement::Left => { + if let Some(dock) = self.left_dock.as_ref() { + dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) + } else { + self.set_left_dock( + DockItem::tabs(vec![panel], &weak_self, window, cx), + None, + true, + window, + cx, + ); + } + } + DockPlacement::Bottom => { + if let Some(dock) = self.bottom_dock.as_ref() { + dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) + } else { + self.set_bottom_dock( + DockItem::tabs(vec![panel], &weak_self, window, cx), + None, + true, + window, + cx, + ); + } + } + DockPlacement::Right => { + if let Some(dock) = self.right_dock.as_ref() { + dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) + } else { + self.set_right_dock( + DockItem::tabs(vec![panel], &weak_self, window, cx), + None, + true, + window, + cx, + ); + } + } + DockPlacement::Center => { + self.center + .add_panel(panel, &cx.entity().downgrade(), bounds, window, cx); + } + } + } + + /// Remove panel from the DockArea at the given placement. + pub fn remove_panel( + &mut self, + panel: Arc, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) { + match placement { + DockPlacement::Left => { + if let Some(dock) = self.left_dock.as_mut() { + dock.update(cx, |dock, cx| { + dock.remove_panel(panel, window, cx); + }); + } + } + DockPlacement::Right => { + if let Some(dock) = self.right_dock.as_mut() { + dock.update(cx, |dock, cx| { + dock.remove_panel(panel, window, cx); + }); + } + } + DockPlacement::Bottom => { + if let Some(dock) = self.bottom_dock.as_mut() { + dock.update(cx, |dock, cx| { + dock.remove_panel(panel, window, cx); + }); + } + } + DockPlacement::Center => { + self.center.remove_panel(panel, window, cx); + } + } + cx.notify(); + } + + /// Remove a panel from all docks. + pub fn remove_panel_from_all_docks( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.remove_panel(panel.clone(), DockPlacement::Center, window, cx); + self.remove_panel(panel.clone(), DockPlacement::Left, window, cx); + self.remove_panel(panel.clone(), DockPlacement::Right, window, cx); + self.remove_panel(panel.clone(), DockPlacement::Bottom, window, cx); + } + + /// Load the state of the DockArea from the DockAreaState. + /// + /// See also [DockeArea::dump]. + pub fn load( + &mut self, + state: DockAreaState, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + self.version = state.version; + let weak_self = cx.entity().downgrade(); + + if let Some(left_dock_state) = state.left_dock { + self.left_dock = Some(left_dock_state.to_dock(weak_self.clone(), window, cx)); + } + + if let Some(right_dock_state) = state.right_dock { + self.right_dock = Some(right_dock_state.to_dock(weak_self.clone(), window, cx)); + } + + if let Some(bottom_dock_state) = state.bottom_dock { + self.bottom_dock = Some(bottom_dock_state.to_dock(weak_self.clone(), window, cx)); + } + + self.center = state.center.to_item(weak_self, window, cx); + self.update_toggle_button_tab_panels(window, cx); + Ok(()) + } + + /// Dump the dock panels layout to PanelState. + /// + /// See also [DockArea::load]. + pub fn dump(&self, cx: &App) -> DockAreaState { + let root = self.center.view(); + let center = root.dump(cx); + + let left_dock = self + .left_dock + .as_ref() + .map(|dock| DockState::new(dock.clone(), cx)); + let right_dock = self + .right_dock + .as_ref() + .map(|dock| DockState::new(dock.clone(), cx)); + let bottom_dock = self + .bottom_dock + .as_ref() + .map(|dock| DockState::new(dock.clone(), cx)); + + DockAreaState { + version: self.version, + center, + left_dock, + right_dock, + bottom_dock, + } + } + + /// Subscribe event on the panels + #[allow(clippy::only_used_in_recursion)] + fn subscribe_item(&mut self, item: &DockItem, window: &mut Window, cx: &mut Context) { + match item { + DockItem::Split { items, view, .. } => { + for item in items { + self.subscribe_item(item, window, cx); + } + + self._subscriptions.push(cx.subscribe_in( + view, + window, + move |_, _, event, window, cx| { + if let PanelEvent::LayoutChanged = event { + cx.spawn_in(window, async move |view, window| { + _ = view.update_in(window, |view, window, cx| { + view.update_toggle_button_tab_panels(window, cx) + }); + }) + .detach(); + cx.emit(DockEvent::LayoutChanged); + } + }, + )); + } + DockItem::Tabs { .. } => { + // We subscribe to the tab panel event in StackPanel's insert_panel + } + DockItem::Tiles { .. } => { + // We subscribe to the tab panel event in Tiles's [`add_item`](Tiles::add_item) + } + DockItem::Panel { .. } => { + // Not supported + } + } + } + + /// Subscribe zoom event on the panel + pub(crate) fn subscribe_panel( + &mut self, + view: &Entity

, + window: &mut Window, + cx: &mut Context, + ) { + let subscription = + cx.subscribe_in( + view, + window, + move |_, panel, event, window, cx| match event { + PanelEvent::ZoomIn => { + let panel = panel.clone(); + cx.spawn_in(window, async move |view, window| { + _ = view.update_in(window, |view, window, cx| { + view.set_zoomed_in(panel, window, cx); + cx.notify(); + }); + }) + .detach(); + } + PanelEvent::ZoomOut => cx + .spawn_in(window, async move |view, window| { + _ = view.update_in(window, |view, window, cx| { + view.set_zoomed_out(window, cx); + }); + }) + .detach(), + PanelEvent::LayoutChanged => { + cx.spawn_in(window, async move |view, window| { + _ = view.update_in(window, |view, window, cx| { + view.update_toggle_button_tab_panels(window, cx) + }); + }) + .detach(); + cx.emit(DockEvent::LayoutChanged); + } + }, + ); + + self._subscriptions.push(subscription); + } + + /// Returns the ID of the dock area. + pub fn id(&self) -> SharedString { + self.id.clone() + } + + pub fn set_zoomed_in( + &mut self, + panel: Entity

, + _: &mut Window, + cx: &mut Context, + ) { + self.zoom_view = Some(panel.into()); + cx.notify(); + } + + pub fn set_zoomed_out(&mut self, _: &mut Window, cx: &mut Context) { + self.zoom_view = None; + cx.notify(); + } + + fn render_items(&self, _window: &mut Window, _cx: &mut Context) -> AnyElement { + match &self.center { + DockItem::Split { view, .. } => view.clone().into_any_element(), + DockItem::Tabs { view, .. } => view.clone().into_any_element(), + DockItem::Tiles { view, .. } => view.clone().into_any_element(), + DockItem::Panel { view, .. } => view.clone().view().into_any_element(), + } + } + + pub fn update_toggle_button_tab_panels(&mut self, _: &mut Window, cx: &mut Context) { + // Left toggle button + self.toggle_button_panels.left = self + .center + .left_top_tab_panel(cx) + .map(|view| view.entity_id()); + + // Right toggle button + self.toggle_button_panels.right = self + .center + .right_top_tab_panel(cx) + .map(|view| view.entity_id()); + + // Bottom toggle button + self.toggle_button_panels.bottom = self + .bottom_dock + .as_ref() + .and_then(|dock| dock.read(cx).panel.left_top_tab_panel(cx)) + .map(|view| view.entity_id()); + } +} +impl EventEmitter for DockArea {} +impl Render for DockArea { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity().clone(); + + div() + .id("dock-area") + .relative() + .size_full() + .overflow_hidden() + .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)) + .map(|this| { + if let Some(zoom_view) = self.zoom_view.clone() { + this.child(zoom_view) + } else { + match &self.center { + DockItem::Tiles { view, .. } => { + // render tiles + this.child(view.clone()) + } + _ => { + // render dock + this.child( + div() + .flex() + .flex_row() + .h_full() + // Left dock + .when_some(self.left_dock.clone(), |this, dock| { + this.child(div().flex().flex_none().child(dock)) + }) + // Center + .child( + div() + .flex() + .flex_1() + .flex_col() + .overflow_hidden() + // Top center + .child( + div() + .flex_1() + .overflow_hidden() + .child(self.render_items(window, cx)), + ) + // Bottom Dock + .when_some(self.bottom_dock.clone(), |this, dock| { + this.child(dock) + }), + ) + // Right Dock + .when_some(self.right_dock.clone(), |this, dock| { + this.child(div().flex().flex_none().child(dock)) + }), + ) + } + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use gpui::TestAppContext; + + use super::*; + + #[gpui::test] + fn split_with_sizes_adds_each_child_once(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.set_global(gpui_component::Theme::default()); + cx.open_window(Default::default(), |window, cx| { + let dock_area = cx.new(|cx| DockArea::new("test-dock", None, window, cx)); + let weak_dock_area = dock_area.downgrade(); + let children = vec![ + DockItem::tabs(Vec::new(), &weak_dock_area, window, cx), + DockItem::tabs(Vec::new(), &weak_dock_area, window, cx), + ]; + + let split = DockItem::split_with_sizes( + Axis::Horizontal, + children, + vec![None, None], + &weak_dock_area, + window, + cx, + ); + + let DockItem::Split { view, .. } = split else { + unreachable!("split_with_sizes must return DockItem::Split"); + }; + assert_eq!(view.read(cx).panels_len(), 2); + + cx.new(|cx| gpui_component::Root::new(dock_area, window, cx)) + }) + .unwrap(); + }); + } +} diff --git a/crates/dock/src/panel.rs b/crates/dock/src/panel.rs new file mode 100644 index 0000000..afd0cc9 --- /dev/null +++ b/crates/dock/src/panel.rs @@ -0,0 +1,386 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use gpui::{ + AnyElement, AnyView, App, AppContext as _, Context, Entity, EntityId, EventEmitter, + FocusHandle, Focusable, Global, Hsla, IntoElement, Render, SharedString, WeakEntity, Window, +}; +use gpui_component::button::Button; +use gpui_component::menu::PopupMenu; + +use super::{DockArea, PanelInfo, PanelState, TabPanel}; +use crate::invalid_panel::InvalidPanel; +use crate::t; + +pub enum PanelEvent { + ZoomIn, + ZoomOut, + LayoutChanged, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PanelStyle { + /// Display the TabBar when there are multiple tabs, otherwise display the simple title. + #[default] + Auto, + /// Always display the tab bar. + TabBar, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TitleStyle { + pub background: Hsla, + pub foreground: Hsla, +} + +#[derive(Clone, Copy, Default)] +pub enum PanelControl { + Both, + #[default] + Menu, + Toolbar, +} + +impl PanelControl { + #[inline] + pub fn toolbar_visible(&self) -> bool { + matches!(self, PanelControl::Both | PanelControl::Toolbar) + } + + #[inline] + pub fn menu_visible(&self) -> bool { + matches!(self, PanelControl::Both | PanelControl::Menu) + } +} + +/// The Panel trait used to define the panel. +#[allow(unused_variables)] +pub trait Panel: EventEmitter + Render + Focusable { + /// The name of the panel used to serialize, deserialize and identify the panel. + /// + /// This is used to identify the panel when deserializing the panel. + /// Once you have defined a panel name, this must not be changed. + fn panel_name(&self) -> &'static str; + + /// The name of the tab of the panel, default is `None`. + /// + /// Used to display in the already collapsed tab panel. + fn tab_name(&self, cx: &App) -> Option { + None + } + + /// The title of the panel + fn title(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + t("Dock.Unnamed") + } + + /// The theme of the panel title, default is `None`. + fn title_style(&self, cx: &App) -> Option { + None + } + + /// The suffix of the panel title, default is `None`. + /// + /// This is used to add a suffix element to the panel title. + fn title_suffix( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + None:: + } + + /// Whether the panel can be closed, default is `true`. + /// + /// This method called in Panel render, we should make sure it is fast. + fn closable(&self, cx: &App) -> bool { + true + } + + /// Return `PanelControl` if the panel is zoomable, default is `PanelControl::Menu`. + /// + /// This method called in Panel render, we should make sure it is fast. + fn zoomable(&self, cx: &App) -> Option { + Some(PanelControl::Menu) + } + + /// Return false to hide panel, true to show panel, default is `true`. + /// + /// This method called in Panel render, we should make sure it is fast. + fn visible(&self, cx: &App) -> bool { + true + } + + /// Set active state of the panel. + /// + /// Called with the frame-end net state when this panel becomes (or stops + /// being) the displayed tab of its tab group: exactly one notification + /// per edge, delivered on the next tick after the change — never + /// same-value repeats nor false→true flips within one frame. + /// + /// A panel removed from its group is NOT told `false`; [`Panel::on_removed`] + /// is the deactivation signal. A hidden panel occupying `active_ix` still + /// receives `true` even though rendering falls back to the first visible + /// panel, and panels inside a bare `DockItem::Panel` (no tab group) are + /// outside this contract. + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) {} + + /// Set zoomed state of the panel. + /// + /// This method will be called when the panel is zoomed or unzoomed. + /// + /// Only current Panel will touch this method. + fn set_zoomed(&mut self, zoomed: bool, window: &mut Window, cx: &mut Context) {} + + /// When this Panel is added to a TabPanel, this will be called. + fn on_added_to( + &mut self, + tab_panel: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + } + + /// When this Panel is removed from a TabPanel, this will be called. + fn on_removed(&mut self, window: &mut Window, cx: &mut Context) {} + + /// The addition dropdown menu of the panel, default is `None`. + fn dropdown_menu( + &mut self, + this: PopupMenu, + window: &mut Window, + cx: &mut Context, + ) -> PopupMenu { + this + } + + /// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`. + fn toolbar_buttons( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + None + } + + /// Dump the panel, used to serialize the panel. + fn dump(&self, cx: &App) -> PanelState { + PanelState::new(self) + } + + /// Whether the panel has inner padding when the panel is in the tabs layout, default is `true`. + fn inner_padding(&self, cx: &App) -> bool { + true + } +} + +/// The PanelView trait used to define the panel view. +#[allow(unused_variables)] +pub trait PanelView: 'static + Send + Sync { + fn panel_name(&self, cx: &App) -> &'static str; + fn panel_id(&self, cx: &App) -> EntityId; + fn tab_name(&self, cx: &App) -> Option; + fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement; + fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option; + fn title_style(&self, cx: &App) -> Option; + fn closable(&self, cx: &App) -> bool; + fn zoomable(&self, cx: &App) -> Option; + fn visible(&self, cx: &App) -> bool; + fn set_active(&self, active: bool, window: &mut Window, cx: &mut App); + fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App); + fn on_added_to(&self, tab_panel: WeakEntity, window: &mut Window, cx: &mut App); + fn on_removed(&self, window: &mut Window, cx: &mut App); + fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu; + fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option>; + fn view(&self) -> AnyView; + fn focus_handle(&self, cx: &App) -> FocusHandle; + fn dump(&self, cx: &App) -> PanelState; + fn inner_padding(&self, cx: &App) -> bool; +} + +impl PanelView for Entity { + fn panel_name(&self, cx: &App) -> &'static str { + self.read(cx).panel_name() + } + + fn panel_id(&self, _: &App) -> EntityId { + self.entity_id() + } + + fn tab_name(&self, cx: &App) -> Option { + self.read(cx).tab_name(cx) + } + + fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement { + self.update(cx, |this, cx| this.title(window, cx).into_any_element()) + } + + fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option { + self.update(cx, |this, cx| { + this.title_suffix(window, cx) + .map(|el| el.into_any_element()) + }) + } + + fn title_style(&self, cx: &App) -> Option { + self.read(cx).title_style(cx) + } + + fn closable(&self, cx: &App) -> bool { + self.read(cx).closable(cx) + } + + fn zoomable(&self, cx: &App) -> Option { + self.read(cx).zoomable(cx) + } + + fn visible(&self, cx: &App) -> bool { + self.read(cx).visible(cx) + } + + fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| { + this.set_active(active, window, cx); + }) + } + + fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| { + this.set_zoomed(zoomed, window, cx); + }) + } + + fn on_added_to(&self, tab_panel: WeakEntity, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.on_added_to(tab_panel, window, cx)); + } + + fn on_removed(&self, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.on_removed(window, cx)); + } + + fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu { + self.update(cx, |this, cx| this.dropdown_menu(menu, window, cx)) + } + + fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option> { + self.update(cx, |this, cx| this.toolbar_buttons(window, cx)) + } + + fn view(&self) -> AnyView { + self.clone().into() + } + + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.read(cx).focus_handle(cx) + } + + fn dump(&self, cx: &App) -> PanelState { + self.read(cx).dump(cx) + } + + fn inner_padding(&self, cx: &App) -> bool { + self.read(cx).inner_padding(cx) + } +} + +impl From<&dyn PanelView> for AnyView { + fn from(handle: &dyn PanelView) -> Self { + handle.view() + } +} + +impl From<&dyn PanelView> for Entity { + fn from(value: &dyn PanelView) -> Self { + value.view().downcast::().unwrap() + } +} + +impl PartialEq for dyn PanelView { + fn eq(&self, other: &Self) -> bool { + self.view() == other.view() + } +} + +/// The deserializer used by [`PanelRegistry`] to rebuild a panel from its +/// persisted [`PanelState`]. +type PanelBuilder = dyn Fn( + WeakEntity, + &PanelState, + &PanelInfo, + &mut Window, + &mut App, +) -> Box; + +pub struct PanelRegistry { + pub(super) items: HashMap>, +} +impl PanelRegistry { + /// Initialize the panel registry. + pub(crate) fn init(cx: &mut App) { + if cx.try_global::().is_none() { + cx.set_global(PanelRegistry::new()); + } + } + + pub fn new() -> Self { + Self { + items: HashMap::new(), + } + } + + pub fn global(cx: &App) -> &Self { + cx.global::() + } + + pub fn global_mut(cx: &mut App) -> &mut Self { + cx.global_mut::() + } + + /// Build a panel by name. + /// + /// If not registered, return InvalidPanel. + pub fn build_panel( + panel_name: &str, + dock_area: WeakEntity, + panel_state: &PanelState, + panel_info: &PanelInfo, + window: &mut Window, + cx: &mut App, + ) -> Box { + if let Some(view) = Self::global(cx) + .items + .get(panel_name) + .cloned() + .map(|f| f(dock_area, panel_state, panel_info, window, cx)) + { + view + } else { + // Show an invalid panel if the panel is not registered. + Box::new(cx.new(|cx| InvalidPanel::new(panel_name, panel_state.clone(), window, cx))) + } + } +} +impl Default for PanelRegistry { + fn default() -> Self { + Self::new() + } +} +impl Global for PanelRegistry {} + +/// Register the Panel init by panel_name to global registry. +pub fn register_panel(cx: &mut App, panel_name: &str, deserialize: F) +where + F: Fn( + WeakEntity, + &PanelState, + &PanelInfo, + &mut Window, + &mut App, + ) -> Box + + 'static, +{ + PanelRegistry::init(cx); + PanelRegistry::global_mut(cx) + .items + .insert(panel_name.to_string(), Arc::new(deserialize)); +} diff --git a/crates/dock/src/resize_handle.rs b/crates/dock/src/resize_handle.rs new file mode 100644 index 0000000..d60fdb3 --- /dev/null +++ b/crates/dock/src/resize_handle.rs @@ -0,0 +1,232 @@ +//! Vendored from `gpui-base`'s private `resizable::resize_handle` module +//! (v0.5.2, rev 9e3a29dcbdebc318632bf68203f26c33e9f0e902). gpui-component +//! keeps this and [`PANEL_MIN_SIZE`] crate-private, so the dock crate carries +//! its own copy. + +use std::cell::Cell; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement, + IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, + StatefulInteractiveElement, Styled as _, Window, div, px, +}; +use gpui_component::{ActiveTheme as _, AxisExt as _, Side}; + +pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.); +pub(crate) const HANDLE_PADDING: Pixels = px(4.); +pub(crate) const HANDLE_SIZE: Pixels = px(1.); + +/// Create a resize handle for a resizable panel. +#[doc(hidden)] +pub fn resize_handle( + id: impl Into, + axis: Axis, +) -> ResizeHandle { + ResizeHandle::new(id, axis) +} + +type DragHandler = dyn Fn(&Point, &mut Window, &mut App) -> Entity; + +#[doc(hidden)] +pub struct ResizeHandle { + id: ElementId, + axis: Axis, + drag_value: Option>, + placement: Option, + on_drag: Option>>, +} + +impl ResizeHandle { + fn new(id: impl Into, axis: Axis) -> Self { + let id = id.into(); + Self { + id: id.clone(), + on_drag: None, + drag_value: None, + placement: None, + axis, + } + } + + pub fn on_drag( + mut self, + value: T, + f: impl Fn(Rc, &Point, &mut Window, &mut App) -> Entity + 'static, + ) -> Self { + let value = Rc::new(value); + self.drag_value = Some(value.clone()); + self.on_drag = Some(Rc::new(move |p, window, cx| { + f(value.clone(), p, window, cx) + })); + self + } + + pub fn placement(mut self, placement: Side) -> Self { + self.placement = Some(placement); + self + } +} + +#[derive(Default, Debug, Clone)] +struct ResizeHandleState { + active: Cell, +} + +impl ResizeHandleState { + fn set_active(&self, active: bool) { + self.active.set(active); + } + + fn is_active(&self) -> bool { + self.active.get() + } +} + +impl IntoElement for ResizeHandle { + type Element = ResizeHandle; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ResizeHandle { + type PrepaintState = (); + type RequestLayoutState = AnyElement; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let neg_offset = -HANDLE_PADDING; + let axis = self.axis; + + window.with_element_state(id.unwrap(), |state: Option, window| { + let state = state.unwrap_or_default(); + + let bg_color = if state.is_active() { + cx.theme().drag_border + } else { + cx.theme().border + }; + + let mut el = div() + .id(self.id.clone()) + .occlude() + .absolute() + .flex_shrink_0() + .group("handle") + .when_some(self.on_drag.clone(), |this, on_drag| { + this.on_drag( + self.drag_value.clone().unwrap(), + move |_, position, window, cx| on_drag(&position, window, cx), + ) + }) + .map(|this| match self.placement { + Some(Side::Left) => { + // Special for Left Dock + // FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING) + this.cursor_col_resize() + .top_0() + .right(px(1.)) + .h_full() + .w(HANDLE_SIZE) + .pl(HANDLE_PADDING) + } + _ => this + .when(axis.is_horizontal(), |this| { + this.cursor_col_resize() + .top_0() + .left(neg_offset) + .h_full() + .w(HANDLE_SIZE) + .px(HANDLE_PADDING) + }) + .when(axis.is_vertical(), |this| { + this.cursor_row_resize() + .top(neg_offset) + .left_0() + .w_full() + .h(HANDLE_SIZE) + .py(HANDLE_PADDING) + }), + }) + .child( + div() + .bg(bg_color) + .group_hover("handle", |this| this.bg(bg_color)) + .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) + .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), + ) + .into_any_element(); + + let layout_id = el.request_layout(window, cx); + + ((layout_id, el), state) + }) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + request_layout.prepaint(window, cx); + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + request_layout.paint(window, cx); + + window.with_element_state(id.unwrap(), |state: Option, window| { + let state = state.unwrap_or_default(); + + window.on_mouse_event({ + let state = state.clone(); + move |ev: &MouseDownEvent, phase, window, _| { + if bounds.contains(&ev.position) && phase.bubble() { + state.set_active(true); + window.refresh(); + } + } + }); + + window.on_mouse_event({ + let state = state.clone(); + move |_: &MouseUpEvent, _, window, _| { + if state.is_active() { + state.set_active(false); + window.refresh(); + } + } + }); + + ((), state) + }); + } +} diff --git a/crates/dock/src/stack_panel.rs b/crates/dock/src/stack_panel.rs new file mode 100644 index 0000000..5d64a55 --- /dev/null +++ b/crates/dock/src/stack_panel.rs @@ -0,0 +1,429 @@ +use std::sync::Arc; + +use gpui::{ + App, AppContext as _, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, WeakEntity, + Window, +}; +use gpui_component::{ + ActiveTheme, AxisExt as _, Placement, ResizablePanelEvent, ResizablePanelGroup, ResizableState, + h_flex, resizable_panel, +}; +use smallvec::SmallVec; + +use super::{DockArea, Panel, PanelEvent, PanelState, PanelView, TabPanel}; +use crate::PanelInfo; +use crate::resize_handle::PANEL_MIN_SIZE; + +pub struct StackPanel { + pub(super) parent: Option>, + pub(super) axis: Axis, + focus_handle: FocusHandle, + pub(crate) panels: SmallVec<[Arc; 2]>, + state: Entity, + _subscriptions: Vec, +} + +impl Panel for StackPanel { + fn panel_name(&self) -> &'static str { + "StackPanel" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + "StackPanel" + } + + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { + for panel in &self.panels { + panel.set_active(active, window, cx); + } + } + + fn dump(&self, cx: &App) -> PanelState { + let sizes = self.state.read(cx).sizes().clone(); + let mut state = PanelState::new(self); + state.info = PanelInfo::stack(sizes, self.axis); + for panel in &self.panels { + state.add_child(panel.dump(cx)); + } + + state + } +} + +impl StackPanel { + pub fn new(axis: Axis, _: &mut Window, cx: &mut Context) -> Self { + let state = cx.new(|_| ResizableState::default()); + + let _subscriptions = vec![ + // Bubble up the resize event. + cx.subscribe(&state, |_, _, _: &ResizablePanelEvent, cx| { + cx.emit(PanelEvent::LayoutChanged) + }), + ]; + + Self { + axis, + parent: None, + focus_handle: cx.focus_handle(), + panels: SmallVec::new(), + state, + _subscriptions, + } + } + + /// The first level of the stack panel is root, will not have a parent. + fn is_root(&self) -> bool { + self.parent.is_none() + } + + /// Return true if self or parent only have last panel. + pub(super) fn is_last_panel(&self, cx: &App) -> bool { + if self.panels.len() > 1 { + return false; + } + + if let Some(parent) = &self.parent + && let Some(parent) = parent.upgrade() + { + return parent.read(cx).is_last_panel(cx); + } + + true + } + + pub(super) fn panels_len(&self) -> usize { + self.panels.len() + } + + /// Return the index of the panel. + pub(crate) fn index_of_panel(&self, panel: Arc) -> Option { + self.panels.iter().position(|p| p == &panel) + } + + fn assert_panel_is_valid(&self, panel: &Arc) { + assert!( + panel.view().downcast::().is_ok() + || panel.view().downcast::().is_ok(), + "Panel must be a `TabPanel` or `StackPanel`" + ); + } + + /// Add a panel at the end of the stack. + /// + /// If `size` is `None`, the panel will be given the average size of all panels in the stack. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + pub fn add_panel( + &mut self, + panel: Arc, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_panel(panel, self.panels.len(), size, dock_area, window, cx); + } + + /// Add a panel at the [`Placement`]. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + pub fn add_panel_at( + &mut self, + panel: Arc, + placement: Placement, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_panel_at( + panel, + self.panels_len(), + placement, + size, + dock_area, + window, + cx, + ); + } + + /// Insert a panel at the index. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + #[allow(clippy::too_many_arguments)] + pub fn insert_panel_at( + &mut self, + panel: Arc, + ix: usize, + placement: Placement, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + match placement { + Placement::Top | Placement::Left => { + self.insert_panel_before(panel, ix, size, dock_area, window, cx) + } + Placement::Right | Placement::Bottom => { + self.insert_panel_after(panel, ix, size, dock_area, window, cx) + } + } + } + + /// Insert a panel at the index. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + pub fn insert_panel_before( + &mut self, + panel: Arc, + ix: usize, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_panel(panel, ix, size, dock_area, window, cx); + } + + /// Insert a panel after the index. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + pub fn insert_panel_after( + &mut self, + panel: Arc, + ix: usize, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_panel(panel, ix + 1, size, dock_area, window, cx); + } + + fn insert_panel( + &mut self, + panel: Arc, + ix: usize, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.assert_panel_is_valid(&panel); + + // If the panel is already in the stack, return. + if self.index_of_panel(panel.clone()).is_some() { + return; + } + + let view = cx.entity().clone(); + window.defer(cx, { + let panel = panel.clone(); + + move |window, cx| { + // If the panel is a TabPanel, set its parent to this. + if let Ok(tab_panel) = panel.view().downcast::() { + tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade())); + } else if let Ok(stack_panel) = panel.view().downcast::() { + stack_panel.update(cx, |stack_panel, _| { + stack_panel.parent = Some(view.downgrade()) + }); + } + + // Subscribe to the panel's layout change event. + _ = dock_area.update(cx, |this, cx| { + if let Ok(tab_panel) = panel.view().downcast::() { + this.subscribe_panel(&tab_panel, window, cx); + } else if let Ok(stack_panel) = panel.view().downcast::() { + this.subscribe_panel(&stack_panel, window, cx); + } + }); + } + }); + + let ix = if ix > self.panels.len() { + self.panels.len() + } else { + ix + }; + + // Get avg size of all panels to insert new panel, if size is None. + let size = match size { + Some(size) => size, + None => { + let state = self.state.read(cx); + (state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE) + } + }; + + self.panels.insert(ix, panel.clone()); + self.state.update(cx, |state, cx| { + state.insert_panel(Some(size), Some(ix), cx); + }); + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Remove panel from the stack. + /// + /// If `ix` is not found, do nothing. + pub fn remove_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.index_of_panel(panel.clone()) else { + return; + }; + + self.panels.remove(ix); + self.state.update(cx, |state, cx| { + state.remove_panel(ix, cx); + }); + + cx.emit(PanelEvent::LayoutChanged); + self.remove_self_if_empty(window, cx); + } + + /// Replace the old panel with the new panel at same index. + pub(super) fn replace_panel( + &mut self, + old_panel: Arc, + new_panel: Entity, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(ix) = self.index_of_panel(old_panel.clone()) { + self.panels[ix] = Arc::new(new_panel.clone()); + + self.state.update(cx, |state, cx| { + state.reset_panel(ix, cx); + }); + cx.emit(PanelEvent::LayoutChanged); + } + } + + /// If children is empty, remove self from parent view. + pub(crate) fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context) { + if self.is_root() { + return; + } + + if !self.panels.is_empty() { + return; + } + + let view = cx.entity().clone(); + if let Some(parent) = self.parent.as_ref() { + _ = parent.update(cx, |parent, cx| { + parent.remove_panel(Arc::new(view.clone()), window, cx); + }); + } + + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Find the first top left in the stack. + pub(super) fn left_top_tab_panel( + &self, + check_parent: bool, + cx: &App, + ) -> Option> { + if check_parent + && let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) + && let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) + { + return Some(panel); + } + + let first_panel = self.panels.first(); + if let Some(view) = first_panel { + if let Ok(tab_panel) = view.view().downcast::() { + Some(tab_panel) + } else if let Ok(stack_panel) = view.view().downcast::() { + stack_panel.read(cx).left_top_tab_panel(false, cx) + } else { + None + } + } else { + None + } + } + + /// Find the first top right in the stack. + pub(super) fn right_top_tab_panel( + &self, + check_parent: bool, + cx: &App, + ) -> Option> { + if check_parent + && let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) + && let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) + { + return Some(panel); + } + + let panel = if self.axis.is_vertical() { + self.panels.first() + } else { + self.panels.last() + }; + + if let Some(view) = panel { + if let Ok(tab_panel) = view.view().downcast::() { + Some(tab_panel) + } else if let Ok(stack_panel) = view.view().downcast::() { + stack_panel.read(cx).right_top_tab_panel(false, cx) + } else { + None + } + } else { + None + } + } + + /// Remove all panels from the stack. + pub(super) fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context) { + self.panels.clear(); + self.state.update(cx, |state, cx| { + state.clear(); + cx.notify(); + }); + } + + /// Change the axis of the stack panel. + pub(super) fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context) { + self.axis = axis; + cx.notify(); + } +} + +impl Focusable for StackPanel { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} +impl EventEmitter for StackPanel {} +impl EventEmitter for StackPanel {} +impl Render for StackPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .size_full() + .overflow_hidden() + .bg(cx.theme().tokens.tab_bar) + .child( + ResizablePanelGroup::new("stack-panel-group") + .with_state(&self.state) + .axis(self.axis) + .children(self.panels.clone().into_iter().map(|panel| { + resizable_panel() + .child(panel.view()) + .visible(panel.visible(cx)) + })), + ) + } +} diff --git a/crates/dock/src/state.rs b/crates/dock/src/state.rs new file mode 100644 index 0000000..acfc406 --- /dev/null +++ b/crates/dock/src/state.rs @@ -0,0 +1,282 @@ +use gpui::{App, AppContext, Axis, Bounds, Entity, Pixels, WeakEntity, Window, point, px, size}; +use itertools::Itertools as _; +use serde::{Deserialize, Serialize}; + +use super::{Dock, DockArea, DockItem, DockPlacement, Panel, PanelRegistry}; + +/// Used to serialize and deserialize the DockArea +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] +pub struct DockAreaState { + /// The version is used to mark this persisted state is compatible with the current version + /// For example, some times we many totally changed the structure of the Panel, + /// then we can compare the version to decide whether we can use the state or ignore. + #[serde(default)] + pub version: Option, + pub center: PanelState, + #[serde(skip_serializing_if = "Option::is_none")] + pub left_dock: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub right_dock: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bottom_dock: Option, +} + +/// Used to serialize and deserialize the Dock +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DockState { + panel: PanelState, + placement: DockPlacement, + size: Pixels, + open: bool, +} + +impl DockState { + pub fn new(dock: Entity, cx: &App) -> Self { + let dock = dock.read(cx); + + Self { + placement: dock.placement, + size: dock.size, + open: dock.open, + panel: dock.panel.view().dump(cx), + } + } + + /// Convert the DockState to Dock + pub fn to_dock( + &self, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + let item = self.panel.to_item(dock_area.clone(), window, cx); + cx.new(|cx| { + Dock::from_state( + dock_area.clone(), + self.placement, + self.size, + item, + self.open, + window, + cx, + ) + }) + } +} + +/// Used to serialize and deserialize the DockerItem +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PanelState { + pub panel_name: String, + pub children: Vec, + pub info: PanelInfo, +} + +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +pub struct TileMeta { + pub bounds: Bounds, + pub z_index: usize, +} + +impl Default for TileMeta { + fn default() -> Self { + Self { + bounds: Bounds { + origin: point(px(10.), px(10.)), + size: size(px(200.), px(200.)), + }, + z_index: 0, + } + } +} + +impl From> for TileMeta { + fn from(bounds: Bounds) -> Self { + Self { bounds, z_index: 0 } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PanelInfo { + #[serde(rename = "stack")] + Stack { + sizes: Vec, + axis: usize, // 0 for horizontal, 1 for vertical + }, + #[serde(rename = "tabs")] + Tabs { active_index: usize }, + #[serde(rename = "panel")] + Panel(serde_json::Value), + #[serde(rename = "tiles")] + Tiles { metas: Vec }, +} + +impl PanelInfo { + pub fn stack(sizes: Vec, axis: Axis) -> Self { + Self::Stack { + sizes, + axis: if axis == Axis::Horizontal { 0 } else { 1 }, + } + } + + pub fn tabs(active_index: usize) -> Self { + Self::Tabs { active_index } + } + + pub fn panel(info: serde_json::Value) -> Self { + Self::Panel(info) + } + + pub fn tiles(metas: Vec) -> Self { + Self::Tiles { metas } + } + + pub fn axis(&self) -> Option { + match self { + Self::Stack { axis, .. } => Some(if *axis == 0 { + Axis::Horizontal + } else { + Axis::Vertical + }), + _ => None, + } + } + + pub fn sizes(&self) -> Option<&Vec> { + match self { + Self::Stack { sizes, .. } => Some(sizes), + _ => None, + } + } + + pub fn active_index(&self) -> Option { + match self { + Self::Tabs { active_index } => Some(*active_index), + _ => None, + } + } +} + +impl Default for PanelState { + fn default() -> Self { + Self { + panel_name: "".to_string(), + children: Vec::new(), + info: PanelInfo::Panel(serde_json::Value::Null), + } + } +} + +impl PanelState { + pub fn new(panel: &P) -> Self { + Self { + panel_name: panel.panel_name().to_string(), + ..Default::default() + } + } + + pub fn add_child(&mut self, panel: PanelState) { + self.children.push(panel); + } + + pub fn to_item( + &self, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> DockItem { + let info = self.info.clone(); + + let items: Vec = self + .children + .iter() + .map(|child| child.to_item(dock_area.clone(), window, cx)) + .collect(); + + match info { + PanelInfo::Stack { sizes, axis } => { + let axis = if axis == 0 { + Axis::Horizontal + } else { + Axis::Vertical + }; + let sizes = sizes.iter().map(|s| Some(*s)).collect_vec(); + DockItem::split_with_sizes(axis, items, sizes, &dock_area, window, cx) + } + PanelInfo::Tabs { active_index } => { + if items.len() == 1 { + return items[0].clone(); + } + + let items = items + .iter() + .flat_map(|item| match item { + DockItem::Tabs { items, .. } => items.clone(), + _ => { + // ignore invalid panels in tabs + vec![] + } + }) + .collect_vec(); + + DockItem::tabs(items, &dock_area, window, cx).active_index(active_index, cx) + } + PanelInfo::Panel(_) => { + let view = PanelRegistry::build_panel( + &self.panel_name, + dock_area.clone(), + self, + &info, + window, + cx, + ); + DockItem::tabs(vec![view.into()], &dock_area, window, cx) + } + PanelInfo::Tiles { metas } => DockItem::tiles(items, metas, &dock_area, window, cx), + } + } +} + +#[cfg(test)] +mod tests { + use gpui::px; + + use super::*; + #[test] + fn test_deserialize_item_state() { + let json = include_str!("fixtures/layout.json"); + let state: DockAreaState = serde_json::from_str(json).unwrap(); + assert_eq!(state.version, None); + assert_eq!(state.center.panel_name, "StackPanel"); + assert_eq!(state.center.children.len(), 2); + assert_eq!(state.center.children[0].panel_name, "TabPanel"); + assert_eq!(state.center.children[1].children.len(), 1); + assert_eq!( + state.center.children[1].children[0].panel_name, + "StoryContainer" + ); + assert_eq!(state.center.children[1].panel_name, "TabPanel"); + + let left_dock = state.left_dock.unwrap(); + assert!(left_dock.open); + assert_eq!(left_dock.size, px(350.0)); + assert_eq!(left_dock.placement, DockPlacement::Left); + assert_eq!(left_dock.panel.panel_name, "TabPanel"); + assert_eq!(left_dock.panel.children.len(), 1); + assert_eq!(left_dock.panel.children[0].panel_name, "StoryContainer"); + + let bottom_dock = state.bottom_dock.unwrap(); + assert!(bottom_dock.open); + assert_eq!(bottom_dock.size, px(200.0)); + assert_eq!(bottom_dock.panel.panel_name, "TabPanel"); + assert_eq!(bottom_dock.panel.children.len(), 2); + assert_eq!(bottom_dock.panel.children[0].panel_name, "StoryContainer"); + + let right_dock = state.right_dock.unwrap(); + assert!(right_dock.open); + assert_eq!(right_dock.size, px(320.0)); + assert_eq!(right_dock.panel.panel_name, "TabPanel"); + assert_eq!(right_dock.panel.children.len(), 1); + assert_eq!(right_dock.panel.children[0].panel_name, "StoryContainer"); + } +} diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs new file mode 100644 index 0000000..713038a --- /dev/null +++ b/crates/dock/src/tab_panel.rs @@ -0,0 +1,2102 @@ +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use gpui::prelude::FluentBuilder; +use gpui::{ + Anchor, Animation, AnimationExt as _, App, AppContext, Bounds, Context, DismissEvent, Div, + DragMoveEvent, Empty, Entity, EntityId, EventEmitter, FocusHandle, Focusable, + InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, ScrollHandle, + SharedString, StatefulInteractiveElement, StyleRefinement, Styled, WeakEntity, Window, div, + point, px, rems, +}; +use gpui_component::animation::{Lerp, ease_out_cubic}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::menu::{DropdownMenu, PopupMenu}; +use gpui_component::tab::{Tab, TabBar}; +use gpui_component::{ + ActiveTheme, AxisExt, IconName, Placement, Selectable, Sizable, h_flex, v_flex, +}; + +use super::{ + AnyDrag, ClosePanel, DockArea, DockEvent, DockPlacement, DropTarget, Panel, PanelControl, + PanelEvent, PanelState, PanelStyle, PanelView, StackPanel, ToggleZoom, +}; +use crate::{PanelInfo, t}; + +#[derive(Clone)] +struct TabState { + closable: bool, + zoomable: Option, + draggable: bool, + droppable: bool, + active_panel: Option>, +} + +#[derive(Clone)] +pub(crate) struct DragPanel { + pub(crate) panel: Arc, + pub(crate) tab_panel: Entity, + drag_offset: Rc>>, + drag_session_id: u64, +} + +static NEXT_DRAG_SESSION_ID: AtomicU64 = AtomicU64::new(1); + +/// Stands in for [`DragPanel::drag_session_id`] on host-owned drag items, which +/// carry no session of their own. `NEXT_DRAG_SESSION_ID` starts at 1, so 0 never +/// collides. +const ITEM_DRAG_SESSION_ID: u64 = 0; + +impl DragPanel { + pub(crate) fn new(panel: Arc, tab_panel: Entity) -> Self { + Self { + panel, + tab_panel, + drag_offset: Rc::new(Cell::new(Point::default())), + drag_session_id: NEXT_DRAG_SESSION_ID.fetch_add(1, Ordering::Relaxed), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct DropPlaceholderBounds { + origin: Point, + size: gpui::Size, +} + +impl DropPlaceholderBounds { + fn for_placement(bounds: gpui::Bounds, placement: Option) -> Self { + let half_width = bounds.size.width * 0.5; + let half_height = bounds.size.height * 0.5; + + match placement { + Some(Placement::Left) => Self { + origin: Point::default(), + size: gpui::size(half_width, bounds.size.height), + }, + Some(Placement::Right) => Self { + origin: point(half_width, px(0.)), + size: gpui::size(half_width, bounds.size.height), + }, + Some(Placement::Top) => Self { + origin: Point::default(), + size: gpui::size(bounds.size.width, half_height), + }, + Some(Placement::Bottom) => Self { + origin: point(px(0.), half_height), + size: gpui::size(bounds.size.width, half_height), + }, + None => Self { + origin: Point::default(), + size: bounds.size, + }, + } + } +} + +#[derive(Clone, Copy, Debug)] +struct DropPlaceholderAnimation { + drag_session_id: u64, + placement: Option, + from: DropPlaceholderBounds, + to: DropPlaceholderBounds, + epoch: u64, +} + +impl Render for DragPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("drag-panel") + .cursor_grab() + .py_1() + .px_3() + .w_24() + .overflow_hidden() + .whitespace_nowrap() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .text_color(cx.theme().tab_foreground) + .bg(cx.theme().tokens.tab_active) + .opacity(0.75) + .child(self.panel.title(window, cx)) + } +} + +pub struct TabPanel { + focus_handle: FocusHandle, + dock_area: WeakEntity, + /// The stock_panel can be None, if is None, that means the panels can't be split or move + stack_panel: Option>, + pub(crate) panels: Vec>, + pub(crate) active_ix: usize, + /// What each panel was last told via `set_active`, keyed by EntityId; absent means `false`. + notified_active: HashMap, + /// Whether an active-state reconcile task is already queued for this frame. + active_sync_scheduled: bool, + /// If this is true, the Panel closable will follow the active panel's closable, + /// otherwise this TabPanel will not able to close + /// + /// This is used for Dock to limit the last TabPanel not able to close, see [`super::Dock::new`]. + pub(crate) closable: bool, + + tab_bar_scroll_handle: ScrollHandle, + pending_scroll_to_ix: Option, + zoomed: bool, + collapsed: bool, + /// When drag move, will get the placement of the panel to be split + will_split_placement: Option, + drop_placeholder_animation: Option, + drop_placeholder_animation_name: SharedString, + /// Is TabPanel used in Tiles. + in_tiles: bool, +} + +impl Panel for TabPanel { + fn panel_name(&self) -> &'static str { + "TabPanel" + } + + fn title(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.active_panel(cx) + .map(|panel| panel.title(window, cx)) + .unwrap_or("Empty Tab".into_any_element()) + } + + fn closable(&self, cx: &App) -> bool { + if !self.closable { + return false; + } + + // 1. When is the final panel in the dock, it will not able to close. + // 2. When is in the Tiles, it will always able to close (by active panel state). + if !self.draggable(cx) && !self.in_tiles { + return false; + } + + self.active_panel(cx) + .map(|panel| panel.closable(cx)) + .unwrap_or(false) + } + + fn zoomable(&self, cx: &App) -> Option { + self.active_panel(cx).and_then(|panel| panel.zoomable(cx)) + } + + fn visible(&self, cx: &App) -> bool { + self.visible_panels(cx).next().is_some() + } + + fn dropdown_menu( + &mut self, + menu: PopupMenu, + window: &mut Window, + cx: &mut Context, + ) -> PopupMenu { + if let Some(panel) = self.active_panel(cx) { + panel.dropdown_menu(menu, window, cx) + } else { + menu + } + } + + fn toolbar_buttons( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + self.active_panel(cx) + .and_then(|panel| panel.toolbar_buttons(window, cx)) + } + + fn dump(&self, cx: &App) -> PanelState { + let mut state = PanelState::new(self); + for panel in self.panels.iter() { + state.add_child(panel.dump(cx)); + state.info = PanelInfo::tabs(self.active_ix); + } + state + } + + fn inner_padding(&self, cx: &App) -> bool { + self.active_panel(cx) + .is_none_or(|panel| panel.inner_padding(cx)) + } +} + +impl TabPanel { + pub fn new( + stack_panel: Option>, + dock_area: WeakEntity, + _: &mut Window, + cx: &mut Context, + ) -> Self { + let entity_id = cx.entity_id(); + Self { + focus_handle: cx.focus_handle(), + dock_area, + stack_panel, + panels: Vec::new(), + active_ix: 0, + notified_active: HashMap::new(), + active_sync_scheduled: false, + tab_bar_scroll_handle: ScrollHandle::new(), + pending_scroll_to_ix: None, + will_split_placement: None, + drop_placeholder_animation: None, + drop_placeholder_animation_name: format!("dock-drop-placeholder-{entity_id}").into(), + zoomed: false, + collapsed: false, + closable: true, + in_tiles: false, + } + } + + /// Mark the TabPanel as being used in Tiles. + pub(super) fn set_in_tiles(&mut self, in_tiles: bool) { + self.in_tiles = in_tiles; + } + + pub(super) fn set_parent(&mut self, view: WeakEntity) { + self.stack_panel = Some(view); + } + + /// Return current active_panel View + pub fn active_panel(&self, cx: &App) -> Option> { + let panel = self.panels.get(self.active_ix); + + if let Some(panel) = panel { + if panel.visible(cx) { + Some(panel.clone()) + } else { + // Return the first visible panel + self.visible_panels(cx).next() + } + } else { + None + } + } + + pub fn active_ix(&self) -> usize { + self.active_ix + } + + fn set_active_ix(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + if ix == self.active_ix { + return; + } + + self.active_ix = ix; + self.pending_scroll_to_ix = Some(ix); + self.focus_active_panel(window, cx); + self.schedule_active_sync(window, cx); + + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Queue one reconcile task per frame that notifies panels of their + /// frame-end net active state. Using a spawned task (not `defer`) is what + /// guarantees the task runs after every same-frame mutation, including + /// deferred `set_collapsed` from [`super::Dock::set_open`]. + fn schedule_active_sync(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_sync_scheduled { + return; + } + self.active_sync_scheduled = true; + + cx.spawn_in(window, async move |view, cx| { + _ = cx.update(|window, cx| { + let Ok(changes) = view.update(cx, |view, _| view.reconcile_active_states()) else { + return; + }; + // Dispatch outside the TabPanel update so a `set_active` + // handler may call back into this TabPanel without panicking. + for (panel, active) in changes { + panel.set_active(active, window, cx); + } + }); + }) + .detach(); + } + + /// Diff every panel's target state (`ix == active_ix && !collapsed`) + /// against what it was last told, returning the deliveries to make — + /// all `false` first, the single `true` last. Panels no longer in the + /// group are pruned without a `false`: `on_removed` is their signal. + fn reconcile_active_states(&mut self) -> Vec<(Arc, bool)> { + self.active_sync_scheduled = false; + + let mut notified = HashMap::with_capacity(self.panels.len()); + let mut changes = Vec::new(); + let mut activated = None; + for (ix, panel) in self.panels.iter().enumerate() { + let id = panel.view().entity_id(); + let target = ix == self.active_ix && !self.collapsed; + let last = self.notified_active.get(&id).copied().unwrap_or(false); + if target != last { + if target { + activated = Some((panel.clone(), true)); + } else { + changes.push((panel.clone(), false)); + } + } + notified.insert(id, target); + } + self.notified_active = notified; + changes.extend(activated); + changes + } + + /// Add a panel to the end of the tabs + pub fn add_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.add_panel_with_active(panel, true, window, cx); + } + + fn add_panel_with_active( + &mut self, + panel: Arc, + active: bool, + window: &mut Window, + cx: &mut Context, + ) { + assert_ne!( + panel.panel_name(cx), + "StackPanel", + "can not allows add `StackPanel` to `TabPanel`" + ); + + if self + .panels + .iter() + .any(|p| p.view().entity_id() == panel.view().entity_id()) + { + return; + } + + panel.on_added_to(cx.entity().downgrade(), window, cx); + self.panels.push(panel); + // set the active panel to the new panel + if active { + self.set_active_ix(self.panels.len() - 1, window, cx); + } + // Unconditional: set_active_ix early-returns for the first panel, + // which is displayed regardless of `active`. + self.schedule_active_sync(window, cx); + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Add panel to try to split + pub fn add_panel_at( + &mut self, + panel: Arc, + placement: Placement, + size: Option, + window: &mut Window, + cx: &mut Context, + ) { + cx.spawn_in(window, async move |view, cx| { + cx.update(|window, cx| { + view.update(cx, |view, cx| { + view.will_split_placement = Some(placement); + view.split_panel(panel, placement, size, None, window, cx) + }) + .ok() + }) + .ok() + }) + .detach(); + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + fn insert_panel_at( + &mut self, + panel: Arc, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + if self + .panels + .iter() + .any(|p| p.view().entity_id() == panel.view().entity_id()) + { + return; + } + + panel.on_added_to(cx.entity().downgrade(), window, cx); + self.panels.insert(ix, panel); + self.set_active_ix(ix, window, cx); + // set_active_ix early-returns when ix == active_ix, yet the + // displayed panel just changed. + self.schedule_active_sync(window, cx); + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Remove a panel from the tab panel + pub fn remove_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.detach_panel(panel, window, cx); + self.remove_self_if_empty(window, cx); + cx.emit(PanelEvent::ZoomOut); + cx.emit(PanelEvent::LayoutChanged); + } + + /// Detach the panel, returning what it was last told via `set_active` so + /// drag-and-drop can carry that belief into the target `TabPanel`. + fn detach_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Option { + panel.on_removed(window, cx); + let panel_view = panel.view(); + let removed_ix = self.panels.iter().position(|p| p.view() == panel_view); + self.panels.retain(|p| p.view() != panel_view); + // Keep following the same displayed panel. + if removed_ix.is_some_and(|ix| ix < self.active_ix) { + self.active_ix -= 1; + } + if self.active_ix >= self.panels.len() { + self.set_active_ix(self.panels.len().saturating_sub(1), window, cx) + } + self.schedule_active_sync(window, cx); + self.notified_active.remove(&panel_view.entity_id()) + } + + /// Check to remove self from the parent StackPanel, if there is no panel left + fn remove_self_if_empty(&self, window: &mut Window, cx: &mut Context) { + if !self.panels.is_empty() { + return; + } + + let tab_view = cx.entity().clone(); + if let Some(stack_panel) = self.stack_panel.as_ref() { + _ = stack_panel.update(cx, |view, cx| { + view.remove_panel(Arc::new(tab_view), window, cx); + }); + } + } + + pub(super) fn set_collapsed( + &mut self, + collapsed: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.collapsed = collapsed; + self.schedule_active_sync(window, cx); + cx.notify(); + } + + fn is_locked(&self, cx: &App) -> bool { + let Some(dock_area) = self.dock_area.upgrade() else { + return true; + }; + + if dock_area.read(cx).is_locked() { + return true; + } + + if self.zoomed { + return true; + } + + self.stack_panel.is_none() + } + + /// Return true if self or parent only have last panel. + /// + /// Only visible panels are counted, so a hidden panel does not keep the + /// last visible panel draggable/closable (which could otherwise leave the + /// dock visually empty and undroppable). + fn is_last_panel(&self, cx: &App) -> bool { + if let Some(parent) = &self.stack_panel + && let Some(stack_panel) = parent.upgrade() + && !stack_panel.read(cx).is_last_panel(cx) + { + return false; + } + + self.visible_panels(cx).count() <= 1 + } + + /// Return all visible panels + fn visible_panels<'a>(&'a self, cx: &'a App) -> impl Iterator> + 'a { + self.panels.iter().filter_map(|panel| { + if panel.visible(cx) { + Some(panel.clone()) + } else { + None + } + }) + } + + /// Return true if the tab panel is draggable. + /// + /// E.g. if the parent and self only have one panel, it is not draggable. + fn draggable(&self, cx: &App) -> bool { + !self.is_locked(cx) && !self.is_last_panel(cx) + } + + /// Return true if the tab panel is droppable. + /// + /// E.g. if the tab panel is locked, it is not droppable. + fn droppable(&self, cx: &App) -> bool { + !self.is_locked(cx) + } + + fn render_toolbar( + &mut self, + state: &TabState, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + if self.collapsed { + return div(); + } + + let zoomed = self.zoomed; + let view = cx.entity().clone(); + let zoomable_toolbar_visible = state.zoomable.is_some_and(|v| v.toolbar_visible()); + + h_flex() + .gap_1() + .occlude() + .when_some(self.toolbar_buttons(window, cx), |this, buttons| { + this.children( + buttons + .into_iter() + .map(|btn| btn.xsmall().ghost().tab_stop(false)), + ) + }) + .map(|this| { + let value = if zoomed { + Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out"))) + } else if zoomable_toolbar_visible { + Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In"))) + } else { + None + }; + + if let Some((id, icon, tooltip)) = value { + this.child( + Button::new(id) + .icon(icon) + .xsmall() + .ghost() + .tab_stop(false) + .tooltip_with_action(tooltip, &ToggleZoom, None) + .selected(zoomed) + .on_click(cx.listener(|view, _, window, cx| { + view.on_action_toggle_zoom(&ToggleZoom, window, cx) + })), + ) + } else { + this + } + }) + .child( + Button::new("menu") + .icon(IconName::Ellipsis) + .xsmall() + .ghost() + .tab_stop(false) + .dropdown_menu({ + let zoomable = state.zoomable.is_some_and(|v| v.menu_visible()); + let closable = state.closable; + + move |menu, window, cx| { + view.update(cx, |this, cx| { + this.dropdown_menu(menu, window, cx) + .separator() + .menu_with_disabled( + if zoomed { + t("Dock.Zoom Out") + } else { + t("Dock.Zoom In") + }, + Box::new(ToggleZoom), + !zoomable, + ) + .when(closable, |this| { + this.separator().menu(t("Dock.Close"), Box::new(ClosePanel)) + }) + }) + } + }) + .anchor(Anchor::TopRight), + ) + } + + fn render_dock_toggle_button( + &self, + placement: DockPlacement, + _: &mut Window, + cx: &mut Context, + ) -> Option