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;