//! The settings view, laid out like T3 Code's: a search field and the pages //! where the thread list was, the open page on the sheet under a "Settings / //! Page" breadcrumb. Every change is saved at once and handed to the open //! conversations. mod appearance; mod archive; mod connections; mod diagnostics; mod general; mod integrations; mod keybindings; mod licenses; mod providers; mod scope; mod search; mod source_control; mod storage; mod widgets; use std::{cell::Cell, collections::HashSet, rc::Rc, time::Duration}; use gpui_kit::component::{ Icon, IconName, Sizable as _, WindowExt as _, h_flex, input::{Input, InputEvent, InputState, TextareaState}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::CloseSettings; use crate::models::Catalog; use crate::settings::{self, Settings}; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton, MetalStyled as _}; use crate::ui::usage::HubSettings; use crate::ui::workspace::{SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace}; pub(crate) use scope::row_titles; pub(crate) use widgets::{card, radio, section, section_title}; use widgets::{EXPANDED_WIDTH, PAGE_PADDING, READABLE_WIDTH, Target, WIDE_WIDTH}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Page { General, Appearance, Keybindings, Providers, Integrations, SourceControl, Storage, Connections, Archive, /// Reached from General → Diagnostics. Diagnostics, /// Reached from General → Open source licenses. Licenses, } impl Page { /// The pages in the nav, in T3 Code's order. const NAV: [Page; 9] = [ Page::General, Page::Appearance, Page::Keybindings, Page::Providers, Page::Integrations, Page::SourceControl, Page::Storage, Page::Connections, Page::Archive, ]; pub fn label(self) -> &'static str { match self { Page::General => "General", Page::Appearance => "Appearance", Page::Keybindings => "Keybindings", Page::Providers => "Providers", Page::Integrations => "Integrations", Page::SourceControl => "Source Control", Page::Storage => "Storage", Page::Connections => "Connections", Page::Archive => "Archive", Page::Diagnostics => "Diagnostics", Page::Licenses => "Open source licenses", } } fn icon(self) -> Icon { match self { Page::General | Page::Diagnostics | Page::Licenses => Icon::new(Lucide::Settings2), Page::Appearance => Icon::new(IconName::Palette), Page::Keybindings => Icon::new(Lucide::Keyboard), Page::Providers => Icon::new(IconName::Bot), Page::Integrations => Icon::new(Lucide::Blocks), Page::SourceControl => Icon::new(Lucide::GitBranch), Page::Storage => Icon::new(Lucide::HardDrive), Page::Connections => Icon::new(Lucide::Link2), Page::Archive => Icon::new(Lucide::Archive), } } /// The nav item lit while this page is open. fn nav_page(self) -> Page { match self { Page::Diagnostics | Page::Licenses => Page::General, page => page, } } fn width(self) -> f32 { match self { Page::Providers | Page::Connections => WIDE_WIDTH, Page::Diagnostics => EXPANDED_WIDTH, _ => READABLE_WIDTH, } } } pub enum SettingsEvent { Close, } pub struct SettingsView { workspace: WeakEntity, page: Page, /// The nav's search field and the highlighted result. search: Entity, active_result: usize, scroll: ScrollHandle, jumps: usize, /// Where the running jump goes if its row isn't shown. jump_fallback: Option<&'static str>, /// Folded sections the user opened. open_folds: HashSet<&'static str>, add_project_dir: Entity, branch_prefix: Entity, branch_instructions: Entity, writing_instructions: Entity, worktrees_dir: Entity, keybinding_search: Entity, providers: providers::ProvidersState, hubs: Entity, connections: connections::ConnectionsState, source_control: source_control::SourceControlState, diagnostics: diagnostics::DiagnosticsState, licenses: licenses::LicensesState, /// The device change running: `hub`, `agent`, `check` or a tool update. device_pending: Option<&'static str>, device_error: Option, /// Which environments and project the environment settings edit. scope: scope::Scope, /// The text settings last put into their fields, for the selection. scoped_inputs: Option<[String; 4]>, focus_handle: FocusHandle, _subscriptions: Vec, } impl EventEmitter for SettingsView {} impl SettingsView { pub fn new(workspace: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { let input = |placeholder: &str, window: &mut Window, cx: &mut Context| { let placeholder = placeholder.to_string(); cx.new(|cx| InputState::new(window, cx).placeholder(placeholder)) }; let search = input("Search", window, cx); let add_project_dir = input("~/", window, cx); let branch_prefix = input("No prefix", window, cx); let worktrees_dir = input("Default", window, cx); let keybinding_search = input("Search keybindings", window, cx); let branch_instructions = cx.new(|cx| { TextareaState::new(window, cx).rows(4).placeholder("Use julius/ followed by the issue ID and a short description.") }); let writing_instructions = cx.new(|cx| { TextareaState::new(window, cx).rows(4).placeholder("Keep titles concise. Use short bullet points in descriptions.") }); let mut subscriptions = vec![ cx.subscribe_in(&search, window, |this, _, event: &InputEvent, window, cx| match event { InputEvent::Change => { this.active_result = 0; cx.notify(); } InputEvent::PressEnter { .. } => { if let Some(item) = this.results(cx).get(this.active_result).copied() { this.jump(item, window, cx); } } _ => {} }), cx.subscribe_in(&keybinding_search, window, |_, _, event: &InputEvent, _, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } }), commit_on_blur(&add_project_dir, window, cx, |settings| settings.add_project_dir.clone(), |settings, value| { settings.add_project_dir = value }), commit_on_blur(&branch_prefix, window, cx, |settings| settings.branch_prefix.clone(), |settings, value| { settings.branch_prefix = value }), commit_on_blur(&worktrees_dir, window, cx, |settings| settings.worktrees_dir.clone(), |settings, value| { settings.worktrees_dir = value }), cx.subscribe_in(&writing_instructions, window, |this, input, event: &InputEvent, window, cx| { if matches!(event, InputEvent::Blur) { let value = input.read(cx).value().trim().to_string(); if this.compute_shown(cx).settings.writing_instructions != value { this.apply(|settings| settings.writing_instructions = value.clone(), window, cx); } } }), cx.subscribe_in(&branch_instructions, window, |this, input, event: &InputEvent, window, cx| { if matches!(event, InputEvent::Blur) { let value = input.read(cx).value().trim().to_string(); if this.compute_shown(cx).settings.branch_instructions != value { this.apply(|settings| settings.branch_instructions = value.clone(), window, cx); } } }), ]; let connections = connections::ConnectionsState::new(window, cx, &mut subscriptions); let mut view = Self { workspace, page: Page::General, search, active_result: 0, scroll: ScrollHandle::new(), jumps: 0, jump_fallback: None, open_folds: HashSet::new(), add_project_dir, branch_prefix, branch_instructions, writing_instructions, worktrees_dir, keybinding_search, providers: providers::ProvidersState::default(), hubs: cx.new(|cx| HubSettings::new(window, cx)), connections, source_control: source_control::SourceControlState::default(), diagnostics: diagnostics::DiagnosticsState::default(), licenses: licenses::LicensesState::new(window, cx), device_pending: None, device_error: None, scope: scope::Scope::default(), scoped_inputs: None, focus_handle: cx.focus_handle(), _subscriptions: subscriptions, }; view.reload_inputs(window, cx); view } /// Readies the view for showing: the fields reflect the saved settings, /// and Esc closes it. pub fn opened(&mut self, window: &mut Window, cx: &mut Context) { self.reload_inputs(window, cx); self.focus_handle.focus(window, cx); cx.notify(); } /// Shows the page with this name (`Keybindings`), as the command /// palette asks. pub fn open_page_named(&mut self, label: &str, cx: &mut Context) { let pages = Page::NAV.into_iter().chain([Page::Diagnostics, Page::Licenses]); if let Some(page) = pages.into_iter().find(|page| page.label() == label) { self.open_page(page, cx); } } fn reload_inputs(&mut self, window: &mut Window, cx: &mut Context) { let saved = settings::load(); let set = |input: &Entity, value: String, window: &mut Window, cx: &mut Context| { input.update(cx, |input, cx| input.set_value(value, window, cx)); }; set(&self.add_project_dir, saved.add_project_dir.clone(), window, cx); self.scoped_inputs = None; self.sync_scoped_inputs(window, cx); self.connections.reload(&saved, window, cx); self.reload_provider_form(window, cx); } /// Repaints, and lets the open conversations pick up the change. fn changed(&mut self, window: &mut Window, cx: &mut Context) { let _ = self.workspace.update(cx, |workspace, cx| workspace.settings_changed(window, cx)); cx.notify(); } fn close(&mut self, cx: &mut Context) { cx.emit(SettingsEvent::Close); } fn open_page(&mut self, page: Page, cx: &mut Context) { self.page = page; if cx.has_global::() { cx.remove_global::(); } self.scroll.set_offset(point(px(0.), px(0.))); match page { Page::Diagnostics => self.refresh_diagnostics(cx), _ => {} } cx.notify(); } fn toggle_fold(&mut self, id: &'static str) { if !self.open_folds.remove(id) { self.open_folds.insert(id); } } // ---- search -------------------------------------------------------- fn query(&self, cx: &App) -> String { self.search.read(cx).value().trim().to_string() } fn results(&self, cx: &App) -> Vec { search::search(&self.query(cx)) } fn clear_search(&mut self, window: &mut Window, cx: &mut Context) { self.search.update(cx, |input, cx| input.set_value("", window, cx)); self.active_result = 0; } /// Opens a result's page, scrolls its row into view and pulses it. fn jump(&mut self, item: search::Item, window: &mut Window, cx: &mut Context) { self.clear_search(window, cx); self.open_page(item.page, cx); if let Some(fold) = item.fold { self.open_folds.insert(fold); } self.jumps += 1; cx.set_global(Target { id: item.id, generation: self.jumps, bounds: Rc::new(Cell::new(None)) }); self.focus_handle.focus(window, cx); self.jump_fallback = item.fallback; self.scroll_to_target(0, window, cx); // Let the pulse play out, then forget the target so it doesn't // pulse again when its page is opened later. let generation = self.jumps; cx.spawn(async move |this, cx| { cx.background_executor().timer(Duration::from_millis(1600)).await; let _ = this.update(cx, |this, cx| { if this.jumps == generation && cx.has_global::() { cx.remove_global::(); } }); }) .detach(); } /// Once the target has been painted, centers it in the page. fn scroll_to_target(&mut self, attempt: usize, window: &mut Window, cx: &mut Context) { let handle = self.scroll.clone(); let view = cx.weak_entity(); window.on_next_frame(move |window, cx| { let Some(bounds) = cx.try_global::().and_then(|target| target.bounds.get()) else { if attempt < 6 { let _ = view.update(cx, |view, cx| view.scroll_to_target(attempt + 1, window, cx)); window.refresh(); } else if let Some(fallback) = view.update(cx, |view, _| view.jump_fallback.take()).ok().flatten() && let Some(generation) = cx.try_global::().map(|target| target.generation) { // The row isn't shown; aim for the one that reveals it. cx.set_global(Target { id: fallback, generation, bounds: Rc::new(Cell::new(None)) }); let _ = view.update(cx, |view, cx| view.scroll_to_target(0, window, cx)); window.refresh(); } return; }; let viewport = handle.bounds(); let offset = handle.offset(); let delta = bounds.center().y - viewport.center().y; handle.set_offset(point(offset.x, (offset.y - delta).min(px(0.)))); window.refresh(); }); cx.notify(); } fn move_result(&mut self, by: isize, cx: &mut Context) { let count = self.results(cx).len() as isize; if count > 0 { self.active_result = (self.active_result as isize + by).rem_euclid(count) as usize; cx.notify(); } } // ---- restore defaults ---------------------------------------------- fn confirm_restore(&mut self, window: &mut Window, cx: &mut Context) { let changed = settings::get(Settings::differing_defaults); if changed.is_empty() { return; } let view = cx.weak_entity(); let list = changed.join(", "); window.open_dialog(cx, move |dialog, _, cx| { let p = metal::palette(cx); let view = view.clone(); dialog .title("Restore default settings?") .w(px(460.)) .child(div().text_size(px(13.)).line_height(px(19.)).text_color(p.ink2).child(format!("This will reset: {list}."))) .footer( h_flex() .w_full() .justify_end() .gap(px(8.)) .child( MetalButton::new("restore-cancel") .ghost() .compact() .label("Cancel") .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( MetalButton::new("restore-confirm") .danger() .compact() .label("Restore defaults") .on_click(move |_, window, cx| { window.close_dialog(cx); let _ = view.update(cx, |view, cx| view.restore_defaults(window, cx)); }), ), ) }); } fn restore_defaults(&mut self, window: &mut Window, cx: &mut Context) { settings::restore_defaults(); metal::sync_appearance(window, cx); Catalog::refresh_all(cx); self.reload_inputs(window, cx); self.changed(window, cx); } // ---- frame --------------------------------------------------------- fn render_nav(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let query = self.query(cx); let searching = !query.is_empty(); let results = self.results(cx); let settled = self .workspace .upgrade() .map(|workspace| workspace.read(cx).settled_threads(cx).len()) .unwrap_or(0); let updates = Catalog::global(cx).instances.iter().filter(|state| state.update_available()).count(); let search_row = h_flex() .id("settings-search") .h(px(32.)) .px(px(10.)) .gap(px(8.)) .rounded(px(12.)) .well(p) .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { if this.query(cx).is_empty() { return; } match event.keystroke.key.as_str() { "down" => this.move_result(1, cx), "up" => this.move_result(-1, cx), _ => return, } cx.stop_propagation(); })) .child(Icon::new(IconName::Search).size(px(15.)).text_color(p.ink3)) .child( div() .flex_1() .min_w_0() .text_size(px(13.5)) .text_color(p.ink) .child(Input::new(&self.search).appearance(false).small()), ) .child(if searching { MetalButton::new("clear-settings-search") .ghost() .mini() .icon(IconName::Close) .tooltip("Clear settings search") .on_click(cx.listener(|this, _, window, cx| { this.clear_search(window, cx); this.search.update(cx, |input, cx| input.focus(window, cx)); cx.notify(); })) .into_any_element() } else { metal::keycap("/", p).into_any_element() }); let items: Vec = if searching { if results.is_empty() { vec![ div() .py(px(24.)) .flex() .justify_center() .text_size(px(12.)) .text_color(p.ink3) .child("No settings found") .into_any_element(), ] } else { results .iter() .enumerate() .map(|(ix, item)| { let active = ix == self.active_result; let item = *item; h_flex() .id(("settings-result", ix)) .items_start() .min_h(px(40.)) .px(px(12.)) .py(px(6.)) .gap(px(10.)) .rounded(px(12.)) .cursor_pointer() .map(|this| if active { this.raised(p) } else { this.hover(|style| style.bg(p.row_hover)) }) .on_mouse_move(cx.listener(move |this, _, _, cx| { if this.active_result != ix { this.active_result = ix; cx.notify(); } })) .on_click(cx.listener(move |this, _, window, cx| this.jump(item, window, cx))) .child(div().pt(px(2.)).child(item.page.icon().size(px(14.)).text_color(p.ink3.opacity(0.8)))) .child( v_flex() .min_w_0() .child( div() .truncate() .text_size(px(13.5)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(item.title), ) .child(div().text_size(px(11.)).text_color(p.ink3).child(item.page.label())), ) .into_any_element() }) .collect() } } else { Page::NAV .into_iter() .map(|page| { let selected = page == self.page.nav_page(); let count = match page { Page::Archive if settled > 0 => Some(settled.to_string()), Page::Providers if updates > 0 => { Some(format!("{updates} update{}", if updates == 1 { "" } else { "s" })) } _ => None, }; h_flex() .id(SharedString::from(format!("settings-page-{}", page.label()))) .h(px(34.)) .px(px(12.)) .gap(px(10.)) .rounded(px(12.)) .cursor_pointer() .text_size(px(13.5)) .font_weight(FontWeight::MEDIUM) .map(|this| { if selected { this.raised(p).text_color(p.ink) } else { this.text_color(p.ink2).hover(|style| style.bg(p.row_hover).text_color(p.ink)) } }) .on_click(cx.listener(move |this, _, _, cx| this.open_page(page, cx))) .child(page.icon().size(px(15.)).text_color(if selected { p.ink } else { p.ink2 })) .child(div().flex_1().child(page.label())) .when_some(count, |this, count| { this.child(div().font_family(metal::MONO).text_size(px(10.5)).text_color(p.ink3).child(count)) }) .into_any_element() }) .collect() }; let _ = window; v_flex() .w(px(SIDEBAR_WIDTH)) .h_full() .flex_none() .child( h_flex() .flex_none() .h(px(52.)) .px(px(16.)) .gap(px(8.)) .child(Icon::default().data(metal::LOGO_SVG).size(px(18.)).text_color(p.ink)) .child(div().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum")) .child(metal::tag("Settings", p)), ) .child( v_flex() .id("settings-nav") .flex_1() .min_h_0() .overflow_y_scroll() .px(px(8.)) .pt(px(8.)) .gap(px(3.)) .child(div().pb(px(6.)).child(search_row)) .children(items), ) .child( h_flex() .flex_none() .h(px(48.)) .px(px(12.)) .gap(px(8.)) .child( MetalButton::new("settings-back") .ghost() .compact() .icon(IconName::ArrowLeft) .label("Back") .tooltip("Back to threads (Esc)") .on_click(cx.listener(|this, _, _, cx| this.close(cx))), ) .child(div().flex_1()) .child(metal::engraved(format!("v{}", env!("CARGO_PKG_VERSION")), p)), ) } fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let restorable = self.page == Page::General && !settings::get(Settings::differing_defaults).is_empty(); v_flex() .flex_none() .child( h_flex() .h(px(52.)) .pl(px(20.)) .pr(px(12.)) .gap(px(8.)) .child( div() .id("breadcrumb-settings") .text_size(px(14.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink3) .when(self.page != Page::General, |this| { this.cursor_pointer() .hover(|style| style.text_color(p.ink)) .on_click(cx.listener(|this, _, _, cx| this.open_page(Page::General, cx))) }) .child("Settings"), ) .child(div().text_size(px(14.)).text_color(p.ink3).child("/")) .child(div().text_size(px(14.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child(self.page.label())) .child(div().flex_1()) .when(self.page == Page::General, |this| { this.child( MetalButton::new("restore-defaults") .ghost() .compact() .icon(Lucide::RotateCcw) .label("Restore device defaults") .disabled(!restorable) .on_click(cx.listener(|this, _, window, cx| this.confirm_restore(window, cx))), ) }), ) .child(metal::rule(p)) } fn render_page(&mut self, window: &mut Window, cx: &mut Context) -> Vec { match self.page { Page::General => self.render_general(window, cx), Page::Appearance => self.render_appearance(cx), Page::Keybindings => self.render_keybindings(cx), Page::Providers => self.render_providers(window, cx), Page::Integrations => self.render_integrations(cx), Page::SourceControl => self.render_source_control(window, cx), Page::Storage => self.render_storage(window, cx), Page::Connections => self.render_connections(window, cx), Page::Archive => self.render_archive(cx), Page::Diagnostics => self.render_diagnostics(cx), Page::Licenses => self.render_licenses(cx), } } } /// Saves a text field when it loses focus or Enter is pressed, if it changed, /// to the selected environments when it's one of their settings. fn commit_on_blur( input: &Entity, window: &mut Window, cx: &mut Context, get: fn(&Settings) -> String, set: fn(&mut Settings, String), ) -> Subscription { cx.subscribe_in(input, window, move |this, input, event: &InputEvent, window, cx| { if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) { let value = input.read(cx).value().trim().to_string(); if get(&this.compute_shown(cx).settings) != value { this.apply(|settings| set(settings, value.clone()), window, cx); } } }) } impl Focusable for SettingsView { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for SettingsView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let shown = self.compute_shown(cx); cx.set_global(shown); self.sync_scoped_inputs(window, cx); let mut page = self.render_page(window, cx); if self.page_scoped() { page.insert(0, self.render_scope_sentence(cx)); } let width = self.page.width(); let wizard = self.render_add_provider(window, cx); h_flex() .key_context("SettingsView") .track_focus(&self.focus_handle) .size_full() .relative() // Esc clears a search first, then goes back to the threads. .on_action(cx.listener(|this, _: &CloseSettings, window, cx| { if this.query(cx).is_empty() { this.close(cx); } else { this.clear_search(window, cx); cx.notify(); } })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { let keystroke = &event.keystroke; let plain = !keystroke.modifiers.control && !keystroke.modifiers.alt && !keystroke.modifiers.platform; if keystroke.key == "/" && plain && this.focus_handle.is_focused(window) { this.search.update(cx, |input, cx| { input.focus(window, cx); input.select_all(window, cx); }); cx.stop_propagation(); } })) .child(self.render_nav(window, cx)) .child( div().flex_1().min_w_0().h_full().py(px(SHEET_INSET)).pr(px(SHEET_INSET)).child( v_flex() .size_full() .rounded(px(SHEET_RADIUS)) .plate(p) .child(self.render_top_bar(cx)) .child( div() .id(("settings-scroll", self.page as usize)) .flex_1() .min_h_0() .overflow_y_scroll() .track_scroll(&self.scroll) .child( h_flex().w_full().justify_center().px(px(PAGE_PADDING)).pt(px(24.)).pb(px(48.)).child( v_flex() .w_full() .max_w(px(width)) .gap(px(if self.page == Page::Diagnostics { 40. } else { 32. })) .children(page), ), ), ), ), ) .children(wizard) } }