diff --git a/Cargo.lock b/Cargo.lock index ab991d8..545d8de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7924,6 +7924,7 @@ dependencies = [ "nostr", "nostr-connect", "nostr-sdk", + "paths", "rustls", "signed_core", "signed_git", diff --git a/crates/paths/src/lib.rs b/crates/paths/src/lib.rs index 2de8abf..bc3fe6d 100644 --- a/crates/paths/src/lib.rs +++ b/crates/paths/src/lib.rs @@ -41,6 +41,12 @@ pub fn desktop_dir() -> PathBuf { dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) } +/// Returns the current user's Documents folder, falling back to the home +/// directory (or an empty path) when it can't be determined. +pub fn documents_dir() -> PathBuf { + dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) +} + /// 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. diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index e7bef21..c7951fa 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -63,6 +63,65 @@ impl GitCache { } } +/// Maximum directory nesting depth when scanning for local repositories, +/// so pathological trees can't stall the scan. +const SCAN_MAX_DEPTH: usize = 12; + +/// Directories never descended into during a scan: dependency caches that +/// can be enormous without ever containing user repositories. +const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"]; + +/// Walk `root` recursively and collect the paths of git repositories +/// (directories containing a `.git` entry) below it. +/// +/// Hidden entries and symlinks are skipped, and directories that are +/// themselves repositories are not descended into (so nested repositories, +/// like submodule worktrees, are not reported). Results are canonicalized, +/// deduplicated and sorted by path. +pub fn find_git_repos(root: &Path) -> Vec { + let mut repos = Vec::new(); + if !root.is_dir() { + return repos; + } + + let mut stack = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = stack.pop() { + if depth > SCAN_MAX_DEPTH { + continue; + } + // A directory containing a `.git` entry is a repository (a linked + // worktree has a `.git` file instead of a directory); don't descend. + if dir.join(".git").exists() { + if let Ok(path) = dir.canonicalize() { + repos.push(path); + } + continue; + } + + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() || file_type.is_symlink() { + continue; + } + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + if name.starts_with('.') || SCAN_SKIPPED_DIRS.contains(&name.as_ref()) { + continue; + } + stack.push((entry.path(), depth + 1)); + } + } + + repos.sort(); + repos.dedup(); + repos +} + /// Clone a repository into `path` from the first working URL in /// `clone_urls` (the announcement's `clone` tag), then fetch the /// `refs/nostr/*` PR refs like the cache clone does. The destination must @@ -1552,6 +1611,48 @@ mod tests { ); } + #[test] + fn find_git_repos_discovers_repositories_recursively() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + + // Repositories are found at any depth; a linked worktree (a `.git` + // file instead of a directory) counts too. + let nested = root.join("a/b/project"); + std::fs::create_dir_all(nested.join(".git")).unwrap(); + let worktree = root.join("wt"); + std::fs::create_dir_all(&worktree).unwrap(); + std::fs::write( + worktree.join(".git"), + "gitdir: ../a/b/project/.git/worktrees/wt", + ) + .unwrap(); + + // Plain directories are not repositories. + std::fs::create_dir_all(root.join("plain")).unwrap(); + + // Hidden entries and dependency caches are skipped. + std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap(); + std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap(); + + // A repository is not descended into, so repositories inside it + // (submodule worktrees) are not reported. + let outer = root.join("outer"); + std::fs::create_dir_all(outer.join(".git")).unwrap(); + std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap(); + + let mut found = find_git_repos(root); + found.sort(); + + let mut expected = vec![ + nested.canonicalize().unwrap(), + worktree.canonicalize().unwrap(), + outer.canonicalize().unwrap(), + ]; + expected.sort(); + assert_eq!(found, expected); + } + #[test] fn repo_ref_state_lists_branches_tags_and_head() { let (_dir, repo) = fixture(&[("a.txt", b"hello")]); diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index 0bd56f0..6706315 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -23,3 +23,4 @@ log.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rustls = "0.23" +paths = { path = "../paths" } diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 2e39ab0..927e859 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,5 +1,6 @@ mod backend; mod git_store; +mod local_repos; mod profile; mod repo; mod repo_list; @@ -9,6 +10,7 @@ use std::path::{Path, PathBuf}; pub use backend::{Backend, BackendEvent}; pub use git_store::GitStore; use gpui::{App, AppContext, Entity}; +pub use local_repos::LocalReposStore; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; pub use repo::RepoStore; @@ -16,8 +18,15 @@ pub use repo_list::{RepoActivityCounts, 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. +/// The default directories scanned for local git repositories +/// on every platform: the user's Desktop and Documents folders. +#[cfg(not(target_arch = "wasm32"))] +fn default_scan_paths() -> Vec { + vec![paths::desktop_dir(), paths::documents_dir()] +} + +/// Initialize the backend and stores, and install them as globals. +/// Call once at startup, before opening any window that uses the stores. #[cfg(not(target_arch = "wasm32"))] pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { // rustls uses the `aws_lc_rs` provider by default; ignore if already installed. @@ -25,46 +34,64 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { .install_default() .ok(); - let path = db_path.as_ref().to_path_buf(); + // Initialize the nostr client and universal signer. let (client, signer) = cx.foreground_executor().block_on(async move { + let path = db_path.as_ref().to_path_buf(); new_backend(path) .await .expect("failed to initialize nostr backend") }); + // Initialize the backend and stores. let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(entity.clone(), cx); + // Initialize the profile store. ProfileStore::set_global(cx.new(ProfileStore::new), cx); - // Start the explore list from the local database before the first - // window opens; relay syncs continue in the background, so the list - // never waits for them. + // Start the explore list from the local database before + // the first window opens, relay syncs continue in the background, + // so the list never waits for them. RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); - // The clone cache is only meaningful on native platforms; the wasm - // build registers an empty store so `GitStore::global` still works. + // 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); + // Scan the default directories (Desktop, Documents) for local git + // repositories; the sidebar lists them next to the user's NIP-34 repos. + LocalReposStore::set_global( + cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)), + cx, + ); + entity } /// Initialize the backend with an in-memory database on wasm. #[cfg(target_arch = "wasm32")] pub fn init(cx: &mut App) -> Entity { + // Initialize the nostr client and universal signer. let (client, signer) = new_backend().expect("failed to initialize nostr backend"); + // Initialize the backend and stores. let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(entity.clone(), cx); + // Initialize the profile store. ProfileStore::set_global(cx.new(ProfileStore::new), cx); - // Start the explore list from the local database before the first - // window opens; relay syncs continue in the background, so the list - // never waits for them. + // Start the explore list from the local database before + // the first window opens, relay syncs continue in the background, + // so the list never waits for them. RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), 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); + // No filesystem scan on wasm: there are no local git repositories. + LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); + entity } diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs new file mode 100644 index 0000000..23c42ab --- /dev/null +++ b/crates/signed_state/src/local_repos.rs @@ -0,0 +1,101 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Error; +use gpui::{App, AppContext, Context, Entity, Global, Task}; +use signed_git::find_git_repos; + +struct GlobalLocalReposStore(Entity); + +impl Global for GlobalLocalReposStore {} + +/// Store of the git repositories discovered under a set of scan paths. +/// +/// Created at startup by [`crate::init`] with the default scan paths +/// (the Desktop and Documents folders; empty on wasm, where no scan runs), +/// then installed as a global so the sidebar can list local repositories. +/// The scan runs on a background thread; only the results cross back into the entity. +pub struct LocalReposStore { + /// The directories being scanned. + pub roots: Arc>, + /// Git repositories discovered under [`Self::roots`], sorted by path. + pub repos: Arc>, + /// A scan is currently running. + pub scanning: bool, + /// A scan was requested while one was already running. + scan_dirty: bool, + tasks: Vec>>, +} + +impl LocalReposStore { + /// Retrieve the global local-repositories store + /// (created at startup by [`crate::init`]). + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub(crate) fn set_global(entity: Entity, cx: &mut App) { + cx.set_global(GlobalLocalReposStore(entity)); + } + + /// Create a store scanning `roots` right away + /// (a no-op when the list is empty, e.g. on wasm). + pub fn new(roots: Vec, cx: &mut Context) -> Self { + let mut store = Self { + roots: Arc::new(roots), + repos: Arc::new(Vec::new()), + scanning: false, + scan_dirty: false, + tasks: Vec::new(), + }; + store.rescan(cx); + store + } + + /// Re-run the scan. Requests that arrive while a scan is running are + /// folded into one follow-up scan; the results replace the list atomically. + pub fn rescan(&mut self, cx: &mut Context) { + if self.scanning { + self.scan_dirty = true; + return; + } + if self.roots.is_empty() { + return; + } + + self.scanning = true; + cx.notify(); + + let roots = self.roots.clone(); + let work = cx.background_spawn(async move { + let mut repos = Vec::new(); + for root in roots.iter() { + repos.extend(find_git_repos(root)); + } + repos.sort(); + repos.dedup(); + repos + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let repos = work.await; + let again = this.update(cx, |this, cx| { + this.repos = Arc::new(repos); + this.scanning = false; + cx.notify(); + + let dirty = this.scan_dirty; + this.scan_dirty = false; + dirty + })?; + + // Scans requested while this one was running are coalesced into + // a single follow-up scan. + if again { + this.update(cx, |this, cx| this.rescan(cx))?; + } + + Ok(()) + })); + } +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index dad9bac..c8e584e 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,4 +1,5 @@ use std::ops::Range; +use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use assets::CustomIconName; @@ -18,7 +19,7 @@ use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::InputState; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use signed_core::Announcement; -use signed_state::{Backend, BackendEvent, Profile, ProfileStore, RepoListStore}; +use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore}; use super::{RepoDetailView, RepoListView}; use crate::image_cache::{MAX_IMAGES, image_cache}; @@ -37,20 +38,23 @@ pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, explore: Option>, + logged_in: bool, /// Repositories announced by the current user, listed under /// "All Repositories". Recreated when the signer changes. my_repos: Option>, /// Observes the current user's repo store so the list re-renders. my_repos_subscription: Option, - logged_in: bool, /// Banner artwork shown behind the sign-in screen, /// picked at random from the bundled `backgrounds/` assets. banner: SharedString, + /// Observes the local-repository scan so new discoveries re-render. + _local_repos_subscription: Subscription, _subscription: Subscription, } impl SidebarPanel { pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { + let local_repos_store = LocalReposStore::global(cx); let backend = Backend::global(cx); let logged_in = backend.read(cx).current_user().is_some(); @@ -71,14 +75,19 @@ impl SidebarPanel { cx.notify(); }); + let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| { + cx.notify(); + }); + let mut panel = Self { focus_handle: cx.focus_handle(), dock_area, + logged_in, explore: None, my_repos: None, my_repos_subscription: None, - logged_in, banner: pick_banner(), + _local_repos_subscription: local_repos_subscription, _subscription: subscription, }; @@ -168,9 +177,13 @@ impl SidebarPanel { /// The "All Repositories" section: header with the create button and /// the current user's repositories below it, lazily rendered through a - /// [`uniform_list`]. + /// [`uniform_list`], followed by the local git repositories discovered + /// by the startup scan. fn render_my_repos(&self, cx: &mut Context) -> impl IntoElement { let store = self.my_repos.as_ref(); + let local = LocalReposStore::global(cx); + let local_repos = local.read(cx).repos.clone(); + let scanning = local.read(cx).scanning; v_flex() .px_2() @@ -193,19 +206,37 @@ impl SidebarPanel { .child(div().text_xs().font_semibold().child("All Repositories")), ) .child( - Button::new("add") - .icon(IconName::Plus) - .small() - .ghost() - .on_click(cx.listener(|this, _ev, window, cx| { - this.open_create_repo(window, cx); - })), + h_flex() + .gap_1() + .child( + Button::new("rescan") + .icon(IconName::Redo) + .small() + .ghost() + .tooltip("Rescan for local repositories") + .on_click(cx.listener(|_this, _ev, _window, cx| { + LocalReposStore::global(cx) + .update(cx, |store, cx| store.rescan(cx)); + })), + ) + .child( + Button::new("add") + .icon(IconName::Plus) + .small() + .ghost() + .on_click(cx.listener(|this, _ev, window, cx| { + this.open_create_repo(window, cx); + })), + ), ), ) .when_some(store, |builder, store| { let announcements = store.read(cx).announcements.clone(); + // One merged list: the user's NIP-34 repositories first, + // then the local repositories discovered by the scan. + let total = announcements.len() + local_repos.len(); - if announcements.is_empty() { + if total == 0 { builder.child( div() .flex_1() @@ -213,18 +244,27 @@ impl SidebarPanel { .py_1() .text_xs() .text_color(cx.theme().muted_foreground) - .child("No repositories yet"), + .child(if scanning { + "Scanning for local repositories…" + } else { + "No repositories yet" + }), ) } else { builder.child( uniform_list( "my-repos-list", - announcements.len(), + total, cx.processor(move |this, range: Range, _window, cx| { range .map(|ix| { - this.render_repo_row(&announcements[ix], cx) - .into_any_element() + this.render_repo_row_at( + &announcements, + &local_repos, + ix, + cx, + ) + .into_any_element() }) .collect() }), @@ -236,8 +276,27 @@ impl SidebarPanel { }) } - /// One repository row in the sidebar, styled like the nav items: a - /// deterministic pixel avatar and the repo name. + /// One row of the merged sidebar list: a NIP-34 repository or a local + /// repository. + fn render_repo_row_at( + &self, + announcements: &[Announcement], + local_repos: &[PathBuf], + ix: usize, + cx: &mut Context, + ) -> AnyElement { + if ix < announcements.len() { + return self + .render_repo_row(&announcements[ix], cx) + .into_any_element(); + } + + let local_ix = ix - announcements.len(); + let path = &local_repos[local_ix]; + + self.render_local_row(path, cx).into_any_element() + } + fn render_repo_row( &self, announcement: &Announcement, @@ -255,6 +314,28 @@ impl SidebarPanel { ) } + /// One local repository row: a deterministic pixel avatar seeded from + /// the path, the directory name, and a warning suffix marking it as + /// not yet set up for NIP-34. The row has no click handler — announcing + /// local repositories is future work. + fn render_local_row(&self, path: &Path, cx: &mut Context) -> impl IntoElement { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + + NavItem::new( + format!("local-repo:{}", path.display()), + name, + PixelAvatar::new(path.to_string_lossy()), + ) + .suffix( + Icon::new(IconName::TriangleAlert) + .small() + .text_color(cx.theme().warning), + ) + } + /// Show the Import Identity dialog. fn open_import(&mut self, window: &mut Window, cx: &mut Context) { import_dialog::open(window, cx); @@ -491,8 +572,8 @@ impl Render for SidebarPanel { } /// A single navigation entry in the sidebar: an arbitrary leading element -/// (an icon, avatar, ...) and a text label with a hover highlight and an -/// optional click handler. +/// (an icon, avatar, ...) and a text label with a hover highlight, +/// an optional trailing suffix (e.g. a status icon) and an optional click handler. #[allow(clippy::type_complexity)] #[derive(IntoElement)] struct NavItem { @@ -500,6 +581,8 @@ struct NavItem { style: StyleRefinement, icon: AnyElement, label: SharedString, + /// Trailing element rendered at the right edge of the row, after the (ellipsized) label. + suffix: Option, on_click: Option>, } @@ -515,10 +598,17 @@ impl NavItem { icon: icon.into_any_element(), label: label.into(), style: StyleRefinement::default(), + suffix: None, on_click: None, } } + /// A trailing element rendered at the right edge of the row + fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { self.on_click = Some(Box::new(listener)); self @@ -545,6 +635,9 @@ impl RenderOnce for NavItem { .text_ellipsis() .child(self.label), ) + .when_some(self.suffix, |this, suffix| { + this.child(div().flex_shrink_0().child(suffix)) + }) .hover(|this| this.bg(cx.theme().list_hover)) .when_some(self.on_click, |this, listener| this.on_click(listener)) } diff --git a/docs/TODO.md b/docs/TODO.md index fa6fc2e..b5bfff4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,7 +1,9 @@ # TODO +## Local repository scan + +- [ ] Allow the user to configure which directories are scanned for local git repositories (currently fixed to the Desktop and Documents folders). + ## Create repository dialog -- [ ] Persist the user's preferred local repository folder (the one picked in - the create-repository dialog, defaulting to Desktop) and use it as the - default next time the dialog opens. +- [ ] Persist the user's preferred local repository folder (the one picked in the create-repository dialog, defaulting to Desktop) and use it as the default next time the dialog opens.