From 43ea3708d2582f3dda2e47e123d8e3932fb53b03 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 31 Aug 2026 11:02:20 +0700 Subject: [PATCH] clean up --- crates/assets/assets/icons/settings.svg | 4 +- crates/signed_state/src/backend.rs | 101 +++--- .../src/views/repo_detail/init_dialog.rs | 301 ++---------------- .../src/views/sidebar/create_repo_dialog.rs | 273 +--------------- .../src/views/sidebar/grasp_servers.rs | 278 ++++++++++++++++ crates/workspace/src/views/sidebar/mod.rs | 1 + 6 files changed, 367 insertions(+), 591 deletions(-) create mode 100644 crates/workspace/src/views/sidebar/grasp_servers.rs 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/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 6abbf42..46eb9a3 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; @@ -515,38 +515,11 @@ impl Backend { 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_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>(()) - } + push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main) }); push.await?; - Announcement::from_event(&event) - .ok_or_else(|| anyhow!("failed to parse the published announcement")) + Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement")) }) } @@ -667,33 +640,7 @@ impl Backend { 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_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_all) }); push.await?; } @@ -1331,6 +1278,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) { diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo_detail/init_dialog.rs index 414d22f..e1c420c 100644 --- a/crates/workspace/src/views/repo_detail/init_dialog.rs +++ b/crates/workspace/src/views/repo_detail/init_dialog.rs @@ -1,66 +1,33 @@ -//! 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 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, 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::filters; +use gpui_component::{ActiveTheme, Disableable, WindowExt}; 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", -]; +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, - /// 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 +/// 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, @@ -81,19 +48,19 @@ pub fn open( 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()); + let state = cx.new(|_| InitRepoState::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 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"; + 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(); @@ -103,9 +70,6 @@ pub fn open( .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( @@ -132,11 +96,10 @@ pub fn open( .label("Folder") .description("The local repository being published") .child( - div() + h_flex() .h_8() .w_full() .px_2() - .items_center() .bg(cx.theme().muted) .text_sm() .text_color(cx.theme().muted_foreground) @@ -144,89 +107,7 @@ pub fn open( .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..."), - ) - }, - ) - })), - ), + .child(grasp_servers_field(&grasp_state, &relay_input, cx)), ) .children(error.map(|message| { div().text_sm().text_color(cx.theme().danger).child(message) @@ -236,23 +117,24 @@ pub fn open( Button::new("init") .primary() .label("Initialize") - .icon(IconName::ArrowRight) - .tooltip("Publish to NIP-34") + .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(), + (name_input.clone(), desc_input.clone()), state.clone(), + grasp_state.clone(), view.clone(), window, cx, @@ -265,103 +147,21 @@ pub fn open( }); } -/// 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, + 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 = state.read(cx).grasp_servers.clone(); + let servers = grasp_state.read(cx).grasp_servers.clone(); if name.is_empty() { state.update(cx, |state, _| { @@ -369,6 +169,7 @@ fn init_repository( }); return; } + if servers.is_empty() { state.update(cx, |state, _| { state.error = Some("Add at least one grasp server".into()); @@ -385,6 +186,7 @@ fn init_repository( 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(); @@ -413,54 +215,3 @@ fn init_repository( }) .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/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 b45d857..8d08dd6 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -26,6 +26,7 @@ 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;