This commit is contained in:
2026-08-31 11:02:20 +07:00
parent 675e40ca1c
commit 43ea3708d2
6 changed files with 367 additions and 591 deletions
+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M19.2479 6.923L12.9979 3.32455C12.3802 2.96888 11.6198 2.96888 11.0021 3.32455L4.75208 6.92297C4.13211 7.27992 3.75 7.94083 3.75 8.65622V15.3439C3.75 16.0593 4.13213 16.7202 4.75213 17.0772L11.0021 20.6754C11.6198 21.031 12.3802 21.031 12.9979 20.6753L19.2479 17.0769C19.8679 16.7199 20.25 16.059 20.25 15.3436V8.65625C20.25 7.94086 19.8679 7.27995 19.2479 6.923Z" stroke="black" stroke-width="1.5" stroke-linecap="square"/><path d="M15.25 12C15.25 13.7949 13.7949 15.25 12 15.25C10.2051 15.25 8.75 13.7949 8.75 12C8.75 10.2051 10.2051 8.75 12 8.75C13.7949 8.75 15.25 10.2051 15.25 12Z" stroke="black" stroke-width="1.5" stroke-linecap="square"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M7.878 5.21415L7.17474 5.05186C6.58003 4.91462 5.95657 5.09343 5.525 5.525C5.09343 5.95657 4.91462 6.58003 5.05186 7.17474L5.21415 7.878C5.40122 8.6886 5.06696 9.53036 4.37477 9.99182L3.51965 10.5619C3.03881 10.8825 2.75 11.4221 2.75 12C2.75 12.5779 3.03881 13.1175 3.51965 13.4381L4.37477 14.0082C5.06696 14.4696 5.40122 15.3114 5.21415 16.122L5.05186 16.8253C4.91462 17.42 5.09343 18.0434 5.525 18.475C5.95657 18.9066 6.58003 19.0854 7.17474 18.9481L7.878 18.7858C8.6886 18.5988 9.53036 18.933 9.99182 19.6252L10.5619 20.4804C10.8825 20.9612 11.4221 21.25 12 21.25C12.5779 21.25 13.1175 20.9612 13.4381 20.4804L14.0082 19.6252C14.4696 18.933 15.3114 18.5988 16.122 18.7858L16.8253 18.9481C17.42 19.0854 18.0434 18.9066 18.475 18.475C18.9066 18.0434 19.0854 17.42 18.9481 16.8253L18.7858 16.122C18.5988 15.3114 18.933 14.4696 19.6252 14.0082L20.4804 13.4381C20.9612 13.1175 21.25 12.5779 21.25 12C21.25 11.4221 20.9612 10.8825 20.4804 10.5619L19.6252 9.99182C18.933 9.53036 18.5988 8.6886 18.7858 7.878L18.9481 7.17473C19.0854 6.58003 18.9066 5.95657 18.475 5.525C18.0434 5.09343 17.42 4.91462 16.8253 5.05186L16.122 5.21415C15.3114 5.40122 14.4696 5.06696 14.0082 4.37477L13.4381 3.51965C13.1175 3.03881 12.5779 2.75 12 2.75C11.4221 2.75 10.8825 3.03881 10.5619 3.51965L9.99182 4.37477C9.53036 5.06696 8.6886 5.40122 7.878 5.21415Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M14.75 12C14.75 13.5188 13.5188 14.75 12 14.75C10.4812 14.75 9.25 13.5188 9.25 12C9.25 10.4812 10.4812 9.25 12 9.25C13.5188 9.25 14.75 10.4812 14.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 1.7 KiB

+44 -57
View File
@@ -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>
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<RelayUrl>,
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) {
@@ -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<SharedString>,
pub grasp_servers: Vec<RelayUrl>,
/// 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<InitRepoState>,
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<InitRepoState>,
input: &Entity<InputState>,
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<InputState>,
desc_input: Entity<TextareaState>,
inputs: (Entity<InputState>, Entity<TextareaState>),
state: Entity<InitRepoState>,
grasp_state: Entity<GraspServersState>,
view: WeakEntity<RepoDetailView>,
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<InitRepoState>, 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<Vec<RelayUrl>> = async {
let mut events: Vec<Event> = 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();
}
@@ -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<SharedString>,
pub grasp_servers: Vec<RelayUrl>,
/// 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<DockArea>, 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<DockArea>, 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<DockArea>, 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<DockArea>, 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<DockArea>, 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<DockArea>, 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<DockArea>, 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<CreateRepoState>,
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<InputState>, 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<CreateRepoState>,
input: &Entity<InputState>,
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<InputState>,
desc_input: Entity<TextareaState>,
state: Entity<CreateRepoState>,
grasp_state: Entity<GraspServersState>,
dock_area: WeakEntity<DockArea>,
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<CreateRepoState>, 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<Vec<RelayUrl>> = async {
let mut events: Vec<Event> = 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();
}
@@ -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<RelayUrl>,
/// 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<SharedString>,
}
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<GraspServersState>,
relay_input: &Entity<InputState>,
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<GraspServersState>,
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<GraspServersState>,
input: &Entity<InputState>,
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<GraspServersState>,
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<Vec<RelayUrl>> = async {
let mut events: Vec<Event> = 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();
}
@@ -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;