//! 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<Workspace>,
page: Page,
/// The nav's search field and the highlighted result.
search: Entity<InputState>,
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<InputState>,
branch_prefix: Entity<InputState>,
branch_instructions: Entity<TextareaState>,
writing_instructions: Entity<TextareaState>,
worktrees_dir: Entity<InputState>,
keybinding_search: Entity<InputState>,
providers: providers::ProvidersState,
hubs: Entity<HubSettings>,
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<SharedString>,
/// 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<Subscription>,
}
impl EventEmitter<SettingsEvent> for SettingsView {}
impl SettingsView {
pub fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = |placeholder: &str, window: &mut Window, cx: &mut Context<Self>| {
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>) {
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<Self>) {
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<Self>) {
let saved = settings::load();
let set = |input: &Entity<InputState>, value: String, window: &mut Window, cx: &mut Context<Self>| {
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<Self>) {
let _ = self.workspace.update(cx, |workspace, cx| workspace.settings_changed(window, cx));
cx.notify();
}
fn close(&mut self, cx: &mut Context<Self>) {
cx.emit(SettingsEvent::Close);
}
fn open_page(&mut self, page: Page, cx: &mut Context<Self>) {
self.page = page;
if cx.has_global::<Target>() {
cx.remove_global::<Target>();
}
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::Item> {
search::search(&self.query(cx))
}
fn clear_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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>) {
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::<Target>() {
cx.remove_global::<Target>();
}
});
})
.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<Self>) {
let handle = self.scroll.clone();
let view = cx.weak_entity();
window.on_next_frame(move |window, cx| {
let Some(bounds) = cx.try_global::<Target>().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::<Target>().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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) -> 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<AnyElement> = 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<Self>) -> 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<Self>) -> Vec<AnyElement> {
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<InputState>,
window: &mut Window,
cx: &mut Context<SettingsView>,
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<Self>) -> 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)
}
}