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/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/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index c7951fa..6d73636 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -289,6 +289,57 @@ 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 (like `git push --all --tags`), so an initialized +/// repository's whole history is mirrored, not just `main`. +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", "--all", "--tags"]) + .arg(&url) + .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<()> { @@ -1653,6 +1704,31 @@ mod tests { 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 repo_ref_state_lists_branches_tags_and_head() { let (_dir, repo) = fixture(&[("a.txt", b"hello")]); diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 9036190..6abbf42 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::PathBuf; use std::str::FromStr; use std::time::Duration; @@ -549,6 +550,169 @@ impl Backend { }) } + /// 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 state = signed_git::worktree_ref_state(&path)?; + let euc = signed_git::root_commit(&path)?; + Ok::<_, Error>((state, euc)) + } + }); + let (state, euc) = work.await?; + + // 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(); + this.update(cx, |this, cx| { + let builder = build_state(&repo_id, &refs, head.as_deref()); + this.send(builder, cx) + })? + .await?; + + // 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(); + + 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_all(&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>(()) + } + }); + push.await?; + } + + // 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")) + }) + } + /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on /// the credential's prefix. pub fn login(&mut self, credential: &str, cx: &mut Context) { @@ -578,9 +742,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 +750,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(()) })); 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..414d22f --- /dev/null +++ b/crates/workspace/src/views/repo_detail/init_dialog.rs @@ -0,0 +1,466 @@ +//! Dialog guiding the user through publishing an existing local repository +//! to NIP-34 (the "Init" button of a local repository's detail view). +//! +//! Mirrors the flow of `ngit init` / `nak git init`: pick a name, +//! description and grasp servers; on submit the backend publishes the +//! announcement and repository state, then pushes every branch and tag to +//! the grasp servers. On success the dialog closes and the repository +//! switches into its NIP-34 mode in place. + +use std::path::PathBuf; + +use gpui::prelude::*; +use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px}; +use gpui_base::input::TextareaState; +use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants}; +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::filters; +use signed_state::Backend; + +use 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", +]; + +/// Shared state for the Init dialog, so async results can be rendered. +#[derive(Default)] +pub struct InitRepoState { + 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 InitRepoState { + /// 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 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 [`DEFAULT_GRASP_SERVERS`] 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::new_default()); + + load_user_grasp_servers(state.clone(), window, cx); + + window.open_dialog(cx, move |dialog, _window, _cx| { + const DESC: &str = "Publish this local repository to NIP-34. Its branches and tags are pushed to the grasp servers."; + 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 relay_input = relay_input.clone(); + let state = 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(); + 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( + 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)), + ) + .child( + field() + .label("Description") + .child(Textarea::new(&desc_input)), + ) + .child( + field() + .label("Folder") + .description("The local repository being published") + .child( + div() + .h_8() + .w_full() + .px_2() + .items_center() + .bg(cx.theme().muted) + .text_sm() + .text_color(cx.theme().muted_foreground) + .rounded(cx.theme().radius) + .child(local_path.display().to_string()), + ), + ) + .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..."), + ) + }, + ) + })), + ), + ) + .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(IconName::ArrowRight) + .tooltip("Publish to NIP-34") + .loading(busy) + .disabled(busy) + .on_click({ + let name_input = name_input.clone(); + let desc_input = desc_input.clone(); + let state = 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(), + view.clone(), + window, + cx, + ); + } + }), + ), + ) + }) + }); +} + +/// 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()); + }); + } + } +} + +/// Run the init flow; closes the dialog and switches the repository into +/// its NIP-34 mode on success. +fn init_repository( + local_path: PathBuf, + name_input: Entity, + desc_input: Entity, + state: Entity, + view: 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(); + + 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(); +} + +/// 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/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 9a26175..a39b251 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -38,6 +38,7 @@ mod browser; mod commits; mod diff; mod helpers; +mod init_dialog; mod issue_detail; mod issues; mod pull_request_detail; @@ -98,9 +99,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. @@ -161,6 +168,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 +179,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 +260,7 @@ impl RepoDetailView { initial, dock_area, store, + local_path, tree_state, worktree: None, md: None, @@ -254,19 +293,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 +503,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(); @@ -759,6 +830,9 @@ impl RepoDetailView { /// 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 +840,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 +854,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 +864,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,27 +1102,52 @@ 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()) 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()); @@ -1054,9 +1156,6 @@ impl RepoDetailView { let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); 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 ngit_command = SharedString::from(format!("git clone {nostr_url}")); let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); @@ -1066,10 +1165,14 @@ 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); + } } }), ) @@ -1232,11 +1335,9 @@ impl RepoDetailView { .tooltip("About") .secondary() .on_click(cx.listener(|this, _event, window, cx| { - open_about_dialog( - this.announcement(cx).clone(), - window, - cx, - ); + if let Some(announcement) = this.announcement(cx) { + open_about_dialog(announcement.clone(), window, cx); + } })), ) .child({ @@ -1342,157 +1443,257 @@ 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, + ) { + 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); + self.local_path = None; + 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(); diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index c8e584e..b45d857 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -175,6 +175,23 @@ 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`], followed by the local git repositories discovered @@ -210,13 +227,13 @@ impl SidebarPanel { .gap_1() .child( Button::new("rescan") - .icon(IconName::Redo) + .icon(CustomIconName::Refresh) .small() .ghost() .tooltip("Rescan for local repositories") .on_click(cx.listener(|_this, _ev, _window, cx| { - LocalReposStore::global(cx) - .update(cx, |store, cx| store.rescan(cx)); + let local_repos = LocalReposStore::global(cx); + local_repos.update(cx, |store, cx| store.rescan(cx)); })), ) .child( @@ -253,7 +270,7 @@ impl SidebarPanel { } else { builder.child( uniform_list( - "my-repos-list", + "repos", total, cx.processor(move |this, range: Range, _window, cx| { range @@ -316,13 +333,14 @@ impl SidebarPanel { /// One local repository row: a deterministic pixel avatar seeded from /// the path, the directory name, and a warning suffix marking it as - /// not yet set up for NIP-34. The row has no click handler — announcing - /// local repositories is future work. + /// 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()), @@ -334,6 +352,9 @@ impl SidebarPanel { .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.