From e86f0fdbb12ed0647941f5f21af31a63fd46155e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 12 Nov 2026 09:03:20 +0700 Subject: [PATCH] add settings dialog --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/signed_app/src/app/helpers.rs | 7 +- crates/signed_app/src/lib.rs | 3 +- crates/signed_app/src/repo/dialogs/init.rs | 6 +- crates/signed_app/src/settings/helpers.rs | 369 +++++++++++++++++++++ crates/signed_app/src/settings/mod.rs | 112 +++++++ crates/signed_app/src/sidebar/mod.rs | 30 ++ 8 files changed, 532 insertions(+), 13 deletions(-) create mode 100644 crates/signed_app/src/settings/helpers.rs create mode 100644 crates/signed_app/src/settings/mod.rs diff --git a/Cargo.lock b/Cargo.lock index be22fa7..aef8f7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3059,7 +3059,7 @@ dependencies = [ [[package]] name = "gpui-native-core" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558" dependencies = [ "cpal", "flume 0.12.0", @@ -3074,7 +3074,7 @@ dependencies = [ [[package]] name = "gpui-native-data" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558" dependencies = [ "gpui", "gpui-native-core", @@ -3086,7 +3086,7 @@ dependencies = [ [[package]] name = "gpui-native-editor" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558" dependencies = [ "gpui", "gpui-native-core", @@ -3105,7 +3105,7 @@ dependencies = [ [[package]] name = "gpui-native-icons" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558" dependencies = [ "anyhow", "gpui", @@ -3114,7 +3114,7 @@ dependencies = [ [[package]] name = "gpui-native-kit" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558" dependencies = [ "gpui", "gpui-native-core", @@ -3128,7 +3128,7 @@ dependencies = [ [[package]] name = "gpui-native-layout" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558" dependencies = [ "gpui", "gpui-native-core", @@ -3141,7 +3141,7 @@ dependencies = [ [[package]] name = "gpui-native-theme" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558" dependencies = [ "anyhow", "async-channel", @@ -3162,7 +3162,7 @@ dependencies = [ [[package]] name = "gpui-native-utils" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558" dependencies = [ "gpui", "gpui-native-theme", diff --git a/Cargo.toml b/Cargo.toml index cd40028..6c638ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503de gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc", features = ["font-kit"] } reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" } -gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "ccd8997801186a33d4ed299d5bc9446c0f015781", features = ["layout", "editor"] } +gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "9678a7676f4c70f91f04b6ab0ad68148659c2558", features = ["layout", "editor"] } settings = { path = "crates/settings" } signed_app = { path = "crates/signed_app" } diff --git a/crates/signed_app/src/app/helpers.rs b/crates/signed_app/src/app/helpers.rs index b9f3ec8..ee2d1e4 100644 --- a/crates/signed_app/src/app/helpers.rs +++ b/crates/signed_app/src/app/helpers.rs @@ -4,7 +4,12 @@ use settings::{AppearanceMode, Settings}; /// Applies the persisted light/dark/system preference to the kit theme. pub fn apply_appearance(settings: &Settings, cx: &mut App) { - match settings.appearance { + apply_appearance_mode(settings.appearance, cx); +} + +/// Applies one appearance mode to the kit theme. +pub fn apply_appearance_mode(mode: AppearanceMode, cx: &mut App) { + match mode { AppearanceMode::System => follow_system(cx), AppearanceMode::Light => set_mode_override(Some(ThemeVariant::Light), cx), AppearanceMode::Dark => set_mode_override(Some(ThemeVariant::Dark), cx), diff --git a/crates/signed_app/src/lib.rs b/crates/signed_app/src/lib.rs index bc56ca0..c567644 100644 --- a/crates/signed_app/src/lib.rs +++ b/crates/signed_app/src/lib.rs @@ -5,11 +5,12 @@ mod inbox; mod issues; mod pull_requests; mod repo; +mod settings; mod shared; mod sidebar; pub use app::AppRoot; -pub use app::helpers::apply_appearance; +pub use app::helpers::{apply_appearance, apply_appearance_mode}; use gpui::{App, AppContext as _, Entity, Window}; diff --git a/crates/signed_app/src/repo/dialogs/init.rs b/crates/signed_app/src/repo/dialogs/init.rs index 6332241..7cddb58 100644 --- a/crates/signed_app/src/repo/dialogs/init.rs +++ b/crates/signed_app/src/repo/dialogs/init.rs @@ -55,8 +55,10 @@ impl InitRepoDialog { let state = cx.new(|_| DialogProgress::default()); let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings)); - let name_input = InputState::builder().text(default_name).build(cx); - name_input.update(cx, |state, cx| state.set_read_only(true, cx)); + let name_input = InputState::builder() + .text(default_name) + .read_only() + .build(cx); let desc_input = InputState::builder().multiline().build(cx); let relay_input = InputState::new(cx); diff --git a/crates/signed_app/src/settings/helpers.rs b/crates/signed_app/src/settings/helpers.rs new file mode 100644 index 0000000..bcedb41 --- /dev/null +++ b/crates/signed_app/src/settings/helpers.rs @@ -0,0 +1,369 @@ +use std::path::PathBuf; + +use gpui::{ + AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement as _, PathPromptOptions, + SharedString, Styled as _, WeakEntity, Window, div, px, +}; +use gpui_native_kit::button::Button; +use gpui_native_kit::common::{h_flex, v_flex}; +use gpui_native_kit::icons::{Icon, IconRole}; +use gpui_native_kit::input::{Input, InputState}; +use gpui_native_kit::select::{Select, SelectOption}; +use gpui_native_kit::theme::ActiveTheme as _; +use gpui_native_kit::tooltip::Tooltip; +use settings::{AppearanceMode, EventFetchingStrategy, SettingsStore}; + +use super::SettingsDialog; +use crate::repo::dialogs::grasp_servers::{normalize_server, server_host}; + +/// A settings row with the label and description on the left and the control on the right. +pub fn setting_row( + cx: &App, + title: impl Into, + description: impl Into, + control: impl IntoElement, +) -> AnyElement { + h_flex() + .w_full() + .justify_between() + .gap_4() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(title.into()), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .child(description.into()), + ), + ) + .child(div().w(px(200.)).flex_shrink_0().child(control)) + .into_any_element() +} + +/// A full-width settings block with title and subtitle in one header. +pub fn setting_block( + cx: &App, + title: impl Into, + description: impl Into, + control: impl IntoElement, +) -> AnyElement { + v_flex() + .w_full() + .gap_3() + .child( + v_flex() + .gap_1() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(title.into()), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .child(description.into()), + ), + ) + .child(control) + .into_any_element() +} + +pub fn appearance_select(cx: &App) -> Select { + let current = SettingsStore::global(cx).read(cx).settings().appearance; + Select::new("settings-appearance") + .options(vec![ + SelectOption::new(AppearanceMode::System, "System"), + SelectOption::new(AppearanceMode::Light, "Light"), + SelectOption::new(AppearanceMode::Dark, "Dark"), + ]) + .selected(Some(current)) + .on_change(|value, _, cx| { + let Some(mode) = value else { + return; + }; + SettingsStore::global(cx).update(cx, |store, cx| { + store.edit(|settings| settings.appearance = mode, cx); + }); + crate::apply_appearance_mode(mode, cx); + }) +} + +pub fn event_fetching_select(cx: &App) -> Select { + let current = SettingsStore::global(cx).read(cx).settings().event_fetching; + Select::new("settings-event-fetching") + .options(vec![ + SelectOption::new(EventFetchingStrategy::Curated, "Curated"), + SelectOption::new(EventFetchingStrategy::Uncensored, "Uncensored"), + ]) + .selected(Some(current)) + .on_change(|value, _, cx| { + let Some(strategy) = value else { + return; + }; + SettingsStore::global(cx).update(cx, |store, cx| { + store.edit(|settings| settings.event_fetching = strategy, cx); + }); + }) +} + +pub fn grasp_servers_editor(input: &Entity, cx: &App) -> AnyElement { + let servers = SettingsStore::global(cx) + .read(cx) + .settings() + .grasp_servers + .default_servers + .clone(); + + v_flex() + .gap_2() + .children(servers.iter().enumerate().map(|(ix, server)| { + let store = SettingsStore::global(cx); + h_flex() + .gap_1() + .child( + div() + .flex_1() + .h_8() + .px_2() + .bg(cx.theme().colors.muted) + .text_color(cx.theme().colors.muted_foreground) + .text_sm() + .rounded(cx.theme().radius.control) + .child(display_server(server)), + ) + .child( + Button::new(format!("settings-remove-server:{ix}")) + .icon(Icon::named(IconRole::Close)) + .variant(gpui_native_kit::button::ButtonVariant::Ghost) + .tooltip(Tooltip::text("Remove")) + .on_click({ + let store = store.clone(); + move |_, _, cx| { + store.update(cx, |store, cx| { + store.edit( + |settings| { + settings.grasp_servers.default_servers.remove(ix); + }, + cx, + ); + }); + } + }), + ) + .into_any_element() + })) + .child( + h_flex() + .gap_1() + .child( + div() + .flex_1() + .child(Input::new("settings-grasp-input", input.clone())), + ) + .child( + Button::new("settings-add-server") + .icon(Icon::named(IconRole::Plus)) + .variant(gpui_native_kit::button::ButtonVariant::Ghost) + .tooltip(Tooltip::text("Add grasp server")) + .on_click({ + let input = input.clone(); + move |_, window, cx| add_server(&input, window, cx) + }), + ), + ) + .into_any_element() +} + +fn display_server(server: &str) -> SharedString { + match normalize_server(server) { + Some((_, relay)) => server_host(&relay), + None => SharedString::from(server.to_owned()), + } +} + +fn add_server(input: &Entity, window: &mut Window, cx: &mut App) { + let value = input.read(cx).value().trim().to_owned(); + if value.is_empty() { + return; + } + let Some((normalized, _)) = normalize_server(&value) else { + return; + }; + SettingsStore::global(cx).update(cx, |store, cx| { + store.edit( + |settings| { + if !settings.grasp_servers.default_servers.contains(&normalized) { + settings + .grasp_servers + .default_servers + .push(normalized.clone()); + } + }, + cx, + ); + }); + input.update(cx, |input, cx| input.set_value("", window, cx)); +} + +pub fn scan_paths_editor(cx: &App) -> AnyElement { + let scan_paths = SettingsStore::global(cx) + .read(cx) + .settings() + .local_repos + .scan_paths + .clone(); + let store = SettingsStore::global(cx); + + v_flex() + .gap_2() + .children(scan_paths.iter().enumerate().map(|(ix, path)| { + let store = store.clone(); + h_flex() + .gap_1() + .child( + div() + .flex_1() + .h_8() + .px_2() + .bg(cx.theme().colors.muted) + .text_color(cx.theme().colors.muted_foreground) + .text_sm() + .rounded(cx.theme().radius.control) + .child(path.display().to_string()), + ) + .child( + Button::new(format!("settings-remove-path:{ix}")) + .icon(Icon::named(IconRole::Close)) + .variant(gpui_native_kit::button::ButtonVariant::Ghost) + .tooltip(Tooltip::text("Remove")) + .on_click(move |_, _, cx| { + store.update(cx, |store, cx| { + store.edit( + |settings| { + settings.local_repos.scan_paths.remove(ix); + }, + cx, + ); + }); + }), + ) + .into_any_element() + })) + .child( + h_flex().justify_end().child( + Button::new("settings-add-path") + .icon(Icon::named(IconRole::Plus)) + .label("Add directory") + .on_click(|_, _, cx| add_scan_path(cx)), + ), + ) + .into_any_element() +} + +fn add_scan_path(cx: &mut App) { + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: true, + prompt: Some("Choose directories".into()), + }); + cx.spawn(async move |cx| { + if let Ok(Ok(Some(paths))) = prompt.await { + cx.update(|cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit( + |settings| { + for path in paths { + let path = path.to_string_lossy().to_string(); + if !settings + .local_repos + .scan_paths + .iter() + .any(|existing| existing.to_string_lossy() == path) + { + settings.local_repos.scan_paths.push(PathBuf::from(path)); + } + } + }, + cx, + ); + }); + }); + } + }) + .detach(); +} + +pub fn default_folder_selector( + default_folder: &Entity, + weak: WeakEntity, +) -> AnyElement { + let folder = default_folder.clone(); + h_flex() + .gap_1() + .child( + div() + .flex_1() + .child(Input::new("settings-default-folder", folder.clone())), + ) + .child( + Button::new("settings-choose-folder") + .icon(Icon::named(IconRole::Folder)) + .variant(gpui_native_kit::button::ButtonVariant::Ghost) + .tooltip(Tooltip::text("Choose folder")) + .on_click(move |_, window, cx| { + choose_default_folder(&folder, &weak, window, cx); + }), + ) + .into_any_element() +} + +fn choose_default_folder( + default_folder: &Entity, + weak: &WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let handle = window.window_handle(); + let default_folder = default_folder.clone(); + let weak = weak.clone(); + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Choose default folder".into()), + }); + cx.spawn(async move |cx| { + if let Ok(Ok(Some(mut paths))) = prompt.await + && let Some(path) = paths.pop() + { + let text = path.to_string_lossy().to_string(); + if let Err(error) = cx.update_window(handle, |_, window, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit( + |settings| settings.create_repository.default_folder = Some(path.clone()), + cx, + ); + }); + default_folder.update(cx, |input, cx| input.set_value(text, window, cx)); + let _ = weak.update(cx, |_this, cx| cx.notify()); + }) { + log::warn!("failed to apply default folder: {error}"); + } + } + }) + .detach(); +} diff --git a/crates/signed_app/src/settings/mod.rs b/crates/signed_app/src/settings/mod.rs new file mode 100644 index 0000000..47b5ba4 --- /dev/null +++ b/crates/signed_app/src/settings/mod.rs @@ -0,0 +1,112 @@ +mod helpers; + +use gpui::{ + App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, + Window, px, +}; +use gpui_native_kit::common::v_flex; +use gpui_native_kit::dialog::Dialog; +use gpui_native_kit::input::InputState; +use gpui_native_kit::scroll_area::ScrollArea; +use gpui_native_kit::separator::Separator; +use helpers::{ + appearance_select, default_folder_selector, event_fetching_select, grasp_servers_editor, + scan_paths_editor, setting_block, setting_row, +}; +use settings::SettingsStore; + +use crate::shared::dialogs::open_global; + +pub struct SettingsDialog { + grasp_input: Entity, + default_folder: Entity, +} + +impl SettingsDialog { + pub fn new(cx: &mut App) -> Entity { + let store = SettingsStore::global(cx); + let settings = store.read(cx).settings().clone(); + let grasp_input = InputState::new(cx); + + let folder = settings + .create_repository + .default_folder + .clone() + .unwrap_or_else(paths::desktop_dir); + + let default_folder = InputState::builder() + .text(folder.to_string_lossy()) + .read_only() + .build(cx); + + cx.new(|_| Self { + grasp_input, + default_folder, + }) + } +} + +impl Render for SettingsDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let grasp_input = self.grasp_input.clone(); + let default_folder = self.default_folder.clone(); + let weak = cx.entity().downgrade(); + + Dialog::new("settings-dialog") + .open(true) + .min_width(px(650.)) + .on_change(|open, _, cx| { + if !open { + crate::shared::dialogs::close_global(cx); + } + }) + .title("Settings") + .content(move |_, cx| { + ScrollArea::new("settings-scroll") + .max_h(px(560.)) + .child( + v_flex() + .gap_4() + .child(setting_row( + cx, + "Appearance", + "Choose whether the app follows the system theme or uses a light/dark theme.", + appearance_select(cx), + )) + .child(Separator::horizontal()) + .child(setting_row( + cx, + "Event Fetching Strategy", + "Curated fetches events from relays in the repository's announcement. Uncensored also fetches from every maintainer's relays.", + event_fetching_select(cx), + )) + .child(Separator::horizontal()) + .child(setting_block( + cx, + "Grasp Servers", + "Servers used to host your git repositories via the Grasp protocol", + grasp_servers_editor(&grasp_input, cx), + )) + .child(Separator::horizontal()) + .child(setting_block( + cx, + "Scan Directories", + "Directories scanned for local git repositories.", + scan_paths_editor(cx), + )) + .child(setting_block( + cx, + "Default Folder", + "The folder for newly created repositories.", + default_folder_selector(&default_folder, weak.clone()), + )), + ) + .into_any_element() + }) + } +} + +/// Opens the settings dialog. +pub fn open(cx: &mut App) { + open_global(SettingsDialog::new(cx), cx); +} diff --git a/crates/signed_app/src/sidebar/mod.rs b/crates/signed_app/src/sidebar/mod.rs index d0aab00..17918f4 100644 --- a/crates/signed_app/src/sidebar/mod.rs +++ b/crates/signed_app/src/sidebar/mod.rs @@ -30,6 +30,7 @@ use crate::explore::ExploreView; use crate::identity; use crate::inbox::InboxView; use crate::repo::dialogs; +use crate::settings; mod helpers; @@ -226,6 +227,7 @@ impl SidebarView { Sidebar::new("signed-sidebar") .transparent(true) .header(self.render_user(&profile, self.scanning, cx)) + .footer(self.render_footer(cx)) .entries(move |_, cx| { let muted = cx.theme().colors.muted_foreground; let mut entries = vec![ @@ -439,6 +441,34 @@ impl SidebarView { ); } + fn render_footer(&self, _cx: &App) -> impl IntoElement { + let guide_weak = self.weak.clone(); + let settings_click = |_: &gpui::ClickEvent, _: &mut Window, cx: &mut gpui::App| { + settings::open(cx); + }; + + v_flex() + .p_2() + .gap_1() + .w_full() + .child( + Button::new("guide") + .label("Guide") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| { + let _ = guide_weak.update(cx, |this, cx| { + this.open_explore(window, cx); + }); + }), + ) + .child( + Button::new("settings") + .label("Settings") + .variant(ButtonVariant::Ghost) + .on_click(settings_click), + ) + } + fn open_inbox(&mut self, window: &mut Window, cx: &mut Context) { let dock = self.dock.clone(); let dock_for_build = dock.clone();