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/assets/assets/icons/init.svg b/crates/assets/assets/icons/init.svg new file mode 100644 index 0000000..060c043 --- /dev/null +++ b/crates/assets/assets/icons/init.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/refresh.svg b/crates/assets/assets/icons/refresh.svg new file mode 100644 index 0000000..b7ec11e --- /dev/null +++ b/crates/assets/assets/icons/refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/settings.svg b/crates/assets/assets/icons/settings.svg index 5ba9d43..cb2a6ac 100644 --- a/crates/assets/assets/icons/settings.svg +++ b/crates/assets/assets/icons/settings.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 7e6fc8a..d6d44b4 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -74,7 +74,9 @@ pub enum CustomIconName { Share, Trending, Recent, + Refresh, Grid, + Init, } impl IconNamed for CustomIconName { @@ -101,8 +103,10 @@ impl IconNamed for CustomIconName { CustomIconName::Markdown => "icons/markdown.svg", CustomIconName::Share => "icons/share.svg", CustomIconName::Trending => "icons/trending.svg", + CustomIconName::Refresh => "icons/refresh.svg", CustomIconName::Recent => "icons/recent.svg", CustomIconName::Grid => "icons/grid.svg", + CustomIconName::Init => "icons/init.svg", } .into() } 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_core/src/addr.rs b/crates/signed_core/src/addr.rs index 0a7bc51..8617de8 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -2,12 +2,38 @@ use nostr::prelude::*; /// Address of a NIP-34 repository announcement: `30617::`. /// -/// 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. +/// 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) } + +/// Derive a repository identifier from a display name +pub fn identifier_from_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '/' { + c + } else { + '-' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifier_from_name_slugs_like_gitworkshop() { + assert_eq!(identifier_from_name("My Repo"), "My-Repo"); + assert_eq!(identifier_from_name("my-repo"), "my-repo"); + assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-"); + assert_eq!(identifier_from_name("a/b"), "a/b"); + assert_eq!(identifier_from_name("Café"), "Caf-"); + } +} diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index a19d715..b784804 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -8,7 +8,7 @@ pub mod model; pub mod state; pub mod status; -pub use addr::{RepoAddr, repo_addr}; +pub use addr::{RepoAddr, identifier_from_name, repo_addr}; pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override}; pub use clone_url::{CloneTarget, parse_clone_url}; pub use comments::{CommentThread, comment_threads}; diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index e7bef21..33a2ab2 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -1,7 +1,3 @@ -//! Blocking local git operations against GRASP servers. -//! -//! All functions may block; call them inside `cx.background_spawn`. - use std::collections::HashSet; use std::io::Write; use std::path::{Path, PathBuf}; @@ -63,6 +59,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 @@ -197,16 +252,15 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result Result<()> { let url = format!("{base_url}/{owner}/{repo_id}.git"); @@ -230,6 +284,58 @@ pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) - Ok(()) } +/// Push every local branch and tag of the repository at `repo_path` to a grasp server, +/// so an initialized repository's whole history is mirrored. +pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + let url = format!("{base_url}/{owner}/{repo_id}.git"); + + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["push"]) + .arg(&url) + .args(["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"]) + .env("GIT_TERMINAL_PROMPT", "0") + .stderr(Stdio::piped()) + .output() + .context("failed to spawn `git push`")?; + + if !output.status.success() { + bail!( + "git push to {base_url} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + Ok(()) +} + +/// The earliest unique commit of the repository at `repo_path` (a root +/// commit, like `git rev-list --max-parents=0 HEAD`), used as the NIP-34 +/// announcement's `euc` marker. `None` for a repository without commits. +pub fn root_commit(repo_path: &Path) -> Result> { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["rev-list", "--max-parents=0", "HEAD"]) + .env("GIT_TERMINAL_PROMPT", "0") + .stderr(Stdio::piped()) + .output() + .context("failed to spawn `git rev-list`")?; + + // An unborn HEAD (no commits yet) makes `rev-list` fail, + // there is no unique commit to report then. + if !output.status.success() { + return Ok(None); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(str::to_owned) + .filter(|id| id.len() == 40)) +} + /// Add `origin` pointing at `url` when the repository has no remote yet. /// No-op if `origin` already exists. pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { @@ -1552,6 +1658,135 @@ 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 root_commit_reports_the_first_ancestor() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + let dir = dir.path(); + let root = root_commit(dir).expect("root").expect("commit"); + assert_eq!(root.len(), 40); + + // The root commit does not change when history grows. + std::fs::write(dir.join("b.txt"), b"two").expect("write"); + commit_all(&repo, "second"); + assert_eq!( + root_commit(dir).expect("root").as_deref(), + Some(root.as_str()) + ); + } + + #[test] + fn root_commit_is_none_without_commits() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + let workdir = repo.workdir().expect("workdir"); + assert_eq!(root_commit(workdir).expect("root"), None); + } + + #[test] + fn push_all_mirrors_branches_and_tags() { + // A bare "server" repository reachable via a `file://` URL, like a + // grasp server's `{base}/{owner}/{repo-id}.git` layout. + let server = tempfile::tempdir().unwrap(); + let server_repo = server.path().join("npub1test").join("my-repo.git"); + std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&server_repo) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + // Two branches plus a tag are all mirrored. + git_run(dir, &["checkout", "-b", "feature"]); + std::fs::write(dir.join("b.txt"), b"two").expect("write"); + commit_all(&repo, "feature work"); + git_run(dir, &["checkout", "-"]); + git_run(dir, &["tag", "v1.0"]); + + let base_url = format!("file://{}", server.path().display()); + push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); + + let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); + assert!(refs.contains("refs/heads/main")); + assert!(refs.contains("refs/heads/feature")); + assert!(refs.contains("refs/tags/v1.0")); + } + + #[test] + fn push_all_tolerates_a_missing_ref_kind() { + // A repository with only tags (no branches) still pushes: wildcard + // refspecs without a local match are ignored. + let server = tempfile::tempdir().unwrap(); + let server_repo = server.path().join("npub1test").join("my-repo.git"); + std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&server_repo) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + git_run(dir, &["tag", "v1.0"]); + git_run(dir, &["update-ref", "-d", "refs/heads/main"]); + + let base_url = format!("file://{}", server.path().display()); + push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); + + let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); + assert!(refs.contains("refs/tags/v1.0")); + assert!(!refs.contains("refs/heads/")); + } + #[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/backend.rs b/crates/signed_state/src/backend.rs index 9036190..d6d31d6 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; @@ -9,7 +10,7 @@ use nostr::event::IntoEventBuilder; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::{Announcement, build_state, filters, repo_addr}; +use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use crate::git_store::GitStore; @@ -496,15 +497,30 @@ impl Backend { })? .await?; - this.update(cx, |this, cx| { - let builder = build_state( - &repo_id, - &[("refs/heads/main".to_owned(), commit)], - Some("main"), - ); - this.send(builder, cx) - })? - .await?; + let state_event = match this + .update(cx, |this, cx| { + let builder = build_state( + &repo_id, + &[("refs/heads/main".to_owned(), commit)], + Some("main"), + ); + this.send(builder, cx) + })? + .await + { + Ok(state_event) => state_event, + Err(e) => { + this.update(cx, |this, cx| { + this.retract_events(std::slice::from_ref(&event), cx); + }) + .ok(); + + return Err(e.context( + "The repository was announced, but its state could not be published. \ + The announcement has been retracted", + )); + } + }; // 4. Push the initial commit to every grasp server. A server // that fails to accept the push is logged, but the creation @@ -514,38 +530,285 @@ impl Backend { let owner = owner.clone(); let repo_id = repo_id.clone(); let servers = servers.clone(); + push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main) + }); + if let Err(e) = push.await { + // The events are already published; retract them so the + // repository doesn't remain announced without content. + this.update(cx, |this, cx| { + this.retract_events(&[event.clone(), state_event.clone()], cx); + }) + .ok(); + return Err(e.context( + "The repository was announced, but the push to every grasp server failed. \ + The announcement has been retracted", + )); + } + + Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement")) + }) + } + + /// Publish an existing local repository to NIP-34: read its current + /// branches, tags and HEAD, publish the announcement and the repository + /// state to the grasp relays, then push every branch and tag to each + /// grasp server. Also points `origin` at the first grasp server. + /// + /// The events must reach the grasp servers *before* the push, like + /// [`Self::create_repository`]: GRASP servers hold the signed state + /// event in "purgatory" and only accept a push while that + /// authorization is pending. + /// + /// The git work (ref listing, push) runs on background threads. The + /// returned task yields the published announcement on success, so + /// callers can switch the repository into its NIP-34 mode. + pub fn publish_local_repo( + &mut self, + path: PathBuf, + name: &str, + description: &str, + grasp_servers: Vec, + cx: &mut Context, + ) -> Task> { + let name = name.trim().to_owned(); + let description = description.trim().to_owned(); + + if name.is_empty() { + return Task::ready(Err(anyhow!("Repository name is required"))); + } + + if grasp_servers.is_empty() { + return Task::ready(Err(anyhow!("Add at least one grasp server"))); + } + + let Some(public_key) = self.current_user else { + return Task::ready(Err(anyhow!("Sign in to publish a repository"))); + }; + + // The repository identifier is derived from the name, like + // [`Self::create_repository`]: spaces become hyphens, other + // non-alphanumeric characters (except `/`) become hyphens. + let repo_id = identifier_from_name(&name); + + if repo_id.is_empty() || repo_id.len() > 100 { + return Task::ready(Err(anyhow!( + "Repository name must produce an identifier of 1-100 characters" + ))); + } + + if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) { + return Task::ready(Err(anyhow!( + "Repository name must contain at least one alphanumeric character" + ))); + } + + let owner = public_key.to_bech32().unwrap(); + let servers = grasp_servers.clone(); + + cx.spawn(async move |this, cx| { + // 1. Read the local repository's refs (branches, tags, HEAD) + // and its root commit on a background thread. + let work = cx.background_spawn({ + let path = path.clone(); async move { - let mut failures = Vec::new(); - let mut pushed = 0; - for relay in &servers { - let Some(base_url) = grasp_base_url(relay) else { - failures.push(format!("{relay}: no domain")); - continue; - }; - match signed_git::push_main(&path, &base_url, &owner, &repo_id) { - Ok(()) => pushed += 1, - Err(e) => failures.push(format!("{relay}: {e}")), - } - } - - if pushed == 0 { - bail!( - "could not push the repository to any grasp server: {}", - failures.join("; ") - ); - } - for failure in failures { - log::warn!("grasp push failed: {failure}"); - } - - Ok::<_, Error>(()) + let state = signed_git::worktree_ref_state(&path)?; + let euc = signed_git::root_commit(&path)?; + Ok::<_, Error>((state, euc)) } }); - push.await?; + let (state, euc) = work.await?; - Announcement::from_event(&event) - .ok_or_else(|| anyhow!("failed to parse the published announcement")) + // 2. Ensure the grasp servers are in the relay pool; the nostr + // client queues events until each relay is connected. + this.update(cx, |this, cx| { + let urls: Vec = servers.iter().map(ToString::to_string).collect(); + this.add_relays(urls, cx); + })?; + + // 3. Publish the announcement, then the state event, to the + // grasp relays. The state event is the push authorization + // ("purgatory"), so it must be accepted before step 4. + let announcement = GitRepositoryAnnouncement { + id: repo_id.clone(), + name: Some(name.clone()), + description: (!description.is_empty()).then_some(description.clone()), + web: Vec::new(), + clone: servers + .iter() + .filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id)) + .collect(), + relays: servers.clone(), + euc: euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()), + maintainers: Vec::new(), + }; + + let event = this + .update(cx, |this, cx| { + this.send(announcement.into_event_builder(), cx) + })? + .await?; + + let refs = state.refs.clone(); + let head = state.head.clone(); + let state_event = match this + .update(cx, |this, cx| { + let builder = build_state(&repo_id, &refs, head.as_deref()); + this.send(builder, cx) + })? + .await + { + Ok(state_event) => state_event, + Err(e) => { + this.update(cx, |this, cx| { + this.retract_events(std::slice::from_ref(&event), cx); + }) + .ok(); + + return Err(e.context( + "The repository was announced, but its state could not be published. \ + The announcement has been retracted", + )); + } + }; + + // 4. Push every branch and tag to each grasp server. A server + // that fails to accept the push is logged, but the init only + // fails when no server accepted it. An empty repository + // (no refs yet) has nothing to push. + if !refs.is_empty() { + let push = cx.background_spawn({ + let path = path.clone(); + let owner = owner.clone(); + let repo_id = repo_id.clone(); + let servers = servers.clone(); + push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_all) + }); + if let Err(e) = push.await { + this.update(cx, |this, cx| { + this.retract_events(&[event.clone(), state_event.clone()], cx); + }) + .ok(); + + return Err(e.context( + "The repository was announced, but the push to every grasp server failed. \ + The announcement has been retracted", + )); + } + } + + // 5. Point `origin` at the first grasp server so later pushes + // have a target, like the create flow. + if let Some(base) = servers.first().and_then(grasp_base_url) { + let url = format!("{base}/{owner}/{repo_id}.git"); + let path = path.clone(); + cx.background_spawn(async move { + signed_git::ensure_origin(&path, &url).ok(); + }) + .await; + } + + Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement")) + }) + } + + /// Re-push the repository's current refs to the grasp servers announced + /// in its `relays` tag: publishes a fresh state event (the push + /// authorization), then pushes every branch and tag, like the init + /// flow. The repository must have a local clone in the cache. + pub fn push_repository( + &mut self, + announcement: Announcement, + cx: &mut Context, + ) -> Task> { + let addr = announcement.addr(); + let cache = GitStore::global(cx).cache().clone(); + let path = cache.repo_path(&addr); + let owner = announcement + .owner + .to_bech32() + .unwrap_or_else(|_| announcement.owner.to_hex()); + let repo_id = announcement.id.clone(); + let relays = announcement.relays.clone(); + + cx.spawn(async move |this, cx| { + // 1. Read the current refs of the local clone. + let work = cx.background_spawn({ + let path = path.clone(); + async move { signed_git::worktree_ref_state(&path) } + }); + let state = work.await?; + + // 2. Publish a fresh state event; grasp servers authorize a + // push by the state they have seen. + let refs = state.refs.clone(); + let head = state.head.clone(); + this.update(cx, |this, cx| { + let builder = build_state(&repo_id, &refs, head.as_deref()); + this.send(builder, cx) + })? + .await?; + + // 3. Push every branch and tag to the announced grasp servers. + if !refs.is_empty() { + let push = cx.background_spawn({ + let path = path.clone(); + let owner = owner.clone(); + let repo_id = repo_id.clone(); + let relays = relays.clone(); + async move { + push_to_grasp_servers(path, owner, repo_id, relays, signed_git::push_all) + .await + } + }); + push.await?; + } + + Ok(()) + }) + } + + /// Delete the repository from nostr: publish NIP-09 deletions for its + /// announcement, state and activity events (issues, pull requests, + /// patches, statuses, comments). Only the repository owner may delete + /// it. + pub fn delete_repository( + &mut self, + addr: RepoAddr, + cx: &mut Context, + ) -> Task> { + let Some(public_key) = self.current_user else { + return Task::ready(Err(anyhow!("Sign in to delete a repository"))); + }; + if public_key != addr.public_key { + return Task::ready(Err(anyhow!("Only the repository owner can delete it"))); + } + + let client = self.client.clone(); + let addr = addr.clone(); + + cx.spawn(async move |this, cx| { + // Collect every event of the repository from the local database. + let events = cx.background_spawn(async move { + let db = client.database(); + let mut events = Vec::new(); + for filter in [ + filters::announcement(&addr), + filters::state(&addr), + filters::activity(&addr), + ] { + events.extend(db.query(filter).await?); + } + Ok::<_, Error>(events) + }); + let events = events.await?; + + this.update(cx, |this, cx| { + this.retract_events(&events, cx); + }) + .ok(); + + Ok(()) }) } @@ -578,9 +841,7 @@ impl Backend { /// 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) { - let nsec = nsec.trim().to_owned(); - - let keys = match SecretKey::parse(&nsec) { + let keys = match SecretKey::parse(nsec) { Ok(secret) => Keys::new(secret), Err(e) => { cx.emit(BackendEvent::error(e.to_string())); @@ -588,15 +849,15 @@ impl Backend { } }; - let write = - cx.write_credentials(USER_KEYRING, &keys.public_key().to_hex(), nsec.as_bytes()); + let nsec = nsec.trim().to_owned(); + let pubkey = keys.public_key().to_hex(); + let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes()); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = write.await { this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; return Ok(()); } - this.update(cx, |this, cx| this.set_signer(keys, cx))?; Ok(()) })); @@ -1040,6 +1301,32 @@ impl Backend { Ok(()) })); } + + /// Publish a NIP-09 deletion event for `events` (best-effort), so a + /// publish that fails midway can retract the events that were already + /// broadcast to relays. Failures are logged, not surfaced: the caller's + /// error already told the user what happened. + fn retract_events(&mut self, events: &[Event], cx: &mut Context) { + if events.is_empty() { + return; + } + + let mut tags: Vec = Vec::with_capacity(events.len() * 2); + + for event in events { + tags.push(Tag::event(event.id)); + tags.push(Tag::parse(["k", &event.kind.to_string()]).expect("valid kind tag")); + } + + let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx); + + self.tasks.push(cx.spawn(async move |_this, _cx| { + if let Err(e) = task.await { + log::warn!("failed to retract repository events: {e}"); + } + Ok(()) + })); + } } /// Add the given relays, connect to them, and fetch the filters: a one-shot @@ -1128,21 +1415,6 @@ fn with_master_key(uri: &str, keys: &Keys) -> String { format!("{uri}{separator}master={nsec}") } -/// Derive a repository identifier (d-tag) from the repo name, matching ngit -/// and gitworkshop: spaces become hyphens, other non-alphanumeric characters -/// (except `/`) become hyphens, case is preserved. -fn identifier_from_name(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '/' { - c - } else { - '-' - } - }) - .collect() -} - /// A `https://` (or `http://` for `ws://` grasp servers, like /// ngit) base URL for a grasp server. The repository then lives at /// `{base}/{npub}/{repo-id}.git`. @@ -1169,6 +1441,46 @@ fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() } +/// Push the repository at `path` to every grasp server: a server that +/// rejects the push is logged, but the push only fails when no server +/// accepted it. `push` performs the single-server push (e.g. +/// [`signed_git::push_main`] for the create flow, [`signed_git::push_all`] +/// for the init flow). +async fn push_to_grasp_servers( + path: PathBuf, + owner: String, + repo_id: String, + servers: Vec, + push: fn(&Path, &str, &str, &str) -> Result<(), Error>, +) -> Result<(), Error> { + let mut failures = Vec::new(); + let mut pushed = 0; + + for relay in &servers { + let Some(base_url) = grasp_base_url(relay) else { + failures.push(format!("{relay}: no domain")); + continue; + }; + match push(&path, &base_url, &owner, &repo_id) { + Ok(()) => pushed += 1, + Err(e) => failures.push(format!("{relay}: {e}")), + } + } + + if pushed == 0 { + bail!( + "could not push the repository to any grasp server: {}", + failures.join("; ") + ); + } + + for failure in failures { + log::warn!("grasp push failed: {failure}"); + } + + Ok(()) +} + /// 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) { @@ -1187,15 +1499,6 @@ fn extract_master_key(credential: &str) -> (&str, Keys) { mod tests { use super::*; - #[test] - fn identifier_from_name_slugs_like_gitworkshop() { - assert_eq!(identifier_from_name("My Repo"), "My-Repo"); - assert_eq!(identifier_from_name("my-repo"), "my-repo"); - assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-"); - assert_eq!(identifier_from_name("a/b"), "a/b"); - assert_eq!(identifier_from_name("Café"), "Caf-"); - } - #[test] fn grasp_base_url_maps_schemes_like_ngit() { let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url"); 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..934420a --- /dev/null +++ b/crates/signed_state/src/local_repos.rs @@ -0,0 +1,116 @@ +use std::path::{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 + } + + /// Forget a repository that has just been published to NIP-34, so it + /// leaves the local list immediately. A later rescan re-discovers it + /// from disk; the sidebar additionally hides published repositories by + /// identifier. + pub fn remove(&mut self, path: &Path, cx: &mut Context) { + self.repos = Arc::new( + self.repos + .iter() + .filter(|repo| repo.as_path() != path) + .cloned() + .collect(), + ); + cx.notify(); + } + + /// 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/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 6456352..9f00170 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -86,8 +86,13 @@ impl RepoStore { let kind = event.kind == Kind::GitRepoAnnouncement; let author = event.pubkey == this.addr.public_key; let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); + // Locally published deletions may target any event of + // this repository; refresh so they take effect + // immediately, like relay deletions. + let deletion = + event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; - coordinate || (kind && author) + coordinate || (kind && author) || deletion } _ => false, }; diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index e547c95..3d8d91f 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -98,8 +98,14 @@ impl RepoListStore { } } BackendEvent::Published(event) => { - event.kind == Kind::GitRepoAnnouncement - && this.author.is_none_or(|a| a == event.pubkey) + let announcement = event.kind == Kind::GitRepoAnnouncement + && this.author.is_none_or(|a| a == event.pubkey); + // Locally published deletions (e.g. deleting a repo) + // are already in the local database; refresh so they + // take effect immediately, like relay deletions. + let deletion = + event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; + announcement || deletion } BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, _ => false, diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo_detail/about.rs index 1bad904..36c2091 100644 --- a/crates/workspace/src/views/repo_detail/about.rs +++ b/crates/workspace/src/views/repo_detail/about.rs @@ -204,21 +204,22 @@ fn list(id: &'static str, items: impl IntoIterator, cx: &App) -> .min_w_0() .children(items.into_iter().enumerate().map(|(ix, item)| { h_flex() + .id(ix) + .h_8() + .px_2() .gap_2() - .items_center() .min_w_0() + .bg(cx.theme().secondary) + .hover(|this| this.bg(cx.theme().secondary_hover)) + .rounded(cx.theme().radius) + .text_color(cx.theme().secondary_foreground) .child( - h_flex() - .h_5() - .px_1() + div() .flex_1() .min_w_0() - .overflow_hidden() .whitespace_nowrap() .text_ellipsis() .text_sm() - .bg(cx.theme().muted) - .rounded(cx.theme().radius) .child(SharedString::from(middle_truncate(&item, 28, 16))), ) .child( diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo_detail/init_dialog.rs new file mode 100644 index 0000000..09c9f09 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/init_dialog.rs @@ -0,0 +1,217 @@ +use std::path::PathBuf; + +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px}; +use gpui_base::h_flex; +use gpui_base::input::TextareaState; +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, Textarea}; +use gpui_component::{ActiveTheme, Disableable, WindowExt}; +use signed_state::Backend; + +use super::RepoDetailView; +use crate::views::sidebar::grasp_servers::{ + GraspServersState, grasp_servers_field, load_user_grasp_servers, +}; + +/// Shared state for the Init dialog, so async results can be rendered. +#[derive(Default)] +pub struct InitRepoState { + pub busy: bool, + pub error: Option, +} + +/// Open the Init dialog for the local repository at `local_path`. +/// +/// The dialog loads the user's default grasp servers (kind `10317` grasp +/// list) and falls back to the shared defaults when none are set. On +/// success the dialog closes and `view` switches into NIP-34 mode. +pub fn open( + local_path: PathBuf, + view: WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let default_name = local_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name)); + let desc_input = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(3, 5) + .placeholder("Short description") + }); + let relay_input = cx.new(|cx| { + InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com") + }); + let state = cx.new(|_| InitRepoState::default()); + let grasp_state = cx.new(|_| GraspServersState::new_default()); + + load_user_grasp_servers(grasp_state.clone(), window, cx); + + window.open_dialog(cx, move |dialog, _window, _cx| { + const DESC: &str = "Publish this local repository to Nostr."; + + let name_input = name_input.clone(); + let desc_input = desc_input.clone(); + let relay_input = relay_input.clone(); + let state = state.clone(); + let grasp_state = grasp_state.clone(); + let local_path = local_path.clone(); + let view = view.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("Initialize repository")) + .child(DialogDescription::new().child(DESC)), + ) + .child( + v_form() + .child( + field() + .label("Repository name") + .description("Max 100 characters") + .required(true) + .child(Input::new(&name_input).readonly(true)), + ) + .child( + field() + .label("Description") + .child(Textarea::new(&desc_input)), + ) + .child( + field() + .label("Folder") + .description("The local repository being published") + .child( + h_flex() + .h_8() + .w_full() + .px_2() + .bg(cx.theme().muted) + .text_sm() + .text_color(cx.theme().muted_foreground) + .rounded(cx.theme().radius) + .child(local_path.display().to_string()), + ), + ) + .child(grasp_servers_field(&grasp_state, &relay_input, cx)), + ) + .children(error.map(|message| { + div().text_sm().text_color(cx.theme().danger).child(message) + })) + .child( + DialogFooter::new().justify_end().child( + Button::new("init") + .primary() + .label("Initialize") + .icon(CustomIconName::Init) + .tooltip("Publish to Nostr") + .loading(busy) + .disabled(busy) + .on_click({ + let name_input = name_input.clone(); + let desc_input = desc_input.clone(); + let state = state.clone(); + let grasp_state = grasp_state.clone(); + let local_path = local_path.clone(); + let view = view.clone(); + + move |_ev, window, cx| { + init_repository( + local_path.clone(), + (name_input.clone(), desc_input.clone()), + state.clone(), + grasp_state.clone(), + view.clone(), + window, + cx, + ); + } + }), + ), + ) + }) + }); +} + +/// Run the init flow; closes the dialog and switches the repository into +/// its NIP-34 mode on success. +fn init_repository( + local_path: PathBuf, + inputs: (Entity, Entity), + state: Entity, + grasp_state: Entity, + view: WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let (name_input, desc_input) = inputs; + let name = name_input.read(cx).value().trim().to_owned(); + let description = desc_input.read(cx).value().trim().to_owned(); + let servers = grasp_state.read(cx).grasp_servers.clone(); + + if name.is_empty() { + state.update(cx, |state, _| { + state.error = Some("Repository name is required".into()); + }); + return; + } + + if servers.is_empty() { + state.update(cx, |state, _| { + state.error = Some("Add at least one grasp server".into()); + }); + return; + } + + state.update(cx, |state, _| { + state.busy = true; + state.error = None; + }); + + let backend = Backend::global(cx); + let task = backend.update(cx, |backend, cx| { + backend.publish_local_repo(local_path.clone(), &name, &description, servers, cx) + }); + + let handle = window.window_handle(); + let state = state.clone(); + let view = view.clone(); + + cx.spawn(async move |cx| match task.await { + Ok(announcement) => { + cx.update_window(handle, |_, window, cx| { + window.close_dialog(cx); + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| { + this.apply_announcement(announcement, cx); + }); + } + }) + .ok(); + } + Err(e) => { + cx.update_window(handle, |_, _window, cx| { + state.update(cx, |state, _| { + state.busy = false; + state.error = Some(e.to_string().into()); + }); + }) + .ok(); + } + }) + .detach(); +} diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 9a26175..d8558d0 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -13,12 +13,14 @@ use gpui::{ Task, WeakEntity, Window, div, px, relative, size, }; use gpui_base::{Button as BaseButton, Disableable, Popover}; +use gpui_component::alert::Alert; use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, }; +use gpui_component::menu::DropdownMenu; use gpui_component::searchable_list::SearchableVec; use gpui_component::tree::TreeState; use gpui_component::{ @@ -28,7 +30,7 @@ use gpui_component::{ use nostr::prelude::{RelayUrl, ToBech32}; use signed_core::Announcement; use signed_git::{CommitList, FileCommit}; -use signed_state::{GitStore, ProfileStore, RepoStore}; +use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore}; use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::pixel_avatar::PixelAvatar; @@ -38,6 +40,7 @@ mod browser; mod commits; mod diff; mod helpers; +mod init_dialog; mod issue_detail; mod issues; mod pull_request_detail; @@ -73,6 +76,12 @@ enum RepoAction { NewIssue, /// Open the "new pull request" dialog. NewPR, + /// Open the about dialog. + About, + /// Re-push the repository to its grasp servers. + Push, + /// Delete the repository from nostr (owner only). + Delete, } /// Everything loaded from the local clone for the explorer: the tree seeds, @@ -98,9 +107,15 @@ pub struct RepoDetailView { 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, + /// `None` for local repositories that haven't been published yet. + initial: Option, /// Per-repository nostr store (announcement, issues, PRs, statuses). - store: Entity, + /// `None` until a local repository is initialized (published) to + /// NIP-34. + store: Option>, + /// Path of the local repository when opened from the scan; `None` once + /// it has been initialized to NIP-34 (or for announced repositories). + local_path: Option, /// File explorer state (worktree of the local clone). tree_state: Entity, /// Root of the local clone, for reading files on demand. @@ -141,6 +156,8 @@ pub struct RepoDetailView { loading: bool, /// The header clone button is cloning into a user-chosen folder. cloning: bool, + /// A push to the grasp servers is in flight. + pushing: bool, error: Option, /// Commit HEAD currently points to, shown in the header button. head_commit: Option, @@ -161,6 +178,8 @@ pub struct RepoDetailView { } impl RepoDetailView { + /// Open a repository announced on NIP-34: the store connects to the + /// announcement's relays and loads issues, PRs and statuses. pub fn new( dock_area: WeakEntity, initial: Announcement, @@ -170,7 +189,36 @@ impl RepoDetailView { // The announcement we opened from already carries the repository's // NIP-34 `relays` tag, so the store can connect to those relays // immediately instead of waiting for the bootstrap fetch. - let store = cx.new(|cx| RepoStore::new(initial.addr(), initial.relays.clone(), cx)); + let addr = initial.addr(); + let relays = initial.relays.clone(); + let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); + + Self::new_common(dock_area, Some(initial), Some(store), None, window, cx) + } + + /// Open a local repository discovered by the scan. There is no + /// announcement and no nostr store until the user initializes + /// (publishes) it to NIP-34, so the header shows an Init button + /// instead of the NIP-34 actions. + pub fn new_local( + dock_area: WeakEntity, + local_path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new_common(dock_area, None, None, Some(local_path), window, cx) + } + + /// Shared construction: file explorer state, ref selectors and the + /// deferred repository load. + fn new_common( + dock_area: WeakEntity, + initial: Option, + store: Option>, + local_path: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { let tree_state = cx.new(|cx| TreeState::new(cx)); // Empty until the clone completes; populated with the local refs. @@ -222,6 +270,7 @@ impl RepoDetailView { initial, dock_area, store, + local_path, tree_state, worktree: None, md: None, @@ -242,6 +291,7 @@ impl RepoDetailView { item_sizes: Rc::new(Vec::new()), loading: true, cloning: false, + pushing: false, error: None, head_commit: None, branch_select, @@ -254,19 +304,49 @@ impl RepoDetailView { } } - /// 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). + /// Load the repository and populate the file explorer. A local + /// (not yet published) repository is opened straight from disk. An + /// announced repository's 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; cx.notify(); + // Local repositories live on disk at their scan path; there is no + // clone to ensure and no network refresh. + if let Some(local_path) = self.local_path.clone() { + let task = cx.spawn_in(window, async move |this, cx| { + let data = cx + .background_spawn(async move { + let repo = gix::open(&local_path)?; + load_repo_data(&repo) + }) + .await; + + this.update_in(cx, |this, window, cx| { + match data { + Ok(data) => this.apply_repo_data(data, window, cx), + Err(error) => this.error = Some(error.to_string().into()), + } + this.loading = false; + cx.notify(); + })?; + Ok(()) + }); + self.tasks.push(task); + return; + } + + let Some(initial) = self.initial.as_ref() else { + return; + }; 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 addr = initial.addr(); + let clone_urls: Vec = 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; @@ -434,7 +514,9 @@ impl RepoDetailView { } let (clone_urls, name) = { - let announcement = self.announcement(cx); + let Some(announcement) = self.announcement(cx) else { + return; + }; let addr = announcement.addr(); let clone_urls: Vec = announcement.clone.iter().map(ToString::to_string).collect(); @@ -757,8 +839,65 @@ impl RepoDetailView { }); } + /// Re-push the repository's refs to its announced grasp servers; the + /// menu trigger shows a spinner while the push is in flight, failures + /// appear in the panel's error banner. + fn push_repository(&mut self, window: &mut Window, cx: &mut Context) { + if self.pushing { + return; + } + let Some(announcement) = self.announcement(cx).cloned() else { + return; + }; + self.pushing = true; + self.error = None; + cx.notify(); + + let backend = Backend::global(cx); + let task = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx)); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let result = task.await; + this.update_in(cx, |this, _window, cx| { + if let Err(error) = result { + this.error = Some(format!("Push failed: {error}").into()); + } + this.pushing = false; + cx.notify(); + })?; + Ok(()) + })); + } + + /// Delete the repository from nostr (announcement, state and activity); + /// only offered to the repository owner. The sidebar list updates when + /// the deletion events arrive. + fn delete_repository(&mut self, window: &mut Window, cx: &mut Context) { + let Some(announcement) = self.announcement(cx).cloned() else { + return; + }; + let backend = Backend::global(cx); + let task = backend.update(cx, |backend, cx| { + backend.delete_repository(announcement.addr(), cx) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let result = task.await; + this.update_in(cx, |this, _window, cx| { + if let Err(error) = result { + this.error = Some(format!("Delete failed: {error}").into()); + } + cx.notify(); + })?; + Ok(()) + })); + } + /// 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(store) = self.store.clone() else { + return; + }; let Some(dock_area) = self.dock_area.upgrade() else { return; }; @@ -766,7 +905,7 @@ impl RepoDetailView { let panel = cx.new(|cx| { IssuesView::new( self.dock_area.clone(), - self.store.clone(), + store, self.display_name(cx), window, cx, @@ -780,6 +919,9 @@ impl RepoDetailView { /// 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(store) = self.store.clone() else { + return; + }; let Some(dock_area) = self.dock_area.upgrade() else { return; }; @@ -787,7 +929,7 @@ impl RepoDetailView { let panel = cx.new(|cx| { PullRequestsView::new( self.dock_area.clone(), - self.store.clone(), + store, self.display_name(cx), window, cx, @@ -1025,39 +1167,66 @@ impl RepoDetailView { } } - /// The latest announcement from the store, or the open-time snapshot. - fn announcement<'a>(&'a self, cx: &'a App) -> &'a Announcement { - self.store + /// The latest announcement from the store, or the open-time snapshot; + /// `None` for local repositories that haven't been published yet. + fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { + let store = self.store.as_ref()?; + store .read(cx) .announcement .as_ref() - .unwrap_or(&self.initial) + .or(self.initial.as_ref()) } - /// Display name: the announcement's name, or the ID if no name is set. + /// Display name: the announcement's name (or ID) for announced + /// repositories, the directory name for local ones. fn display_name(&self, cx: &App) -> SharedString { - let announcement = self.announcement(cx); - announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + if let Some(path) = &self.local_path { + return SharedString::from( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ); + } + self.announcement(cx) + .map(|announcement| { + announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + }) + .unwrap_or_default() } + /// The NIP-34 header (actions, issues/PR counts) or, for a local + /// repository that hasn't been published yet, the local header with an + /// Init button. fn render_header(&self, cx: &mut Context) -> AnyElement { - let store = self.store.read(cx); - let announcement = store.announcement.as_ref().unwrap_or(&self.initial); + if self.local_path.is_some() { + return self.render_local_header(cx); + } + + let Some(store_entity) = self.store.as_ref() else { + return div().into_any_element(); + }; + let store = store_entity.read(cx); + let Some(announcement) = store + .announcement + .as_ref() + .or(self.initial.as_ref()) + .cloned() + else { + return div().into_any_element(); + }; let issue_count = SharedString::from(store.issue_count().to_string()); let pr_count = SharedString::from(store.pull_request_count().to_string()); let name = self.display_name(cx); let description = announcement.description(); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); - let share = ShareTargets::from_announcement(announcement); + let share = ShareTargets::from_announcement(&announcement); - let commits_count = self.all_commits.as_ref().map(|list| list.total); - let worktree_empty = self.switching_ref || self.worktree.is_none(); - - let nostr_url = nostr_clone_url(announcement, cx); + let nostr_url = nostr_clone_url(&announcement, cx); let ngit_command = SharedString::from(format!("git clone {nostr_url}")); let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); let git_commands = announcement.clone_urls(); @@ -1066,11 +1235,22 @@ impl RepoDetailView { .on_action( cx.listener(|this, action: &RepoAction, window, cx| match action { RepoAction::NewIssue => { - open_new_issue_dialog(this.store.clone(), window, cx); + if let Some(store) = this.store.clone() { + open_new_issue_dialog(store, window, cx); + } } RepoAction::NewPR => { - open_new_pull_request_dialog(this.store.clone(), window, cx); + if let Some(store) = this.store.clone() { + open_new_pull_request_dialog(store, window, cx); + } } + RepoAction::About => { + if let Some(announcement) = this.announcement(cx) { + open_about_dialog(announcement.clone(), window, cx); + } + } + RepoAction::Push => this.push_repository(window, cx), + RepoAction::Delete => this.delete_repository(window, cx), }), ) .px_4() @@ -1160,11 +1340,13 @@ impl RepoDetailView { })), ) .dropdown_menu(|menu, _, _| { - menu.menu_element_with_icon( - IconName::Plus, - Box::new(RepoAction::NewIssue), - |_, _| div().text_xs().child("New issue"), - ) + menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New issue") + }) }), ) .child( @@ -1201,11 +1383,13 @@ impl RepoDetailView { })), ) .dropdown_menu(|menu, _, _| { - menu.menu_element_with_icon( - IconName::Plus, - Box::new(RepoAction::NewPR), - |_, _| div().text_xs().child("New PR"), - ) + menu.menu_element(Box::new(RepoAction::NewPR), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New PR") + }) }), ) .child( @@ -1227,17 +1411,50 @@ impl RepoDetailView { .dropdown_menu(move |menu, _, _| share.menu(menu)), ) .child( - Button::new("info") - .icon(IconName::Info) - .tooltip("About") + Button::new("repo-menu-open") + .icon(IconName::EllipsisVertical) + .tooltip("Repository management") + .compact() .secondary() - .on_click(cx.listener(|this, _event, window, cx| { - open_about_dialog( - this.announcement(cx).clone(), - window, - cx, + .loading(self.pushing) + .disabled(self.pushing) + .dropdown_menu(move |menu, _, cx| { + let backend = Backend::global(cx); + let current_user = backend.read(cx).current_user(); + let owner = current_user == Some(announcement.owner); + + let menu = menu.menu_element( + Box::new(RepoAction::About), + |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Info)) + .child("About") + }, ); - })), + + if owner { + menu.menu_element(Box::new(RepoAction::Push), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(CustomIconName::Init)) + .child("Republish") + }) + .separator() + .menu_element(Box::new(RepoAction::Delete), |_, cx| { + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().danger) + .child(Icon::new(IconName::Delete)) + .child("Delete") + }) + } else { + menu + } + }), ) .child({ let view = cx.entity(); @@ -1342,157 +1559,261 @@ impl RepoDetailView { }), ), ) + .child(self.render_header_tabs(cx)) + .into_any_element() + } + + /// Header for a local (not yet published) repository: the directory + /// name and path with an Init button instead of the NIP-34 actions + /// (issues, pull requests, share, info, clone). + fn render_local_header(&self, cx: &mut Context) -> AnyElement { + let name = self.display_name(cx); + let path = self + .local_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + let avatar = PixelAvatar::new(path.clone()); + + v_flex() + .px_4() + .pb_4() + .w_full() + .gap_8() + .border_b_1() + .border_color(cx.theme().border) .child( h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .min_h_8() + .font_semibold() + .child(avatar.size_6()) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .line_clamp(2) + .line_height(relative(1.25)) + .text_ellipsis() + .child(path), + ), + ) + .child( + Button::new("init") + .icon(CustomIconName::Init) + .label("Initialize on Nostr") + .primary() + .tooltip("Publish this repository to Nostr") + .on_click(cx.listener(|this, _event, window, cx| { + this.open_init_dialog(window, cx); + })), + ), + ) + .child(self.render_header_tabs(cx)) + .into_any_element() + } + + /// Open the dialog guiding the user through publishing the local + /// repository to NIP-34. + fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(local_path) = self.local_path.clone() else { + return; + }; + let view = cx.entity().downgrade(); + init_dialog::open(local_path, view, window, cx); + } + + /// Switch the repository into its NIP-34 mode after a successful init: + /// create the nostr store for the announced repository and drop the + /// local (scan) identity. The worktree is unchanged, so the file + /// explorer keeps its loaded content. + pub(crate) fn apply_announcement( + &mut self, + announcement: Announcement, + cx: &mut Context, + ) { + // The repository is no longer a bare local repo: drop it from the + // scan results so it leaves the sidebar's local section immediately. + if let Some(path) = self.local_path.take() { + LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); + } + let store = + cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); + // Re-render when the store refreshes (issues, PRs, statuses). + self._subscriptions + .push(cx.observe(&store, |_this, _store, cx| cx.notify())); + self.store = Some(store); + self.initial = Some(announcement); + cx.notify(); + } + + /// The tab row shared by both header variants: Files/Commits tabs, the + /// HEAD commit button and the branch/tag selectors. + fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { + let commits_count = self.all_commits.as_ref().map(|list| list.total); + let worktree_empty = self.switching_ref || self.worktree.is_none(); + + h_flex() + .items_center() + .gap_2() + .child( + BaseButton::new("files-tab") + .flex() .items_center() + .h_8() + .px_2() .gap_2() .child( - BaseButton::new("files-tab") - .flex() - .items_center() - .h_8() - .px_2() - .gap_2() - .child( - h_flex() - .gap_1() - .text_sm() - .child(Icon::new(CustomIconName::GitFile).small()) - .child("Files"), - ) - .text_color(cx.theme().button_foreground) - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().button_hover)) - .active(|this| this.bg(cx.theme().button_active)) - .selected(self.active_tab == 0) - .when(self.active_tab == 0, |this| { - this.bg(cx.theme().button_active) - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.active_tab = 0; - cx.notify(); - })), - ) - .child( - BaseButton::new("commits-tab") - .flex() - .items_center() - .h_8() - .px_2() - .gap_2() - .child( - h_flex() - .gap_1() - .text_sm() - .child(Icon::new(CustomIconName::GitCommit).small()) - .child("Commits"), - ) - .when_some(commits_count, |this, count| { - this.child( - h_flex() - .justify_center() - .px_1() - .py_0p5() - .min_w_4() - .text_size(px(8.)) - .bg(cx.theme().muted) - .text_color(cx.theme().muted_foreground) - .rounded(cx.theme().radius) - .line_height(relative(1.)) - .child(SharedString::from(count.to_string())), - ) - }) - .text_color(cx.theme().button_foreground) - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().button_hover)) - .active(|this| this.bg(cx.theme().button_active)) - .selected(self.active_tab == 1) - .when(self.active_tab == 1, |this| { - this.bg(cx.theme().button_active) - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.active_tab = 1; - cx.notify(); - })), + h_flex() + .gap_1() + .text_sm() + .child(Icon::new(CustomIconName::GitFile).small()) + .child("Files"), ) + .text_color(cx.theme().button_foreground) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().button_hover)) + .active(|this| this.bg(cx.theme().button_active)) + .selected(self.active_tab == 0) + .when(self.active_tab == 0, |this| { + this.bg(cx.theme().button_active) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.active_tab = 0; + cx.notify(); + })), + ) + .child( + BaseButton::new("commits-tab") + .flex() + .items_center() + .h_8() + .px_2() + .gap_2() .child( h_flex() - .flex_1() - .gap_2() - .justify_end() - .child( - Button::new("enc") - .ghost() - .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); - } - })), + .gap_1() + .text_sm() + .child(Icon::new(CustomIconName::GitCommit).small()) + .child("Commits"), + ) + .when_some(commits_count, |this, count| { + this.child( + h_flex() + .justify_center() + .px_1() + .py_0p5() + .min_w_4() + .text_size(px(8.)) + .bg(cx.theme().muted) + .text_color(cx.theme().muted_foreground) + .rounded(cx.theme().radius) + .line_height(relative(1.)) + .child(SharedString::from(count.to_string())), + ) + }) + .text_color(cx.theme().button_foreground) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().button_hover)) + .active(|this| this.bg(cx.theme().button_active)) + .selected(self.active_tab == 1) + .when(self.active_tab == 1, |this| { + this.bg(cx.theme().button_active) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.active_tab = 1; + cx.notify(); + })), + ) + .child( + h_flex() + .flex_1() + .gap_2() + .justify_end() + .child( + Button::new("enc") + .ghost() + .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() + }), ) - .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) - }), - ), - ), + .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); + } + })), + ) + .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) + }), + ), ), ) .into_any_element() } fn render_maintainers(&self, cx: &mut Context) -> AnyElement { - let announcement = self.announcement(cx); + let Some(announcement) = self.announcement(cx) else { + return div().into_any_element(); + }; let profile_store = ProfileStore::global(cx); let mut seen = HashSet::new(); @@ -1576,6 +1897,16 @@ impl Render for RepoDetailView { .id("repo") .size_full() .child(self.render_header(cx)) + .when_some(self.error.clone(), |this, error| { + this.child( + Alert::error("repo-error", error) + .banner() + .on_close(cx.listener(|this, _event, _window, cx| { + this.error = None; + cx.notify(); + })), + ) + }) .map(|this| match self.active_tab { 0 => this.child( h_flex() diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index 001c688..7b0c7c0 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -2,56 +2,29 @@ use dock::{DockArea, DockPlacement, panel_handle}; use gpui::prelude::*; use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px}; use gpui_base::input::TextareaState; -use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants}; +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, Textarea}; -use gpui_component::{ActiveTheme, Disableable, IconName, Sizable, WindowExt, h_flex, v_flex}; -use nostr::prelude::*; -use signed_core::{Announcement, filters}; +use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex}; +use signed_core::Announcement; use signed_state::Backend; use super::super::RepoDetailView; - -/// Grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet. -const DEFAULT_GRASP_SERVERS: [&str; 3] = [ - "wss://relay.ngit.dev", - "wss://gitnostr.com", - "wss://git.shakespeare.diy", -]; +use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; /// Shared state for the Create Repository dialog, so async results can be rendered. #[derive(Default)] pub struct CreateRepoState { pub busy: bool, - /// The user's grasp list (kind `10317`) is being loaded. - pub loading_servers: bool, pub error: Option, - pub grasp_servers: Vec, - /// Whether the grasp server section is shown; defaults to shown. - pub servers_enabled: bool, -} - -impl CreateRepoState { - /// Defaults until the user's grasp list arrives; replaced by it when it lists any servers. - fn new_default() -> Self { - Self { - loading_servers: true, - servers_enabled: false, - grasp_servers: DEFAULT_GRASP_SERVERS - .iter() - .filter_map(|url| RelayUrl::parse(url).ok()) - .collect(), - ..Default::default() - } - } } /// Open the Create Repository dialog. /// /// The dialog loads the user's default grasp servers (kind `10317` grasp -/// list) and falls back to [`DEFAULT_GRASP_SERVERS`] when none are set. -/// On success the dialog closes and the new repository opens in the dock. +/// list) and falls back to the shared defaults when none are set. On +/// success the dialog closes and the new repository opens in the dock. pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) { let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name")); let desc_input = cx.new(|cx| { @@ -66,21 +39,21 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) let relay_input = cx.new(|cx| { InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com") }); - let state = cx.new(|_| CreateRepoState::new_default()); + let state = cx.new(|_| CreateRepoState::default()); + let grasp_state = cx.new(|_| GraspServersState::new_default()); - load_user_grasp_servers(state.clone(), window, cx); + load_user_grasp_servers(grasp_state.clone(), window, cx); window.open_dialog(cx, move |dialog, _window, _cx| { const DESC: &str = "Publish a new repository to your grasp servers."; const FOLDER_NOTE: &str = "Where the repository is stored, defaults to your Desktop"; - const SERVER_NOTE: &str = - "Where the repository is hosted, the initial push goes to each server"; let name_input = name_input.clone(); let desc_input = desc_input.clone(); let folder_input = folder_input.clone(); let relay_input = relay_input.clone(); let state = state.clone(); + let grasp_state = grasp_state.clone(); let dock_area = dock_area.clone(); dialog @@ -89,9 +62,6 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) .content(move |content, _window, cx| { let busy = state.read(cx).busy; let error = state.read(cx).error.clone(); - let servers = state.read(cx).grasp_servers.clone(); - let loading_servers = state.read(cx).loading_servers; - let servers_enabled = state.read(cx).servers_enabled; content .child( @@ -137,89 +107,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) ), ), ) - .child( - field() - .label_fn({ - let state = state.clone(); - move |_window, cx| { - let enabled = state.read(cx).servers_enabled; - h_flex() - .w_full() - .justify_between() - .items_center() - .gap_1() - .child( - Toggle::new("grasp-servers-toggle") - .xsmall() - .ghost() - .icon({ - if enabled { - IconName::ChevronDown - } else { - IconName::ChevronUp - } - }) - .checked(enabled) - .on_click({ - let state = state.clone(); - move |checked, _window, cx| { - state.update(cx, |state, cx| { - state.servers_enabled = - *checked; - cx.notify(); - }); - } - }), - ) - .child(div().child("Grasp servers")) - } - }) - .when(servers_enabled, |this| this.description(SERVER_NOTE)) - .child(v_flex().gap_1().when(servers_enabled, |this| { - this.children(servers.iter().enumerate().map( - |(ix, relay)| { - render_server_row(ix, relay, state.clone(), cx) - }, - )) - .child( - h_flex() - .gap_1() - .items_center() - .child( - div().flex_1().child(Input::new(&relay_input)), - ) - .child( - Button::new("add-relay") - .icon(IconName::Plus) - .ghost() - .tooltip("Add grasp server") - .on_click({ - let state = state.clone(); - let relay_input = relay_input.clone(); - move |_ev, window, cx| { - add_relay( - &state, - &relay_input, - window, - cx, - ); - } - }), - ), - ) - .when( - loading_servers, - |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("Loading your grasp servers..."), - ) - }, - ) - })), - ), + .child(grasp_servers_field(&grasp_state, &relay_input, cx)), ) .children(error.map(|message| { div().text_sm().text_color(cx.theme().danger).child(message) @@ -237,6 +125,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) let name_input = name_input.clone(); let desc_input = desc_input.clone(); let state = state.clone(); + let grasp_state = grasp_state.clone(); let dock_area = dock_area.clone(); move |_ev, window, cx| { @@ -244,6 +133,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) name_input.clone(), desc_input.clone(), state.clone(), + grasp_state.clone(), dock_area.clone(), window, cx, @@ -256,53 +146,6 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) }); } -/// A grasp server row: the host as a tag plus a remove button. -fn render_server_row( - ix: usize, - relay: &RelayUrl, - state: Entity, - cx: &App, -) -> impl IntoElement { - h_flex() - .w_full() - .gap_1() - .items_center() - .child( - h_flex() - .h_8() - .w_full() - .px_2() - .bg(cx.theme().muted) - .text_color(cx.theme().muted_foreground) - .text_sm() - .rounded(cx.theme().radius) - .child(display_server(relay)), - ) - .child( - Button::new(format!("remove-relay:{ix}")) - .icon(IconName::Close) - .ghost() - .flex_shrink_0() - .tooltip("Remove") - .on_click({ - let state = state.clone(); - move |_ev, _window, cx| { - state.update(cx, |state, _| { - state.grasp_servers.remove(ix); - }); - } - }), - ) -} - -/// The bare host of a grasp server (defaults are entered without a scheme). -fn display_server(relay: &RelayUrl) -> SharedString { - relay - .domain() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(relay.to_string())) -} - /// Prompt the user to pick the folder the repository will be stored in, using /// the platform's native folder picker, and show the result in the disabled /// folder input. @@ -331,54 +174,19 @@ fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mu .detach(); } -/// Parse the relay input (accepting a bare host) and append it to the list. -fn add_relay( - state: &Entity, - input: &Entity, - window: &mut Window, - cx: &mut App, -) { - let value = input.read(cx).value().trim().to_owned(); - if value.is_empty() { - return; - } - - let normalized = if value.contains("://") { - value.clone() - } else { - format!("wss://{value}") - }; - - match RelayUrl::parse(&normalized) { - Ok(relay) => { - state.update(cx, |state, _| { - state.error = None; - if !state.grasp_servers.contains(&relay) { - state.grasp_servers.push(relay); - } - }); - input.update(cx, |input, cx| input.set_value("", window, cx)); - } - Err(_) => { - state.update(cx, |state, _| { - state.error = Some(format!("Invalid grasp server URL: {value}").into()); - }); - } - } -} - /// Run the create-repository flow; closes the dialog and opens the new repository on success. fn create_repository( name_input: Entity, desc_input: Entity, state: Entity, + grasp_state: Entity, dock_area: WeakEntity, window: &mut Window, cx: &mut App, ) { let name = name_input.read(cx).value().trim().to_owned(); let description = desc_input.read(cx).value().trim().to_owned(); - let servers = state.read(cx).grasp_servers.clone(); + let servers = grasp_state.read(cx).grasp_servers.clone(); if name.is_empty() { state.update(cx, |state, _| { @@ -444,54 +252,3 @@ fn open_repo( dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); }); } - -/// Load the user's grasp list (kind `10317`) from the local database and -/// replace the defaults with it when it lists any servers. -fn load_user_grasp_servers(state: Entity, window: &mut Window, cx: &mut App) { - let backend = Backend::global(cx); - let Some(user) = backend.read(cx).current_user() else { - state.update(cx, |state, _| state.loading_servers = false); - return; - }; - let client = backend.read(cx).client(); - let handle = window.window_handle(); - - cx.spawn(async move |cx| { - let result: anyhow::Result> = async { - let mut events: Vec = client - .database() - .query(filters::grasp_list(user)) - .await? - .into_iter() - .collect(); - events.sort_by_key(|event| event.created_at); - - Ok(events - .into_iter() - .last() - .map(|event| { - event - .tags - .iter() - .filter(|tag| tag.kind() == "g") - .filter_map(|tag| tag.content()) - .filter_map(|url| RelayUrl::parse(url).ok()) - .collect() - }) - .unwrap_or_default()) - } - .await; - - let _ = cx.update_window(handle, |_, _window, cx| { - state.update(cx, |state, _| { - state.loading_servers = false; - if let Ok(servers) = result - && !servers.is_empty() - { - state.grasp_servers = servers; - } - }); - }); - }) - .detach(); -} diff --git a/crates/workspace/src/views/sidebar/grasp_servers.rs b/crates/workspace/src/views/sidebar/grasp_servers.rs new file mode 100644 index 0000000..25a9d0a --- /dev/null +++ b/crates/workspace/src/views/sidebar/grasp_servers.rs @@ -0,0 +1,278 @@ +use gpui::prelude::*; +use gpui::{App, Entity, SharedString, Window, div}; +use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants}; +use gpui_component::form::{Field, field}; +use gpui_component::input::{Input, InputState}; +use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex}; +use nostr::prelude::*; +use signed_core::filters; +use signed_state::Backend; + +/// Grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet. +const DEFAULT_GRASP_SERVERS: [&str; 3] = [ + "wss://relay.ngit.dev", + "wss://gitnostr.com", + "wss://git.shakespeare.diy", +]; + +/// State of the grasp-server section of a publish dialog, so async +/// results can be rendered. +#[derive(Default)] +pub struct GraspServersState { + /// The user's grasp list (kind `10317`) is being loaded. + pub loading_servers: bool, + pub grasp_servers: Vec, + /// Whether the grasp server section is shown; defaults to shown. + pub servers_enabled: bool, + /// Error of the last grasp-server edit (e.g. an invalid relay URL). + pub error: Option, +} + +impl GraspServersState { + /// Defaults until the user's grasp list arrives; replaced by it when it lists any servers. + pub fn new_default() -> Self { + Self { + loading_servers: true, + servers_enabled: false, + grasp_servers: DEFAULT_GRASP_SERVERS + .iter() + .filter_map(|url| RelayUrl::parse(url).ok()) + .collect(), + ..Default::default() + } + } +} + +/// The "Grasp servers" form field shared by the publish dialogs: an +/// expandable toggle, the configured servers (each removable) and an +/// add-relay input, with a loading hint while the user's grasp list +/// (kind `10317`) is being fetched. +pub fn grasp_servers_field( + state: &Entity, + relay_input: &Entity, + cx: &App, +) -> Field { + const SERVER_NOTE: &str = + "Where the repository is hosted, the initial push goes to each server"; + + let state = state.clone(); + let relay_input = relay_input.clone(); + + let servers = state.read(cx).grasp_servers.clone(); + let loading_servers = state.read(cx).loading_servers; + let servers_enabled = state.read(cx).servers_enabled; + let error = state.read(cx).error.clone(); + + field() + .label_fn({ + let state = state.clone(); + move |_window, cx| { + let enabled = state.read(cx).servers_enabled; + h_flex() + .w_full() + .justify_between() + .items_center() + .gap_1() + .child( + Toggle::new("grasp-servers-toggle") + .xsmall() + .ghost() + .icon({ + if enabled { + IconName::ChevronDown + } else { + IconName::ChevronUp + } + }) + .checked(enabled) + .on_click({ + let state = state.clone(); + move |checked, _window, cx| { + state.update(cx, |state, cx| { + state.servers_enabled = *checked; + cx.notify(); + }); + } + }), + ) + .child(div().child("Grasp servers")) + } + }) + .when(servers_enabled, |this| this.description(SERVER_NOTE)) + .child(v_flex().gap_1().when(servers_enabled, |this| { + this.children( + servers + .iter() + .enumerate() + .map(|(ix, relay)| render_server_row(ix, relay, state.clone(), cx)), + ) + .child( + h_flex() + .gap_1() + .items_center() + .child(div().flex_1().child(Input::new(&relay_input))) + .child( + Button::new("add-relay") + .icon(IconName::Plus) + .ghost() + .tooltip("Add grasp server") + .on_click({ + let state = state.clone(); + let relay_input = relay_input.clone(); + move |_ev, window, cx| { + add_relay(&state, &relay_input, window, cx); + } + }), + ), + ) + .when(loading_servers, |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Loading your grasp servers..."), + ) + }) + .when_some(error, |this, error| { + this.child(div().text_xs().text_color(cx.theme().danger).child(error)) + }) + })) +} + +/// A grasp server row: the host as a tag plus a remove button. +fn render_server_row( + ix: usize, + relay: &RelayUrl, + state: Entity, + cx: &App, +) -> impl IntoElement { + h_flex() + .w_full() + .gap_1() + .items_center() + .child( + h_flex() + .h_8() + .w_full() + .px_2() + .bg(cx.theme().muted) + .text_color(cx.theme().muted_foreground) + .text_sm() + .rounded(cx.theme().radius) + .child(display_server(relay)), + ) + .child( + Button::new(format!("remove-relay:{ix}")) + .icon(IconName::Close) + .ghost() + .flex_shrink_0() + .tooltip("Remove") + .on_click({ + let state = state.clone(); + move |_ev, _window, cx| { + state.update(cx, |state, _| { + state.grasp_servers.remove(ix); + }); + } + }), + ) +} + +/// The bare host of a grasp server (defaults are entered without a scheme). +fn display_server(relay: &RelayUrl) -> SharedString { + relay + .domain() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(relay.to_string())) +} + +/// Parse the relay input (accepting a bare host) and append it to the list. +fn add_relay( + state: &Entity, + input: &Entity, + window: &mut Window, + cx: &mut App, +) { + let value = input.read(cx).value().trim().to_owned(); + if value.is_empty() { + return; + } + + let normalized = if value.contains("://") { + value.clone() + } else { + format!("wss://{value}") + }; + + match RelayUrl::parse(&normalized) { + Ok(relay) => { + state.update(cx, |state, _| { + state.error = None; + if !state.grasp_servers.contains(&relay) { + state.grasp_servers.push(relay); + } + }); + input.update(cx, |input, cx| input.set_value("", window, cx)); + } + Err(_) => { + state.update(cx, |state, _| { + state.error = Some(format!("Invalid grasp server URL: {value}").into()); + }); + } + } +} + +/// Load the user's grasp list (kind `10317`) from the local database and +/// replace the defaults with it when it lists any servers. +pub fn load_user_grasp_servers( + state: Entity, + window: &mut Window, + cx: &mut App, +) { + let backend = Backend::global(cx); + let Some(user) = backend.read(cx).current_user() else { + state.update(cx, |state, _| state.loading_servers = false); + return; + }; + let client = backend.read(cx).client(); + let handle = window.window_handle(); + + cx.spawn(async move |cx| { + let result: anyhow::Result> = async { + let mut events: Vec = client + .database() + .query(filters::grasp_list(user)) + .await? + .into_iter() + .collect(); + events.sort_by_key(|event| event.created_at); + + Ok(events + .into_iter() + .last() + .map(|event| { + event + .tags + .iter() + .filter(|tag| tag.kind() == "g") + .filter_map(|tag| tag.content()) + .filter_map(|url| RelayUrl::parse(url).ok()) + .collect() + }) + .unwrap_or_default()) + } + .await; + + let _ = cx.update_window(handle, |_, _window, cx| { + state.update(cx, |state, _| { + state.loading_servers = false; + if let Ok(servers) = result + && !servers.is_empty() + { + state.grasp_servers = servers; + } + }); + }); + }) + .detach(); +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index dad9bac..98886a5 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,4 +1,6 @@ +use std::collections::HashSet; use std::ops::Range; +use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use assets::CustomIconName; @@ -17,14 +19,15 @@ use gpui_component::avatar::Avatar; 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_core::{Announcement, identifier_from_name}; +use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore}; use super::{RepoDetailView, RepoListView}; use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::pixel_avatar::PixelAvatar; mod create_repo_dialog; +pub(crate) mod grasp_servers; mod import_dialog; mod onboarding_dialog; pub(crate) mod passphrase_dialog; @@ -37,20 +40,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 +77,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, }; @@ -166,11 +177,32 @@ impl SidebarPanel { }); } + /// Open a local repository's detail view in the dock's center; the + /// detail view offers to publish it to NIP-34. + fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { + let detail = + cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx)); + + let _ = self.dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel_view( + panel_handle(detail), + DockPlacement::Center, + None, + window, + cx, + ); + }); + } + /// 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 +225,54 @@ 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(CustomIconName::Refresh) + .small() + .ghost() + .tooltip("Rescan for local repositories") + .on_click(cx.listener(|_this, _ev, _window, cx| { + let local_repos = LocalReposStore::global(cx); + local_repos.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(); + // Local repositories that have already been published to + // NIP-34 are listed among the user's repositories above; + // hide them from the local section (matched by the + // identifier derived from the directory name, like the + // init dialog's default name). + let announced_ids: HashSet = + announcements.iter().map(|a| a.id.clone()).collect(); + let local_repos: Vec = local_repos + .iter() + .filter(|path| { + let Some(name) = path.file_name() else { + return true; + }; + !announced_ids.contains(&identifier_from_name(&name.to_string_lossy())) + }) + .cloned() + .collect(); + // 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 +280,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(), + "repos", + 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 +312,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 +350,32 @@ 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. Clicking it opens the repository's + /// detail view, which offers to initialize it. + 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()); + let path = path.to_path_buf(); + + 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), + ) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.open_local_repo(path.clone(), window, cx); + })) + } + /// Show the Import Identity dialog. fn open_import(&mut self, window: &mut Window, cx: &mut Context) { import_dialog::open(window, cx); @@ -491,8 +612,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 +621,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 +638,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 +675,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.