Vectrum
Sign In
791 lines · 31.8 KB · Rust
//! 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![
            // About's update status.
            cx.observe_global::<crate::update::Updater>(|_, cx| cx.notify()),
            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)
    }
}