feat: push checkout (#14)

Reviewed-on: https://git.reya.su/reya/signed/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
2026-09-06 13:14:11 +00:00
parent 33cbe42551
commit 00167c6a8d
85 changed files with 6282 additions and 4487 deletions
@@ -1,31 +1,26 @@
use std::path::PathBuf;
use dock::DockArea;
use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
use gpui::{App, Entity, PathPromptOptions, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
use gpui_component::{Disableable, IconName, WindowExt, h_flex};
use settings::SettingsStore;
use signed_core::Announcement;
use signed_state::Backend;
use signed_state::{Backend, CheckoutsStore};
use super::super::open_repo_panel;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
use crate::views::dialog_state::{DialogProgress, error_row};
/// Shared state for the Create Repository dialog, so async results can be rendered.
#[derive(Default)]
pub struct CreateRepoState {
pub busy: bool,
pub error: Option<SharedString>,
}
pub type CreateRepoState = DialogProgress;
/// Open the Create Repository dialog.
///
/// The dialog loads the user's default grasp servers (kind `10317` grasp
/// list) and falls back to the shared defaults when none are set. On
/// success the dialog closes and the new repository opens in the dock.
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
let settings = SettingsStore::global(cx);
let default_folder = settings
@@ -56,7 +51,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
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.";
const FOLDER_NOTE: &str = "Where the repository's working copy is created.";
let name_input = name_input.clone();
let desc_input = desc_input.clone();
@@ -119,9 +114,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
)
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
}))
.children(error_row(&error, cx))
.child(
DialogFooter::new().justify_end().child(
Button::new("create")
@@ -134,6 +127,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
.on_click({
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let folder_input = folder_input.clone();
let state = state.clone();
let grasp_state = grasp_state.clone();
let dock_area = dock_area.clone();
@@ -142,6 +136,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
create_repository(
name_input.clone(),
desc_input.clone(),
folder_input.clone(),
state.clone(),
grasp_state.clone(),
dock_area.clone(),
@@ -156,10 +151,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
});
}
/// 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. The picked folder is remembered in the settings so it
/// becomes the default next time.
/// Pick the repository's storage folder with the platform's native folder picker.
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle();
let folder_input = folder_input.clone();
@@ -194,10 +186,14 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
.detach();
}
/// Run the create-repository flow; closes the dialog and opens the new repository on success.
/// Run the create-repository flow.
///
/// Opens the new working copy and the repository panel on success.
#[allow(clippy::too_many_arguments)]
fn create_repository(
name_input: Entity<InputState>,
desc_input: Entity<TextareaState>,
folder_input: Entity<InputState>,
state: Entity<CreateRepoState>,
grasp_state: Entity<GraspServersState>,
dock_area: WeakEntity<DockArea>,
@@ -206,48 +202,47 @@ fn create_repository(
) {
let name = name_input.read(cx).value().trim().to_owned();
let description = desc_input.read(cx).value().trim().to_owned();
let folder = PathBuf::from(folder_input.read(cx).value().trim());
let servers = grasp_state.read(cx).grasp_servers.clone();
if name.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Repository name is required".into());
});
state.update(cx, |state, _| state.fail("Repository name is required"));
return;
}
if servers.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Add at least one grasp server".into());
});
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
state.update(cx, |state, _| state.begin());
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| {
backend.create_repository(&name, &description, servers, cx)
backend.create_repository(&name, &description, folder, servers, cx)
});
let handle = window.window_handle();
let state = state.clone();
let dock_area = dock_area.clone();
cx.spawn(async move |cx| match task.await {
Ok(announcement) => {
Ok((announcement, local_path)) => {
cx.update_window(handle, |_, window, cx| {
window.close_dialog(cx);
// Record the new working copy as a checkout of this repository.
// The New PR panel then pre-fills it.
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| {
store.record(local_path.clone(), announcement.addr(), cx);
});
cx.open_with_system(&local_path);
open_repo(dock_area, announcement, window, cx);
})
.ok();
}
Err(e) => {
cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
state.busy = false;
state.error = Some(e.to_string().into());
});
state.update(cx, |state, _| state.fail(e.to_string()));
})
.ok();
}
@@ -6,27 +6,24 @@ use gpui_component::input::{Input, InputState};
use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
use nostr::prelude::*;
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
use signed_core::filters;
use signed_state::Backend;
/// State of the grasp-server section of a publish dialog, so async
/// results can be rendered.
/// 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.
/// The user's grasp list of kind `10317` is being loaded.
pub loading_servers: bool,
pub grasp_servers: Vec<RelayUrl>,
/// Whether the grasp server section is shown; defaults to shown.
/// 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).
/// Error of the last grasp-server edit, an invalid relay URL for example.
pub error: Option<SharedString>,
}
impl GraspServersState {
/// Defaults until the user's grasp list arrives; replaced by it when it lists any servers.
/// Defaults used until the user's grasp list loads, which replaces them when non-empty.
///
/// The servers come from the persisted settings, falling back to the
/// built-in defaults when the configured list is empty.
/// Persisted settings supply the defaults, an empty list falls back to the built-ins.
pub fn new_default(settings: &GraspServersSettings) -> Self {
let urls: Vec<String> = if settings.default_servers.is_empty() {
DEFAULT_GRASP_SERVERS
@@ -48,10 +45,7 @@ impl GraspServersState {
}
}
/// 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.
/// The Grasp servers form field shared by the publish dialogs.
pub fn grasp_servers_field(
state: &Entity<GraspServersState>,
relay_input: &Entity<InputState>,
@@ -143,7 +137,7 @@ pub fn grasp_servers_field(
}))
}
/// A grasp server row: the host as a tag plus a remove button.
/// One grasp server row, the host in a tag plus a remove button.
fn render_server_row(
ix: usize,
relay: &RelayUrl,
@@ -182,7 +176,7 @@ fn render_server_row(
)
}
/// The bare host of a grasp server (defaults are entered without a scheme).
/// The bare host of a grasp server, defaults are entered without a scheme.
fn display_server(relay: &RelayUrl) -> SharedString {
relay
.domain()
@@ -190,7 +184,7 @@ fn display_server(relay: &RelayUrl) -> SharedString {
.unwrap_or_else(|| SharedString::from(relay.to_string()))
}
/// Parse the relay input (accepting a bare host) and append it to the list.
/// Parse the relay input, accepting a bare host, and append it to the list.
fn add_relay(
state: &Entity<GraspServersState>,
input: &Entity<InputState>,
@@ -226,8 +220,9 @@ fn add_relay(
}
}
/// Load the user's grasp list (kind `10317`) from the local database and
/// replace the defaults with it when it lists any servers.
/// Load the user's grasp list of kind `10317` from the local database.
///
/// It replaces the defaults when it lists any servers.
pub fn load_user_grasp_servers(
state: Entity<GraspServersState>,
window: &mut Window,
@@ -242,30 +237,7 @@ pub fn load_user_grasp_servers(
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 result = signed_state::user_grasp_list_servers(client, user).await;
let _ = cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
@@ -2,8 +2,6 @@ use gpui::{App, Window, px};
use gpui_component::WindowExt;
/// Open the Import Identity dialog.
///
/// Currently a placeholder — the dialog only shows a title for now.
pub fn open(window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, _cx| {
dialog.title("Import identity").width(px(400.))
+230 -151
View File
@@ -1,22 +1,26 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle};
use dock::{
BasePanel, DockArea, Panel, PanelEvent, TAB_BAR_HEIGHT, add_center_panel, panel_handle,
};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list,
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white,
};
use gpui_base::Button as BaseButton;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use signed_core::{Announcement, identifier_from_name};
use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_core::{Announcement, RepoAddr, identifier_from_name};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
};
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use super::{RepoDetailView, RepoListView, open_repo_panel};
@@ -30,87 +34,176 @@ mod settings_dialog;
use self::onboarding_dialog::OnboardingState;
/// Left-dock panel with navigation entries. Entries open content panels in
/// the dock area.
pub struct SidebarPanel {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>,
logged_in: bool,
/// Repositories announced by the current user, listed under
/// "All Repositories". Recreated when the signer changes.
my_repos: Option<Entity<RepoListStore>>,
/// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option<Subscription>,
/// Banner artwork shown behind the sign-in screen,
/// picked at random from the bundled `backgrounds/` assets.
/// Artwork for the sign-in screen.
banner: SharedString,
/// Observes the local-repository scan so new discoveries re-render.
_local_repos_subscription: Subscription,
_subscription: Subscription,
/// The signed-in user's announced repositories, newest first.
announcements: Arc<Vec<Announcement>>,
/// Local repositories found by the scan that are not announced yet.
local_repos: Arc<Vec<PathBuf>>,
/// A local scan is currently running.
scanning: bool,
/// Unpushed local commits per announced repository, the row badge counts.
unpushed: HashMap<RepoAddr, usize>,
_subscriptions: Vec<Subscription>,
}
impl SidebarPanel {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let local_repos_store = LocalReposStore::global(cx);
let backend = Backend::global(cx);
let logged_in = backend.read(cx).current_user().is_some();
let repos = RepoListStore::global(cx);
let local = LocalReposStore::global(cx);
let checkouts = CheckoutsStore::global(cx);
let subscription = cx.subscribe(&backend, |this, backend, event, cx| {
match event {
BackendEvent::SignerChanged => {
this.logged_in = backend.read(cx).current_user().is_some();
this.refresh_my_repos(cx);
}
BackendEvent::SignerRequired => {
this.logged_in = false;
this.banner = pick_banner();
this.my_repos = None;
this.my_repos_subscription = None;
}
_ => return,
let mut subscriptions = Vec::new();
// Identity changes swap the whole sidebar between the sign-in screen and the signed-in content.
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
let signer_changed = matches!(event, BackendEvent::SignerChanged);
let signer_required = matches!(event, BackendEvent::SignerRequired);
if !signer_changed && !signer_required {
return;
}
cx.notify();
});
let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| {
cx.notify();
});
if signer_required {
this.banner = pick_banner();
}
let mut panel = Self {
if this.refresh(cx) || signer_required {
cx.notify();
}
}));
// The merged list re-derives when announcements or the local scan change.
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
if this.refresh(cx) {
cx.notify();
}
}));
// The local scan re-derives when announcements or the local scan change.
subscriptions.push(cx.observe(&local, |this, _local, cx| {
if this.refresh(cx) {
cx.notify();
}
}));
// Push statuses are recomputed in the background; only the badge counts change.
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
if this.refresh_unpushed(cx) {
cx.notify();
}
}));
let mut this = Self {
focus_handle: cx.focus_handle(),
dock_area,
logged_in,
explore: None,
my_repos: None,
my_repos_subscription: None,
banner: pick_banner(),
_local_repos_subscription: local_repos_subscription,
_subscription: subscription,
announcements: Arc::new(Vec::new()),
local_repos: Arc::new(Vec::new()),
scanning: false,
unpushed: HashMap::new(),
_subscriptions: subscriptions,
};
if logged_in {
panel.refresh_my_repos(cx);
}
// Seed the snapshot right away.
// The stores may already hold data from before the panel opened.
// The first render must not depend on a later store update.
this.refresh(cx);
panel
this
}
/// (Re)create the store listing the current user's repositories.
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None;
/// The sidebar renders only its own derived fields, never the stores
/// directly. Because the panel is a cached view, a store update alone does
/// not re-render it: the observers notify this panel, which re-runs
/// `render` over the fresh snapshot.
///
/// Returns `true` when a rendered field changed.
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
let backend = Backend::global(cx);
let author = backend.read(cx).current_user();
self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
let user = backend.read(cx).current_user();
if let Some(store) = self.my_repos.as_ref() {
self.my_repos_subscription = Some(cx.observe(store, |_, _, cx| cx.notify()));
let repo_list = RepoListStore::global(cx);
let announcements = user
.as_ref()
.map(|user| repo_list.read(cx).announcements_of(user))
.unwrap_or_default();
// A scanned repository is dropped from the local list
// once the user announces it, so it is not listed twice.
let local = LocalReposStore::global(cx);
let scanning = local.read(cx).scanning;
let local_repos = {
let ids: HashSet<String> = announcements.iter().map(|a| a.id.clone()).collect();
local
.read(cx)
.repos
.iter()
.filter(|path| {
let Some(name) = path.file_name() else {
return true;
};
!ids.contains(&identifier_from_name(&name.to_string_lossy()))
})
.cloned()
.collect()
};
let announcements_changed = *self.announcements != announcements;
let local_changed = *self.local_repos != local_repos;
let scanning_changed = self.scanning != scanning;
self.announcements = Arc::new(announcements);
self.local_repos = Arc::new(local_repos);
self.scanning = scanning;
if announcements_changed {
self.request_push_watches(cx);
self.unpushed.clear();
}
announcements_changed || local_changed || scanning_changed
}
/// Open the Explore (repository list) panel in the center of the dock
/// area. No-op if it's already open.
/// Recompute the badge counts from the global checkouts store's ready-to-push statuses
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
let checkouts = CheckoutsStore::global(cx).read(cx);
let mut unpushed = HashMap::with_capacity(self.announcements.len());
for announcement in self.announcements.iter() {
let addr = announcement.addr();
let count = checkouts.unpushed(&addr);
if count > 0 {
unpushed.insert(addr, count);
}
}
if unpushed == self.unpushed {
return false;
}
self.unpushed = unpushed;
true
}
/// Keep the `ready to push` statuses of the announced repositories current.
fn request_push_watches(&self, cx: &mut Context<Self>) {
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| {
for announcement in self.announcements.iter() {
checkouts.request_push_statuses(&announcement.addr(), cx);
}
});
}
/// Open the Explore repository list panel in the dock area's center.
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self
.explore
@@ -125,7 +218,7 @@ impl SidebarPanel {
self.explore = Some(panel.downgrade());
let _ = self.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
@@ -162,32 +255,24 @@ impl SidebarPanel {
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
}
/// Open a local repository's detail view in the dock's center; the
/// detail view offers to publish it to NIP-34.
/// Open a local repository's detail view in the dock's center.
///
/// The detail view offers to publish it to NIP-34.
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let detail =
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
let _ = self.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail),
DockPlacement::Center,
None,
window,
cx,
);
});
self.dock_area
.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(detail), window, cx);
})
.ok();
}
/// The "All Repositories" section: header with the create button and
/// the current user's repositories below it, lazily rendered through a
/// [`uniform_list`], followed by the local git repositories discovered
/// by the startup scan.
fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.my_repos.as_ref();
let local = LocalReposStore::global(cx);
let local_repos = local.read(cx).repos.clone();
let scanning = local.read(cx).scanning;
fn render_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
let announcements = self.announcements.clone();
let local_repos = self.local_repos.clone();
let scanning = self.scanning;
v_flex()
.px_2()
@@ -234,58 +319,36 @@ impl SidebarPanel {
),
),
)
.when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone();
// Local repositories that have already been published to
// NIP-34 are listed among the user's repositories above;
// hide them from the local section (matched by the
// identifier derived from the directory name, like the
// init dialog's default name).
let announced_ids: HashSet<String> =
announcements.iter().map(|a| a.id.clone()).collect();
let local_repos: Vec<PathBuf> = local_repos
.iter()
.filter(|path| {
let Some(name) = path.file_name() else {
return true;
};
!announced_ids.contains(&identifier_from_name(&name.to_string_lossy()))
})
.cloned()
.collect();
// One merged list: the user's NIP-34 repositories first,
// then the local repositories discovered by the scan.
.map(|this| {
// Merged list, the user's NIP-34 repositories and local repositories discovered.
let total = announcements.len() + local_repos.len();
if total == 0 {
builder.child(
this.child(
div()
.flex_1()
.px_2()
.py_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(if scanning {
"Scanning for local repositories…"
} else {
"No repositories yet"
.map(|this| {
if scanning {
this.child("Scanning for local repositories…")
} else {
this.child("No repositories yet")
}
}),
)
} else {
builder.child(
this.child(
uniform_list(
"repos",
total,
cx.processor(move |this, range: Range<usize>, _window, cx| {
cx.processor(move |this, range: Range<usize>, _, cx| {
range
.map(|ix| {
this.render_repo_row_at(
&announcements,
&local_repos,
ix,
cx,
)
.into_any_element()
this.render_repo_at(&announcements, &local_repos, ix, cx)
.into_any_element()
})
.collect()
}),
@@ -297,9 +360,8 @@ impl SidebarPanel {
})
}
/// One row of the merged sidebar list: a NIP-34 repository or a local
/// repository.
fn render_repo_row_at(
/// One row of the merged sidebar list, a NIP-34 or a local repository.
fn render_repo_at(
&self,
announcements: &[Announcement],
local_repos: &[PathBuf],
@@ -323,42 +385,60 @@ impl SidebarPanel {
announcement: &Announcement,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let name = announcement.name().map(SharedString::from);
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
let announcement = announcement.clone();
NavItem::new(format!("my-repo:{}", announcement.id), name, avatar).on_click(
// Badge with the unpushed commit count of the repository's local checkouts.
let unpushed = self
.unpushed
.get(&announcement.addr())
.copied()
.unwrap_or(0);
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
if unpushed > 0 {
row = row.suffix(
v_flex()
.flex_shrink_0()
.size_4()
.items_center()
.justify_center()
.rounded_full()
.line_height(relative(1.))
.bg(cx.theme().red_light)
.text_color(white())
.text_size(px(8.))
.child(SharedString::from(unpushed.to_string())),
);
}
row.on_click(
cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)),
)
}
/// One local repository row: a deterministic pixel avatar seeded from
/// the path, the directory name, and a warning suffix marking it as
/// not yet set up for NIP-34. Clicking it opens the repository's
/// detail view, which offers to initialize it.
/// One local repository row.
///
/// The directory name and a warning suffix, the repo is not yet set up for NIP-34.
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
.unwrap_or("Untitled".into());
let path = path.to_path_buf();
let avatar = PixelAvatar::new(path.to_string_lossy());
NavItem::new(
format!("local-repo:{}", path.display()),
name,
PixelAvatar::new(path.to_string_lossy()),
)
.suffix(
Icon::new(IconName::TriangleAlert)
.small()
.text_color(cx.theme().warning),
)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_local_repo(path.clone(), window, cx);
}))
NavItem::new(format!("local-repo:{}", path.display()), name, avatar)
.suffix(
Icon::new(IconName::TriangleAlert)
.small()
.text_color(cx.theme().warning),
)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_local_repo(path.clone(), window, cx);
}))
}
/// Show the Import Identity dialog.
@@ -366,7 +446,7 @@ impl SidebarPanel {
import_dialog::open(window, cx);
}
/// Render the user avatar and name in the sidebar, wrapped in the window titlebar drag area.
/// Render the user avatar and name in the sidebar, inside the titlebar drag area.
fn render_user(
&self,
profile: &Profile,
@@ -396,8 +476,7 @@ impl SidebarPanel {
)
}
/// Sign-in placeholder shown while logged out: banner artwork behind a
/// scrim so the CTA buttons stay readable in both themes.
/// Sign-in placeholder shown while logged out.
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
v_flex()
.size_full()
@@ -503,10 +582,6 @@ impl Focusable for SidebarPanel {
impl Render for SidebarPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.logged_in {
return self.render_sign_in(window, cx);
}
let backend = Backend::global(cx);
let profile_store = ProfileStore::global(cx);
@@ -515,10 +590,14 @@ impl Render for SidebarPanel {
.current_user()
.map(|public_key| profile_store.read(cx).get(&public_key));
if profile.is_none() {
return self.render_sign_in(window, cx);
}
v_flex()
.size_full()
.justify_between()
.image_cache(image_cache("sidebar", MAX_IMAGES))
.image_cache(gpui::retain_all("sidebar"))
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
@@ -561,7 +640,7 @@ impl Render for SidebarPanel {
)),
),
)
.child(self.render_my_repos(cx)),
.child(self.render_repos(cx)),
)
.child(
v_flex()
@@ -1,24 +1,18 @@
use gpui::prelude::*;
use gpui::{App, Entity, SharedString, Window, div, px};
use gpui::{App, Entity, Window, px};
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};
use gpui_component::{ActiveTheme, Disableable, WindowExt};
use gpui_component::{Disableable, WindowExt};
use signed_state::Backend;
use crate::views::dialog_state::{DialogProgress, error_row};
/// Shared state for the Onboarding dialog, so async results can be rendered.
#[derive(Default)]
pub struct OnboardingState {
pub busy: bool,
pub error: Option<SharedString>,
}
pub type OnboardingState = DialogProgress;
/// Open the Onboarding dialog for creating a new identity.
///
/// The caller is responsible for creating the input and state entities and
/// passing them in. This function only builds the dialog UI and wires up
/// the continue-button handler.
pub fn open(
name_input: Entity<InputState>,
pass_input: Entity<InputState>,
@@ -66,9 +60,7 @@ pub fn open(
)
.child(field().required(true).child(Input::new(&repass_input))),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
}))
.children(error_row(&error, cx))
.child(
DialogFooter::new().justify_end().child(
Button::new("continue")
@@ -91,17 +83,12 @@ pub fn open(
if pass != repass {
state.update(cx, |state, _| {
state.busy = false;
state.error =
Some("Passphrases do not match".into());
state.fail("Passphrases do not match");
});
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
state.update(cx, |state, _| state.begin());
let task = backend.update(cx, |backend, cx| {
backend.create_identity(&name, &pass, cx)
@@ -119,8 +106,7 @@ pub fn open(
Err(e) => {
cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
state.busy = false;
state.error = Some(e.to_string().into());
state.fail(e.to_string());
});
})
.ok();
@@ -1,26 +1,25 @@
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div};
use gpui::{AnyWindowHandle, App, Entity, Subscription, Window};
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, InputEvent, InputState};
use gpui_component::{ActiveTheme, Disableable, WindowExt};
use gpui_component::{Disableable, WindowExt};
use signed_state::Backend;
use crate::views::dialog_state::{DialogProgress, error_row};
/// Shared state for the passphrase dialog, so async results can be rendered.
#[derive(Default)]
pub struct PassphraseState {
pub busy: bool,
pub error: Option<SharedString>,
/// Progress of the unlock flow.
pub progress: DialogProgress,
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
_enter_subscription: Option<Subscription>,
}
/// Open the dialog asking for the passphrase that protects the stored
/// NIP-49 encrypted identity (`ncryptsec1...`).
///
/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`].
/// Open the dialog asking for the passphrase that protects the stored identity.
pub fn open(window: &mut Window, cx: &mut App) {
let pass_input = cx.new(|cx| {
InputState::new(window, cx)
@@ -53,8 +52,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
.overlay_closable(false)
.keyboard(false)
.content(move |content, _window, cx| {
let busy = state.read(cx).busy;
let error = state.read(cx).error.clone();
let busy = state.read(cx).progress.busy;
let error = state.read(cx).progress.error.clone();
content
.child(
@@ -73,9 +72,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
.child(Input::new(&pass_input)),
),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
}))
.children(error_row(&error, cx))
.child(
DialogFooter::new().justify_end().child(
Button::new("unlock")
@@ -98,8 +95,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
});
}
/// Submit the passphrase to the backend. On success the dialog is closed;
/// on failure the error is rendered inline and the dialog stays open.
/// Submit the passphrase to the backend.
fn unlock(
pass_input: &Entity<InputState>,
state: &Entity<PassphraseState>,
@@ -111,15 +107,12 @@ fn unlock(
if pass.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Passphrase must not be empty".into());
state.progress.fail("Passphrase must not be empty");
});
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
state.update(cx, |state, _| state.progress.begin());
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
let handle = *handle;
@@ -134,10 +127,7 @@ fn unlock(
}
Err(e) => {
cx.update_window(handle, |_this, _window, cx| {
state.update(cx, |state, _| {
state.busy = false;
state.error = Some(e.to_string().into());
});
state.update(cx, |state, _| state.progress.fail(e.to_string()));
})
.ok();
}
@@ -1,10 +1,3 @@
//! The Settings dialog, opened from the sidebar's Settings entry.
//!
//! A custom settings layout that divides related settings into sections
//! separated by simple horizontal lines — no `GroupBox` boxes and no settings
//! navigation sidebar. Every control edits the persisted [`SettingsStore`]
//! and applies the change to the live theme immediately.
use std::cell::Cell;
use std::path::PathBuf;
use std::rc::Rc;
@@ -55,8 +48,7 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
(light, dark)
}
/// Stateful controls of the settings dialog, created once when it opens so
/// their values survive re-renders of the dialog content.
/// Stateful controls of the settings dialog, created once when it opens.
struct SettingsControls {
appearance: Entity<SelectState<Vec<SelectOption>>>,
light_theme: Entity<SelectState<Vec<SelectOption>>>,
@@ -66,8 +58,7 @@ struct SettingsControls {
radius: Entity<InputState>,
radius_lg: Entity<InputState>,
grasp_server_input: Entity<InputState>,
/// The effective default create-repository folder, shown in the disabled
/// folder selector.
/// The effective default create-repository folder, shown in the disabled input.
default_folder: Entity<InputState>,
/// Keeps the control subscriptions alive for the dialog's lifetime.
_subscriptions: Vec<Subscription>,
@@ -276,28 +267,19 @@ impl SettingsControls {
/// Open the Settings dialog.
pub fn open(window: &mut Window, cx: &mut App) {
let controls = Rc::new(SettingsControls::new(window, cx));
let store = SettingsStore::global(cx);
let window_handle = window.window_handle();
let store_subscription = cx.observe(&store, move |_, cx| {
window_handle
.update(cx, |_, window, _| window.refresh())
.ok();
});
let dialog_state = Rc::new((controls, store_subscription));
window.open_dialog(cx, move |dialog, _window, cx| {
let dialog_state = dialog_state.clone();
let controls = controls.clone();
dialog
.title("Settings")
.width(px(650.))
.h(px(560.))
.child(settings_view(&dialog_state.0, cx))
.child(settings_view(&controls, cx))
});
}
/// The settings content: one section per related setting, divided by
/// horizontal separator lines.
/// The settings content, one section per related setting.
/// Sections are divided by horizontal separator lines.
fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement {
let store = SettingsStore::global(cx);
let settings = store.read(cx).settings().clone();
@@ -325,8 +307,7 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
))
}
/// Theme configuration: the theme names in the registry plus
/// the visual tweaks the application customizes at startup.
/// Theme configuration, the registry theme names plus tweaks the app customizes at startup.
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex()
.gap_3()
@@ -397,7 +378,7 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) ->
))
}
/// The default grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet.
/// Default grasp servers offered until the user publishes a kind `10317` grasp list.
fn grasp_servers_section(
settings: &Settings,
controls: &SettingsControls,
@@ -413,8 +394,8 @@ fn grasp_servers_section(
))
}
/// The editable list of default grasp servers plus an add-relay input,
/// styled like the grasp-server section of the publish dialogs.
/// The editable list of default grasp servers plus an add-relay input.
/// Styled like the grasp-server section of the publish dialogs.
fn grasp_server_editor(
servers: &[String],
controls: &SettingsControls,
@@ -478,8 +459,8 @@ fn grasp_server_editor(
)
}
/// The bare host of a grasp server (defaults are entered without a scheme),
/// matching how the publish dialogs display servers.
/// The bare host of a grasp server, defaults are entered without a scheme.
/// Matches how the publish dialogs display servers.
fn display_server(server: &str) -> SharedString {
RelayUrl::parse(server)
.ok()
@@ -513,8 +494,8 @@ fn repositories_section(
))
}
/// The editable list of scan directories plus an add-directory button,
/// styled like the grasp-server list.
/// The editable list of scan directories plus an add-directory button.
/// Styled like the grasp-server list.
fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
v_flex()
.w_full()
@@ -566,8 +547,8 @@ fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
)
}
/// The default-folder selector: a disabled input showing the effective
/// folder plus a picker button, matching the create-repository dialog.
/// The default-folder selector, a disabled input plus a picker button.
/// Matches the create-repository dialog.
fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
let default_folder = controls.default_folder.clone();
h_flex()
@@ -590,8 +571,8 @@ fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
)
}
/// Parse the server input (accepting a bare host) and append it to the
/// default grasp servers.
/// Parse the server input and append it to the default grasp servers.
/// A bare host is accepted.
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let value = input.read(cx).value().trim().to_owned();
if value.is_empty() {
@@ -660,8 +641,8 @@ fn add_scan_path(cx: &mut App) {
.detach();
}
/// Prompt for the folder the Create Repository dialog should default to,
/// remembering it in the settings and showing it in the disabled input.
/// Prompt for the Create Repository dialog's default folder.
/// Remember it in the settings and show it in the disabled input.
fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle();
let default_folder = default_folder.clone();
@@ -695,8 +676,9 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
.detach();
}
/// Wire a number input to the settings: steps clamp and persist, typed
/// changes parse, clamp and persist.
/// Wire a number input to the settings.
/// Step actions clamp and persist the value.
/// Typed changes parse, clamp and persist.
fn wire_number_input(
state: &Entity<InputState>,
subscriptions: &mut Vec<Subscription>,