Files
tty7/src/ui/settings.rs
T
l0ng-aiandClaude Opus 5 3fd523e1b9 feat(diff): make the sidebar diff preview optional and bound its cost
Clicking a sidebar row's `+N −N` opens the working-tree diff overlay. On a big
tree that could stall the window, and not everyone wants an in-app diff viewer
in the first place.

Two halves, matching the report.

The setting: `sidebar_diff_preview` (Settings → Window & Tabs, on by default,
persisted in `config.json`). Off, the branch and the counts stay exactly where
they are and read exactly the same; they lose only the pointer cursor and the
`toggle_diff_overlay` handler, so the press falls through to ordinary tab
activation. Both come off one value — `diff_click_cwd` — so they cannot get out
of step.

The performance work. All five of the reporter's hypotheses held up against
v26.7.6, and each fix is measured on a 300-file / 90 000-line / 4.5 MB diff
(release, macOS arm64):

1. The full diff was buffered before parsing — `git_status::git` uses
   `Command::output()`. Now streamed line by line through the new
   `git_status::git_lines` into an incremental `DiffParser`: peak transient
   buffer 4 552 060 bytes → 50 bytes, at ~1.7× the parse CPU (3.97 ms →
   6.76 ms) on the background thread, where it never touches a frame.

2. The snapshot was deep-cloned per holder inside `this.update`, i.e. on the
   UI thread. Now shared behind `Arc`: 2.41 ms → 11 ns per holder.

3. The element tree is not virtualized — confirmed, not cured. Rendering is not
   being redesigned here; instead the element count is bounded (see 4) and
   `MAX_RENDERED_FILES` caps the cards built at all, with a "… and N more" line
   for the tail.

4. Auto-collapse was per file, and counted only +/− while the rendered body
   also has context lines. Added `AUTO_COLLAPSE_TOTAL_LINES` over *retained*
   lines: sixty forty-line files, none individually large, went from 2400
   side-by-side rows to zero, under a summary saying the diff is too large to
   render efficiently and pointing at expanding individual files or `git diff`.

5. The Changes panel probed independently and kept its own snapshot. Both now
   go through `spawn_shared_diff_probe`, which dedupes by cwd and installs one
   `Arc` into every watcher; opening the overlay while the panel already shows
   that repo now paints from the panel's snapshot instead of re-probing.

Plus a repo-wide retention budget (`MAX_TOTAL_LINES`, `MAX_FILES_WITH_HUNKS`):
90 000 lines / 6.2 MiB of line text → 20 000 / 1.2 MiB. The `+N −N` totals
deliberately escape every cap — they are compared against `--numstat` to detect
staleness, so a capped total would disagree forever and re-probe in a loop.

Small diffs are untouched: a forty-file, twelve-lines-each tree is not
oversized and still opens expanded, asserted directly.

Not verified: anything requiring the GUI. No frame timings, no visual check of
the oversized banner or the settings row, and `AUTO_COLLAPSE_TOTAL_LINES` is a
judgement call anchored on row count rather than a measured frame budget.

Refs #239.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 16:10:46 +08:00

5816 lines
241 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Settings tab UI (Cmd+,): a sidebar of sections beside a scrollable
//! content pane. This module owns the panel's *state types* and its *rendering*
//! only; the lifecycle (opening/closing the tab, committing the font family,
//! applying theme/font changes) lives in `app.rs`, where it can touch the
//! shell's tabs and panes. The render methods extend `Tty7App` from here so the
//! window shell stays focused on tab/pane orchestration.
use gpui::{
AnyElement, App, Context, Div, Entity, FontWeight, Image, ImageFormat, KeyDownEvent,
MouseButton, SharedString, Stateful, Subscription, Window, div, img, prelude::*, px, relative,
rgb,
};
use gpui_component::InteractiveElementExt as _;
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::color_picker::{ColorPicker, ColorPickerState};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::link::Link;
use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_component::select::{SearchableVec, Select, SelectState};
use gpui_component::sidebar::{Sidebar, SidebarCollapsible, SidebarMenu, SidebarMenuItem};
use gpui_component::slider::{Slider, SliderState};
use gpui_component::{
ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex,
v_flex,
};
use std::sync::Arc;
use uuid::Uuid;
use crate::core::config::{
BellMode, Config, CursorStyle, NewTabPosition, NotifyMode, TabBarPosition,
};
use crate::core::keychain::CredentialRef;
use crate::core::ssh_profile::{
Algorithms, AuthMode, ForwardKind, ForwardRule, HostPort, SshProfile, to_connect_string,
};
use crate::ui::app::{
FONT_SIZE_STEP, LINE_HEIGHT_STEP, TILE_GLYPH_LINE, TILE_SIZE, TITLE_BAR_HEIGHT, ThemeEdit,
Tty7App,
};
use crate::ui::host_ops::HostId;
use crate::ui::presets;
use crate::ui::rounding;
use crate::ui::rounding::RoundedCorners as _;
/// Which section of the settings panel is currently selected in the sidebar.
/// Sections are named for the *object* being configured (the appearance, the
/// terminal, the window) — never for a property class like "Behavior", which
/// reads fine but predicts nothing about what's inside.
///
/// Two of these were rearranged because the old split didn't survive contact
/// with a user asking "which page is that on?":
///
/// * **Shell** used to be its own page holding three settings, and nothing
/// distinguished "the Terminal page" from "the Shell page" from the outside.
/// Its rows are now Terminal's first group — the program a pane launches is a
/// property of the terminal, not a peer of it. (It also freed the word
/// "Shell", which the menu bar was simultaneously using for its File menu.)
/// * **Input** is new. Completion, history search, the Option/Meta split and
/// selection/clipboard behaviour were scattered through the bottom of the
/// Terminal page under four headers; they're the app's most distinctive
/// surface and they now have a name you can look for.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SettingsSection {
Appearance,
Terminal,
Input,
Ssh,
Agents,
WindowTabs,
Keybindings,
About,
}
impl SettingsSection {
/// Every section, in nav order. The single source of truth for "what
/// sections exist" — [`best_matching_section`] used to carry its own
/// hand-written copy of this list and had silently fallen two behind.
pub(crate) const ALL: [SettingsSection; 8] = [
SettingsSection::Appearance,
SettingsSection::Terminal,
SettingsSection::Input,
SettingsSection::Ssh,
SettingsSection::Agents,
SettingsSection::WindowTabs,
SettingsSection::Keybindings,
SettingsSection::About,
];
/// A `&'static` label for `TTY7_PROFILE` aggregation, so each section's build
/// cost and rebuild rate report under their own line.
fn profile_label(self) -> &'static str {
match self {
SettingsSection::Appearance => "settings:appearance",
SettingsSection::Terminal => "settings:terminal",
SettingsSection::Input => "settings:input",
SettingsSection::Ssh => "settings:ssh",
SettingsSection::Agents => "settings:agents",
SettingsSection::WindowTabs => "settings:window-tabs",
SettingsSection::Keybindings => "settings:keybindings",
SettingsSection::About => "settings:about",
}
}
}
/// One searchable setting for the settings-search box: the row's display title,
/// the section it lives in, and a bag of extra keywords/synonyms so a search
/// lands even when the user's word isn't in the visible label. Matching is
/// case-insensitive substring over `title` + `keywords`.
struct SearchEntry {
section: SettingsSection,
title: &'static str,
keywords: &'static str,
}
/// The static index the settings search matches against — one entry per notable
/// setting, mirroring the rows each `render_settings_*` builds. Keywords carry
/// synonyms the visible label omits (e.g. "meta" → the Option/Alt row, "color"
/// → the theme) so intent-based searches still resolve to the right section.
fn settings_search_entries() -> &'static [SearchEntry] {
use SettingsSection::*;
&[
// ── Appearance ──────────────────────────────────────────────────────
SearchEntry {
section: Appearance,
title: "Theme",
keywords: "appearance color colours scheme dark light palette background foreground accent sync system os auto follow",
},
SearchEntry {
section: Appearance,
title: "Sync with system",
keywords: "theme dark light auto follow os appearance mode",
},
SearchEntry {
section: Appearance,
title: "Custom themes",
keywords: "theme duplicate edit colors folder yaml import",
},
SearchEntry {
section: Appearance,
title: "Opacity",
keywords: "transparency translucent see through window alpha",
},
SearchEntry {
section: Appearance,
title: "Blur",
keywords: "transparency translucent frosted vibrancy window background",
},
SearchEntry {
section: Appearance,
title: "Dim inactive panes",
keywords: "fade unfocused inactive split pane focus opacity highlight active dimming",
},
SearchEntry {
section: Appearance,
title: "Font size",
keywords: "typography text bigger smaller zoom",
},
SearchEntry {
section: Appearance,
title: "Line height",
keywords: "typography leading spacing",
},
SearchEntry {
section: Appearance,
title: "Font family",
keywords: "typeface monospace typography",
},
SearchEntry {
section: Appearance,
title: "Bold font",
keywords: "typeface weight",
},
SearchEntry {
section: Appearance,
title: "Italic font",
keywords: "typeface oblique",
},
SearchEntry {
section: Appearance,
title: "Font ligatures",
keywords: "typography glyph fira",
},
SearchEntry {
section: Appearance,
title: "Cursor shape",
keywords: "caret block bar underline beam",
},
SearchEntry {
section: Appearance,
title: "Cursor blink",
keywords: "caret blinking flash",
},
SearchEntry {
section: Appearance,
title: "ANSI colors",
keywords: "palette 16 terminal colours theme",
},
// ── Terminal ────────────────────────────────────────────────────────
SearchEntry {
section: Terminal,
title: "Program",
keywords: "shell binary zsh bash fish pwsh powershell executable launch",
},
SearchEntry {
section: Terminal,
title: "Arguments",
keywords: "shell flags login args",
},
SearchEntry {
section: Terminal,
title: "Start in",
keywords: "cwd working directory start folder path home inherit custom",
},
SearchEntry {
section: Terminal,
title: "Scrollback",
keywords: "history buffer lines scroll",
},
SearchEntry {
section: Terminal,
title: "Scroll speed",
keywords: "mouse wheel multiplier scrolling",
},
SearchEntry {
section: Terminal,
title: "Focus follows mouse",
keywords: "pane hover activate",
},
SearchEntry {
section: Terminal,
title: "Hide mouse while typing",
keywords: "cursor pointer autohide",
},
SearchEntry {
section: Terminal,
title: "Report mouse to apps",
keywords: "mouse reporting vim tmux click scroll shift passthrough",
},
SearchEntry {
section: Terminal,
title: "Terminal bell",
keywords: "bell audible visual flash sound silence beep ^g",
},
SearchEntry {
section: Terminal,
title: "Detect URLs",
keywords: "links hyperlink clickable open",
},
SearchEntry {
section: Terminal,
title: "Forward SSH loopback links",
keywords: "ssh remote port tunnel localhost forward links",
},
SearchEntry {
section: Terminal,
title: "Open files with",
keywords: "links file editor command external app path line column",
},
// ── Input ───────────────────────────────────────────────────────────
SearchEntry {
section: Input,
title: "Tab completion",
keywords: "complete completion menu suggestions tab prompt",
},
SearchEntry {
section: Input,
title: "History search",
keywords: "ctrl-r reverse search fuzzy history recall fzf prompt",
},
SearchEntry {
section: Input,
title: "Option acts as Meta",
keywords: "alt keyboard modifier escape macos option meta",
},
SearchEntry {
section: Input,
title: "Smart selection",
keywords: "double click word url path select semantic bracket email",
},
SearchEntry {
section: Input,
title: "Copy on select",
keywords: "clipboard selection yank mouse",
},
SearchEntry {
section: Input,
title: "Trim trailing spaces on copy",
keywords: "clipboard whitespace copy",
},
// ── SSH ─────────────────────────────────────────────────────────────
SearchEntry {
section: Ssh,
title: "Hosts",
keywords: "ssh host connection saved profile import ssh_config manage add edit \
quick connect",
},
SearchEntry {
section: Ssh,
title: "Verify host keys",
keywords: "ssh security known_hosts fingerprint mitm host key verification",
},
SearchEntry {
section: Ssh,
title: "Warn before closing",
keywords: "ssh confirm close tab pane live session security",
},
SearchEntry {
section: Ssh,
title: "Port forwarding",
keywords: "ssh tunnel local remote dynamic socks forward rule",
},
// ── Agents ──────────────────────────────────────────────────────────
SearchEntry {
section: Agents,
title: "Claude Code hooks",
keywords: "agent integration install uninstall status rich session working waiting tab bar sidebar badge claude",
},
SearchEntry {
section: Agents,
title: "Codex hooks",
keywords: "agent integration install openai codex",
},
SearchEntry {
section: Agents,
title: "Copilot CLI hooks",
keywords: "agent integration install github copilot",
},
SearchEntry {
section: Agents,
title: "OpenCode plugin",
keywords: "agent integration install opencode",
},
SearchEntry {
section: Agents,
title: "Pi extension",
keywords: "agent integration install pi",
},
// ── Window & Tabs ───────────────────────────────────────────────────
SearchEntry {
section: WindowTabs,
title: "Startup window",
keywords: "launch open maximized fullscreen normal",
},
SearchEntry {
section: WindowTabs,
title: "Remember window size & position",
keywords: "window size position bounds geometry launch startup remember",
},
SearchEntry {
section: WindowTabs,
title: "Restore last layout",
keywords: "restore session previous tabs splits reopen launch startup layout",
},
SearchEntry {
section: WindowTabs,
title: "Confirm before closing the last window",
// Both spellings of the chord: the prompt this turns off is reached
// by ⌘W on macOS and Ctrl-W everywhere else, and the user types
// whichever one their own keyboard just used.
keywords: "close quit confirm prompt dialog ask again warn last window cmd-w ctrl-w",
},
SearchEntry {
section: WindowTabs,
title: "Show tray icon",
keywords: "tray menu bar status item agent attention system icon",
},
SearchEntry {
section: WindowTabs,
title: "New tab position",
keywords: "tabs order end after current",
},
SearchEntry {
section: WindowTabs,
title: "Tab bar position",
keywords: "tabs vertical sidebar left top layout rail",
},
SearchEntry {
section: WindowTabs,
title: "Sidebar grouping",
keywords: "tabs group repo repository git scratch header sidebar flat",
},
SearchEntry {
section: WindowTabs,
title: "Open diff preview from sidebar counts",
keywords: "diff overlay preview sidebar counts git changes click branch lines",
},
SearchEntry {
section: WindowTabs,
title: "Notify on command finish",
keywords: "notification alert done osc desktop banner long command",
},
SearchEntry {
section: WindowTabs,
title: "Notify threshold",
keywords: "notification alert seconds duration long command delay",
},
// ── Keybindings / About ─────────────────────────────────────────────
SearchEntry {
section: Keybindings,
title: "Keybindings",
keywords: "shortcut hotkey keyboard binding chord tmux preset rebind prefix",
},
SearchEntry {
section: About,
title: "About",
keywords: "version license credits build update check github",
},
SearchEntry {
section: About,
title: "How sessions work",
keywords: "session daemon detach persist background close quit stop delete workspace layout survive reboot tmux",
},
]
}
/// Does this entry match the (already lowered, trimmed) query? Matches on the
/// visible title or any of its synonym keywords, so intent-based searches land.
fn entry_matches(entry: &SearchEntry, query: &str) -> bool {
entry.title.to_lowercase().contains(query) || entry.keywords.contains(query)
}
/// How many of `section`'s settings match `query` — the `(N)` shown beside each
/// section link while a search is active. `query` must already be lowered/trimmed.
pub(crate) fn section_match_count(section: SettingsSection, query: &str) -> usize {
settings_search_entries()
.iter()
.filter(|e| e.section == section && entry_matches(e, query))
.count()
}
/// The section a search should jump to: the one with the most matches, ties
/// broken by nav order (the first section wins). `None` when nothing matches, so
/// the caller leaves the current selection alone.
///
/// Driven by [`SettingsSection::ALL`] rather than a hand-written list: the old
/// literal here omitted SSH and Agents, so searching "claude" or "known hosts"
/// annotated the nav with a match count and then refused to go there.
pub(crate) fn best_matching_section(query: &str) -> Option<SettingsSection> {
SettingsSection::ALL
.into_iter()
.map(|s| (s, section_match_count(s, query)))
.filter(|(_, n)| *n > 0)
// `>` (not `>=`) so an equal later section never displaces the earlier one.
.reduce(|best, cur| if cur.1 > best.1 { cur } else { best })
.map(|(s, _)| s)
}
/// The in-app color editor for the active *editable* theme: one color picker per
/// seed color (background/foreground/accent/cursor/selection) and per ANSI slot,
/// each wired to write its change straight back to the theme's YAML file. Rebuilt
/// by `Tty7App::rebuild_theme_editor` whenever the active theme changes, so it
/// always targets (and reflects) the theme on screen.
pub(crate) struct ThemeEditor {
/// The id the pickers were built for (which theme they edit).
#[allow(dead_code)]
pub(crate) for_id: String,
/// Seed-color pickers: `(edit target, row label, picker state)`.
pub(crate) seed: Vec<(ThemeEdit, String, Entity<ColorPickerState>)>,
/// One picker per ANSI slot 015.
pub(crate) ansi: Vec<(ThemeEdit, String, Entity<ColorPickerState>)>,
/// Background-image opacity slider; present only while the theme has an
/// image (wired to `Tty7App::set_theme_image_opacity`).
pub(crate) image_opacity_slider: Option<Entity<SliderState>>,
pub(crate) _subs: Vec<Subscription>,
}
/// Live state for the settings panel (Cmd+,). Holds the panel's focus owner
/// (so Esc closes it), the currently selected sidebar section, and the
/// font-family text input plus its commit subscriptions.
pub(crate) struct SettingsState {
pub(crate) focus_handle: gpui::FocusHandle,
pub(crate) section: SettingsSection,
/// Live query for the settings search box in the nav header. While non-empty
/// the nav rail lists matching settings (across every section) instead of the
/// six section links; picking one jumps to its section.
pub(crate) search: Entity<InputState>,
pub(crate) font_select: Entity<SelectState<SearchableVec<String>>>,
/// Bold / italic face pickers. Their first row is the `FONT_DEFAULT_LABEL`
/// sentinel, meaning "reuse the primary face with synthesized emphasis".
pub(crate) font_bold_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) font_italic_select: Entity<SelectState<SearchableVec<String>>>,
/// Shell program override (empty = the platform default shell).
pub(crate) shell_program_input: Entity<InputState>,
/// Shell launch arguments, space-separated (e.g. `-l`).
pub(crate) shell_args_input: Entity<InputState>,
/// Custom working-directory path (used when the strategy is `Custom`).
pub(crate) wd_path_input: Entity<InputState>,
/// Command template run when ⌘/Ctrl-clicking a file link (Links section). Empty
/// clears the override, restoring the built-in "open in default app".
pub(crate) link_file_command_input: Entity<InputState>,
/// Mouse-scroll multiplier slider (Terminal section).
pub(crate) scroll_slider: Entity<SliderState>,
/// Global window-opacity slider (Appearance's Window section). Shows the
/// effective value; dragging sets the config override.
pub(crate) window_opacity_slider: Entity<SliderState>,
/// The color editor for the effective (on-screen) theme, or `None` when
/// that theme is read-only (a built-in / import).
pub(crate) theme_editor: Option<ThemeEditor>,
/// Whether the theme picker panel is open beside the content pane
/// (Appearance section only). Toggled from the theme card(s).
pub(crate) theme_panel_open: bool,
/// Which theme choice the open picker panel writes to (see [`ThemeSlot`]).
/// Set by the card that opened the panel.
pub(crate) theme_panel_slot: ThemeSlot,
/// Live filter for the theme picker panel's list.
pub(crate) theme_search: Entity<InputState>,
/// `Some` while a Keybindings row is capturing a new shortcut: the action
/// being rebound plus the live keystroke interceptor that swallows and
/// records the next keypress (see `Tty7App::start_recording_key`).
pub(crate) recording: Option<Recording>,
/// A transient one-line note under the Keybindings header — e.g. after a
/// captured key was already taken and its previous owner was unbound.
/// Cleared when the next capture starts.
pub(crate) rebinding_note: Option<String>,
/// The SSH-profile edit form, when a profile in the SSH section is being
/// added or edited. `None` shows just the saved-profile list. Its widgets
/// (inputs) are built lazily when a profile is selected and rebuilt (a fresh
/// input set) each time, so the section never carries N profiles' worth of
/// inputs up front. See `SshProfileForm`.
pub(crate) ssh_form: Option<SshProfileForm>,
/// Which detail the SSH section's right (detail) pane is showing. The section
/// is a two-column master-detail: the left column lists profiles, and this
/// tracks the selected one. `Profile(id)` pairs with `ssh_form` (the loaded
/// edit form); `None` shows the empty state (the "pick a profile" hint plus
/// the two global security toggles).
pub(crate) ssh_detail: SshDetail,
/// Live filter for the SSH master list. Non-empty narrows the list to hosts
/// whose name or address matches, and force-expands every group — results
/// hiding inside a collapsed group is the same as not finding them.
pub(crate) ssh_filter: Entity<InputState>,
/// Group keys (see [`ssh_group_key`]) whose section in the master list is
/// collapsed. Empty = everything expanded.
pub(crate) ssh_collapsed_groups: std::collections::HashSet<String>,
/// The `user@host[:port]` box in the SSH section's empty state. An empty
/// pane whose only content is "select something" wastes the widest column on
/// the page; connecting is what someone opening this section came to do.
pub(crate) ssh_quick_connect: Entity<InputState>,
/// Which machine the Agents section is showing and acting on.
/// [`HostId::LOCAL`] until the user picks one of the connected remotes.
pub(crate) agent_hooks_host: HostId,
/// Install state of each agent's hook integration on
/// [`Self::agent_hooks_host`]. Cached — captured when the panel opens,
/// re-read when the section or the machine is selected, and updated after
/// each install/uninstall — because reading it is a file read per agent,
/// and on a remote machine that is a round trip per agent.
pub(crate) agent_hooks_states: AgentHooksView,
/// Discriminates the load whose answer is allowed to land. Switching
/// machines while a read is in flight would otherwise let the old
/// machine's rows arrive under the new machine's name.
pub(crate) agent_hooks_seq: u64,
/// Outcome of the last Agents-section hook action (install summary or
/// error), shown under that agent's row. Replaced by the next action.
pub(crate) agent_hooks_note: Option<(crate::core::agent_hooks::HookAgent, String)>,
pub(crate) _subs: Vec<Subscription>,
}
/// What Settings → Agents has to show for the machine it is pointed at.
///
/// Three states rather than a `Vec` that is empty when it doesn't know: reading
/// a remote machine's install state is a round trip per agent, so "still asking"
/// and "asked, nothing installed" are genuinely different answers and rendering
/// them the same is how a page silently lies for a second.
#[derive(Clone)]
pub(crate) enum AgentHooksView {
/// The read is in flight.
Loading,
/// One row per hook-capable agent, in
/// [`crate::core::agent_hooks::HookAgent::ALL`] order.
Ready(Vec<AgentHookRow>),
/// The machine can't be acted on, and the sentence says which hop gave up
/// (design §17: a failure is a resting state, not a blank).
Unavailable(String),
}
/// One agent's row, as read off a particular machine.
#[derive(Clone)]
pub(crate) struct AgentHookRow {
pub(crate) agent: crate::core::agent_hooks::HookAgent,
pub(crate) state: crate::core::agent_hooks::HooksState,
/// The file the integration lives in *on that machine*, `~`-abbreviated.
/// Resolved in the background with the rest of the read — it depends on the
/// machine's own home directory and separator, which render cannot ask for.
pub(crate) target: String,
}
/// One entry in the Agents section's machine picker.
#[derive(Clone)]
pub(crate) struct AgentHooksMachine {
pub(crate) host: HostId,
pub(crate) label: String,
}
/// The theme choice a picker card / the picker panel targets. `Manual` is the
/// single `Config::theme_preset` (sync-with-system off); `Light` / `Dark` are
/// the two follow-system slots (`Config::theme_preset_light` / `_dark`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ThemeSlot {
Manual,
Light,
Dark,
}
/// The SSH section's right-pane selection (see [`SettingsState::ssh_detail`]).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SshDetail {
/// Nothing selected — the right pane shows the quick-connect empty state.
None,
/// The settings every host inherits. Its own row at the top of the master
/// list rather than a block pinned under the form: "each host starts from
/// these and can override one" is a thing the list's shape can say, and a
/// paragraph under an unrelated form cannot.
Defaults,
/// A profile's edit form (paired with `ssh_form`, keyed by the profile id).
Profile(Uuid),
}
/// The master list's bucket for `profile.group`: imported aliases first, then
/// any user-defined group, then the ungrouped ones. The key is the raw `group`
/// value (`""` for ungrouped) so it can key the collapsed-set directly.
fn ssh_group_key(p: &SshProfile) -> &str {
p.group.as_deref().unwrap_or("")
}
/// The header text for a group key. The import bucket is labelled by the file
/// it mirrors — `Imported from ssh_config` describes a past action, and this
/// group is a live link to a file the user edits elsewhere.
fn ssh_group_label(key: &str) -> &str {
match key {
crate::core::ssh_config::IMPORTED_GROUP => "~/.ssh/config",
"" => "In tty7",
other => other,
}
}
/// Sort rank for a group key: imported first, ungrouped last, custom groups in
/// between (alphabetical among themselves).
fn ssh_group_rank(key: &str) -> u8 {
match key {
crate::core::ssh_config::IMPORTED_GROUP => 0,
"" => 2,
_ => 1,
}
}
/// Whether a profile survives the master list's filter. `query` is already
/// trimmed and lowercased. Matches the name and the address separately rather
/// than the rendered `user@host:port` line, so typing a port still finds the
/// host but typing `@` doesn't match everything.
fn ssh_row_matches(p: &SshProfile, query: &str) -> bool {
if query.is_empty() {
return true;
}
let hit = |s: &str| s.to_lowercase().contains(query);
hit(&p.name) || hit(&p.host) || hit(&p.user) || hit(&p.port.to_string())
}
/// The live edit-form state for one SSH profile, folded into Settings → SSH.
/// A single reusable input set, rebuilt (via `Tty7App::ssh_form_load`) each time
/// a profile is selected. Edits are committed to `Config::ssh_profiles` only on
/// Save, so the form can be abandoned freely. Mirrors the four-core-fields +
/// collapsible jump / forwards / advanced disclosure the old standalone editor
/// exposed.
pub(crate) struct SshProfileForm {
/// The profile id being edited. A *new* (unsaved) profile carries a freshly
/// minted id here and is only written to config on Save.
editing: Uuid,
/// The group / credential_ref carried over from the profile being edited, so
/// a Save round-trips fields the form doesn't expose.
carry_group: Option<String>,
carry_credential_ref: Option<CredentialRef>,
// Section expansion (progressive disclosure).
show_jump: bool,
show_forwards: bool,
show_advanced: bool,
// Core fields.
name: Entity<InputState>,
host: Entity<InputState>,
port: Entity<InputState>,
user: Entity<InputState>,
auth: AuthMode,
// Jump host (a profile name; empty = none).
jump: Entity<InputState>,
// Port forwards, one row of inputs per rule (see [`ForwardRuleForm`]).
forwards: Vec<ForwardRuleForm>,
// Advanced text inputs.
identity_files: Entity<InputState>,
proxy_command: Entity<InputState>,
socks: Entity<InputState>,
http: Entity<InputState>,
kex: Entity<InputState>,
cipher: Entity<InputState>,
mac: Entity<InputState>,
hostkey: Entity<InputState>,
compression: Entity<InputState>,
keepalive_interval: Entity<InputState>,
keepalive_count: Entity<InputState>,
connect_timeout: Entity<InputState>,
login_scripts: Entity<InputState>,
// Advanced booleans / tri-states.
agent_forward: bool,
x11: bool,
skip_banner: bool,
shell_integration: bool,
verify_host_keys: Option<bool>,
warn_on_close: Option<bool>,
/// Keeps the inputs' change subscriptions alive for this form; dropped (and
/// re-created) whenever the form is rebuilt for another profile.
_subs: Vec<Subscription>,
}
/// One port-forward rule as live inputs.
///
/// The whole set used to be a single multi-line text box in which each rule had
/// to be typed as `L bind_host:port target_host:port [description]` — a syntax
/// nothing on the page taught, and the only field in this form that could
/// silently drop what you entered (an unparsable line was skipped on save).
pub(crate) struct ForwardRuleForm {
pub(crate) kind: ForwardKind,
pub(crate) bind_host: Entity<InputState>,
pub(crate) bind_port: Entity<InputState>,
pub(crate) target_host: Entity<InputState>,
pub(crate) target_port: Entity<InputState>,
pub(crate) description: Entity<InputState>,
}
impl ForwardRuleForm {
/// Read the row back into a rule, or `None` when it is too incomplete to
/// connect: a listener needs a port, and everything but Dynamic needs a
/// target. The UI flags such a row rather than dropping it quietly.
fn collect(&self, cx: &App) -> Option<ForwardRule> {
let val = |e: &Entity<InputState>| e.read(cx).value().trim().to_string();
let bind_port: u16 = val(&self.bind_port).parse().ok().filter(|p| *p > 0)?;
let bind = HostPort::new(val(&self.bind_host), bind_port);
let target = if self.kind == ForwardKind::Dynamic {
HostPort::default()
} else {
let port: u16 = val(&self.target_port).parse().ok().filter(|p| *p > 0)?;
let host = val(&self.target_host);
if host.is_empty() {
return None;
}
HostPort::new(host, port)
};
Some(ForwardRule {
kind: self.kind,
bind,
target,
description: val(&self.description),
})
}
/// Whether the row has anything typed in it at all. An untouched row added
/// by "Add rule" is not an error — it just hasn't been filled in yet.
fn is_blank(&self, cx: &App) -> bool {
[
&self.bind_host,
&self.bind_port,
&self.target_host,
&self.target_port,
&self.description,
]
.iter()
.all(|e| e.read(cx).value().trim().is_empty())
}
}
/// In-progress capture of a new shortcut for one action (click a Keybindings
/// row). The interceptor lives here so it stays active only while recording;
/// dropping it (capture done / Esc) removes the key swallow.
pub(crate) struct Recording {
/// The action name whose shortcut is being captured.
pub(crate) action: String,
/// The chords captured so far, each a config spec (e.g. `["ctrl-b", "x"]`).
/// A single chord is the common case; more than one records a sequence like
/// the tmux preset's `ctrl-b x`. Committed (joined by spaces) after a short
/// pause with no further keys.
pub(crate) chords: Vec<String>,
/// Keeps the keystroke interceptor alive for the duration of the capture.
pub(crate) _intercept: Subscription,
}
/// Sentinel first row in the bold/italic font pickers meaning "no distinct face
/// — reuse the primary family with synthesized emphasis". Chosen to be an
/// unlikely real font name.
pub(crate) const FONT_DEFAULT_LABEL: &str = "Default (match primary)";
/// How the link-click modifier is spelled in the Links copy. It's gpui's
/// `secondary` (see `Modifiers::secondary`), so it must read ⌘ on macOS and
/// Ctrl on Windows/Linux — same split `key_tokens` uses for keycaps.
#[cfg(target_os = "macos")]
const LINK_MODIFIER_LABEL: &str = "⌘";
#[cfg(not(target_os = "macos"))]
const LINK_MODIFIER_LABEL: &str = "Ctrl";
/// Humanize a CamelCase action name for display: "CloseActiveTab" → "Close
/// Active Tab".
pub(crate) fn humanize_action(action: &str) -> String {
let mut out = String::new();
for (i, ch) in action.chars().enumerate() {
if i > 0 && ch.is_uppercase() {
out.push(' ');
}
out.push(ch);
}
out
}
// ── SSH-profile form parsing helpers (moved here from the standalone editor) ──
/// Parse a `host:port` fragment into a [`HostPort`], or `None` when empty/blank.
fn parse_host_port(s: &str) -> Option<HostPort> {
let s = s.trim();
if s.is_empty() {
return None;
}
match s.rsplit_once(':') {
Some((h, p)) => Some(HostPort::new(h.trim(), p.trim().parse().unwrap_or(0))),
None => Some(HostPort::new(s, 0)),
}
}
/// Render a `HostPort` back to `host:port` for the form (empty string for `None`).
fn host_port_text(hp: &Option<HostPort>) -> String {
hp.as_ref()
.map(|h| format!("{}:{}", h.host, h.port))
.unwrap_or_default()
}
/// Split a comma/whitespace list into non-empty items (algorithms, etc.).
fn split_list(s: &str) -> Vec<String> {
s.split([',', ' ', '\n'])
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
/// Split a multiline input into non-empty trimmed lines.
fn split_lines(s: &str) -> Vec<String> {
s.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
/// The five inputs of a forward row, in tab order. Used to subscribe the row
/// (and nothing else needs to know the field names).
fn forward_row_inputs(row: &ForwardRuleForm) -> [&Entity<InputState>; 5] {
[
&row.bind_host,
&row.bind_port,
&row.target_host,
&row.target_port,
&row.description,
]
}
/// Build one forward row's inputs, seeded from `rule`. Port `0` seeds an empty
/// box rather than a literal `0` — the stored default for "unset".
fn seed_forward_row(
window: &mut Window,
cx: &mut Context<Tty7App>,
rule: &ForwardRule,
) -> ForwardRuleForm {
let port = |p: u16| if p == 0 { String::new() } else { p.to_string() };
ForwardRuleForm {
kind: rule.kind,
bind_host: seed_hinted(window, cx, &rule.bind.host, "localhost"),
bind_port: seed_hinted(window, cx, &port(rule.bind.port), "8080"),
target_host: seed_hinted(window, cx, &rule.target.host, "127.0.0.1"),
target_port: seed_hinted(window, cx, &port(rule.target.port), "80"),
description: seed_hinted(window, cx, &rule.description, "what it's for"),
}
}
/// [`seed_input`] with a placeholder — the forward rows carry no labels of
/// their own, so the hint text is what says which box is which.
fn seed_hinted(
window: &mut Window,
cx: &mut Context<Tty7App>,
value: &str,
placeholder: &'static str,
) -> Entity<InputState> {
let value = value.to_string();
cx.new(|cx| {
InputState::new(window, cx)
.placeholder(placeholder)
.default_value(value)
})
}
/// Build an `InputState` seeded with `value` (single- or multi-line). A free
/// function so `window` auto-reborrows cleanly at each call site.
fn seed_input(
window: &mut Window,
cx: &mut Context<Tty7App>,
value: &str,
multi_line: bool,
) -> Entity<InputState> {
let value = value.to_string();
cx.new(|cx| {
InputState::new(window, cx)
.multi_line(multi_line)
.default_value(value)
})
}
impl Tty7App {
/// Build the settings tab body: a fixed left sidebar (section nav) beside a
/// scrollable content area for the selected section. Esc closes the tab.
pub(crate) fn render_settings(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
// Copy the palette out (Hsla is Copy) so this borrow doesn't outlive into
// `render_settings_search_results` below, which needs `cx` mutably.
let theme = cx.theme();
let (background, foreground, header_muted) =
(theme.background, theme.foreground, theme.muted_foreground);
let (focus_handle, section, theme_panel_open, search) = match self.active_settings() {
Some(s) => (
s.focus_handle.clone(),
s.section,
s.theme_panel_open,
s.search.clone(),
),
None => return div(), // not a settings tab; nothing to render
};
// Live settings-search query (trimmed, lowered). Non-empty swaps the six
// section links for a cross-section list of matching settings.
let query = search.read(cx).value().trim().to_lowercase();
// The theme picker panel only makes sense beside its own page.
let show_theme_panel = theme_panel_open && section == SettingsSection::Appearance;
// `TTY7_PROFILE`: time this section's whole element build and, via the
// aggregated call rate, expose whether the panel is rebuilding once (on a
// real change) or in a tight `notify` loop. Labelled per section so
// Appearance's cost stands apart from the lighter pages.
let prof = crate::ui::perf::enabled()
.then(|| (std::time::Instant::now(), section.profile_label()));
// Sidebar nav item that activates a section on click. While a search is
// active it also carries a trailing `(N)` count of that section's matching
// settings — the full section nav stays put and is annotated with
// per-section hit counts, rather than collapsing into a flat result list.
let nav_item = |label: &'static str, target: SettingsSection, icon: Icon| {
let view = cx.entity();
let count = if query.is_empty() {
0
} else {
section_match_count(target, &query)
};
let item = SidebarMenuItem::new(label)
.icon(icon)
.active(section == target)
.on_click(move |_, _window, cx| {
view.update(cx, |this, cx| this.select_settings_section(target, cx));
});
if count > 0 {
item.suffix(move |_w, _cx| {
div()
.text_xs()
.text_color(header_muted)
.child(format!("({count})"))
})
} else {
item
}
};
// The section links stay put during search — only their `(N)` suffixes
// change — so the nav never collapses out from under the user.
let nav_body = SidebarMenu::new()
.child(nav_item(
"Appearance",
SettingsSection::Appearance,
Icon::new(IconName::Palette),
))
// The `>_` prompt glyph for Terminal, which now owns the shell
// program; the "Aa" glyph is the closest thing the icon set has to
// a keyboard for Input.
.child(nav_item(
"Terminal",
SettingsSection::Terminal,
Icon::new(IconName::SquareTerminal),
))
.child(nav_item(
"Input",
SettingsSection::Input,
Icon::new(IconName::Settings2),
))
.child(nav_item(
"SSH",
SettingsSection::Ssh,
Icon::new(IconName::Globe),
))
.child(nav_item(
"Agents",
SettingsSection::Agents,
Icon::new(IconName::Bot),
))
.child(nav_item(
"Window & Tabs",
SettingsSection::WindowTabs,
Icon::new(IconName::WindowRestore),
))
// The icon set ships no keyboard glyph; CaseSensitive ("Aa")
// is the closest key-ish cue available.
.child(nav_item(
"Keybindings",
SettingsSection::Keybindings,
Icon::new(IconName::CaseSensitive),
))
// Not `IconName::Info`: `icons/info.svg` is overridden app-wide with
// the detail panel's "panel with two lines" glyph, which reads as a
// document, not as *About*. This row keeps the circled `i`.
.child(nav_item(
"About",
SettingsSection::About,
Icon::empty().path("icons/circle-info.svg"),
));
let sidebar = Sidebar::new("settings-sidebar")
.collapsible(SidebarCollapsible::None)
// Match the tab sidebar's default width (`default_sidebar_width`, 220px)
// so toggling the settings overlay over the vertical rail doesn't shift
// the left column — narrower than the stock 255px too, which three short
// items don't need and which reads more native/less hollow.
.w(px(220.))
.header(
v_flex()
.w_full()
.px_2()
.gap_2()
// Reserve the title-bar height at the top so the nav rail
// reaches the very top of the window (the macOS traffic lights
// rest on its surface) with the header clearing them — matching
// the tab rail's top zone.
.pt(px(crate::ui::app::TITLE_BAR_HEIGHT))
.pb_1()
.child(
div()
.text_xs()
.font_weight(FontWeight::MEDIUM)
.text_color(header_muted)
.child("SETTINGS"),
)
// Settings search: type a setting or a synonym and each section
// below shows how many of its settings match, with the
// best-matching section auto-selected (see the search input's
// change subscription in `app.rs`). Styled like the tab sidebar's
// search — a leading magnifier + a borderless input sitting flush
// on the rail surface, no box, so the header reads clean.
.child(
h_flex()
.items_center()
// Laid out to land on the nav rows below it rather than
// on the header's own inset: a `SidebarMenuItem` is
// `p_2` + a 16px icon + `gap_x_2`, so its label starts
// 32px into the rail. Matching that takes all three of
// these — the magnifier at the rows' 16px (not `small`,
// which is 14 and left the glyph reading a size below
// the column it heads), the same 8px gap after it, and
// `pl_0` on the input, which otherwise adds `input_px`
// (12px at the default size) whether or not it draws a
// box. Without them the placeholder sat 6px right of
// every label under it.
.gap_2()
// Stock magnifier, not tty7's: this page's glyphs run at
// 16px, where the detail panel's redraw reads thin and
// its handle stubby. See `assets::STOCK_PREFIX`.
.child(
Icon::empty()
.path("stock/icons/search.svg")
.size(px(16.))
.text_color(header_muted),
)
.child(
div()
.flex_1()
.min_w_0()
.child(Input::new(&search).appearance(false).pl_0()),
),
),
)
.child(nav_body);
let content = match section {
SettingsSection::Appearance => self.render_settings_appearance(cx),
SettingsSection::Terminal => self.render_settings_terminal(cx),
SettingsSection::Input => self.render_settings_input(cx),
SettingsSection::Ssh => self.render_settings_ssh(cx),
SettingsSection::Agents => self.render_settings_agents(cx),
SettingsSection::WindowTabs => self.render_settings_window_tabs(cx),
SettingsSection::Keybindings => self.render_settings_keybindings(cx),
SettingsSection::About => self.render_settings_about(cx),
};
// One continuous, flat sheet (no cards) — one document: bold section
// headers and full-width rules carry the structure, so settings read as a
// unified document rather than a widget floating in empty space.
//
// The SSH section is the exception: it is its own two-column master-detail
// that fills the pane height, with each column owning its scroll — so it
// bypasses the shared padded, single-scroll wrapper (which would otherwise
// give the whole section one outer scrollbar and no definite height for the
// columns to fill) and is dropped in flush instead.
// A `flex_1` pane still defaults to `min-width: auto`, so on a narrow
// window it refuses to shrink below its content's intrinsic width and
// shoves the fixed 300px theme panel (and its close `×`) off the right
// edge. `min_w_0` lets the pane yield so the panel stays fully on-screen.
let content_pane = if section == SettingsSection::Ssh {
v_flex()
.id("settings-content")
.flex_1()
.min_w_0()
.h_full()
.bg(background)
.child(content)
} else {
v_flex()
.id("settings-content")
.flex_1()
.min_w_0()
.h_full()
.bg(background)
.overflow_y_scroll()
.child(
div()
.px_10()
.py_8()
// Cap the column tight enough (640px) that a row's
// right-aligned control stays visually paired with its
// label — the cap is what makes `settings_row`'s
// space-between layout safe.
.child(div().w_full().max_w(px(640.)).child(content)),
)
};
let root = div()
.size_full()
.relative()
.flex()
.flex_row()
.bg(background)
.text_color(foreground)
.track_focus(&focus_handle)
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
if ev.keystroke.key.as_str() == "escape" {
this.close_settings(window, cx);
}
}))
// The Sidebar draws its own right border; no wrapper border here, or
// the two stack into one thick rule.
.child(sidebar)
.child(content_pane)
// The overlay covers the real title bar, so the window's own drag
// region is buried. Restore it: a transparent strip across the top
// band (the height the title bar reserved) that moves the window on
// drag and zooms it on double-click, exactly like the title bar it
// stands in for. `window_move_gesture` owns the gesture and the
// reasoning behind it; the #221 failure was worst here, because this
// strip *is* the whole top band with no immune caption beside it.
.child(
crate::ui::app::window_move_gesture(
div()
.id("settings-titlebar-drag")
.absolute()
.top_0()
.left_0()
.right_0()
.h(px(crate::ui::app::TITLE_BAR_HEIGHT)),
"settings-titlebar-drag",
window,
cx,
)
.on_double_click(|_, window, _| window.titlebar_double_click()),
)
.when(show_theme_panel, |r| r.child(self.render_theme_panel(cx)))
// Close affordance at the page's top-right corner (Esc and Cmd+, also
// close) — the intuitive "close this page" spot, and clear of the
// macOS traffic lights (top-left) and the window controls' zone.
// Hidden while the theme panel is open: it docks at the same right edge
// and carries its own ✕, so keeping this one would stack two ✕ there.
.when(!show_theme_panel, |r| {
r.child(
// A full chrome tile, because it stands in the same corner as
// the title bar's own: a `small` icon button is 24px, which
// reads undersized next to the 34px window-control tiles this
// spot belongs to. `right` is the window-control zone's own
// margin rather than the content inset — what this has to
// clear here is the controls, not a text column. `top`
// centres it in the title bar's band.
div()
.absolute()
.top(px((TITLE_BAR_HEIGHT - TILE_SIZE) / 2.))
.right(px(10.))
.occlude()
.child(
Button::new("settings-close")
.icon(Icon::new(IconName::Close))
.ghost()
// Sizing the button, not the icon: `Button::render`
// overwrites whatever size the icon was handed.
// See `BUTTON_ICON_SCALE`.
.with_size(px(
TILE_GLYPH_LINE / crate::ui::tab_strip::BUTTON_ICON_SCALE
))
.w(px(TILE_SIZE))
.h(px(TILE_SIZE))
.rounded_lg()
.on_click(cx.listener(|this, _, window, cx| {
this.close_settings(window, cx)
})),
),
)
});
if let Some((start, label)) = prof {
crate::ui::perf::record(label, start.elapsed());
}
root
}
/// Just the styled section title (no margin). Shared by `section_header` and
/// `section_intro` so the two can never drift in size, weight, or color.
fn header_text(&self, title: &str, cx: &Context<Self>) -> Div {
div()
.text_base()
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().foreground)
.child(title.to_string())
}
/// A bold section header that introduces a group of settings.
/// With no cards, the header *is* the unit of grouping — it tells the eye
/// where one set of related controls begins.
pub(crate) fn section_header(&self, title: &str, cx: &Context<Self>) -> Div {
self.header_text(title, cx).mb_4()
}
/// A section header paired with its one-line intro as a single unit: the
/// subtitle sits tight under the title (`gap_1`) and the block leaves a
/// consistent gap before the first control (`mb_4`). Replaces the ad-hoc
/// "header, then a loose paragraph" pattern that stranded the subtitle 16px
/// below its own title (glued instead to the controls) and used a different
/// bottom margin — `mb_1` here, `mb_2` there — in every section.
fn section_intro(&self, title: &str, desc: impl Into<String>, cx: &Context<Self>) -> Div {
v_flex()
.mb_4()
.gap_1()
.child(self.header_text(title, cx))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(desc.into()),
)
}
/// A full-width hairline between sections, so the page reads as one
/// continuous sheet rather than stacked boxes.
pub(crate) fn section_rule(&self, cx: &Context<Self>) -> Div {
div().h(px(1.)).my_7().bg(cx.theme().border)
}
/// One labelled settings row, shared by every section: title + description
/// on the left, control right-aligned. Space-between is safe here only
/// because both hosting columns are capped — the main content column at
/// 640px and the SSH detail pane at 720px — so the two never stretch apart
/// into a dead gap the way they did on an uncapped pane; widen either cap
/// and every row inside it stretches with it. A soft full-row
/// hover fill makes each row read as one scannable unit — the same quiet
/// highlight the sidebar and menus use; negative side margins let that fill
/// bleed past the text edge while labels stay aligned with the section
/// headers above.
pub(crate) fn settings_row(
&self,
label: impl Into<String>,
desc: impl Into<String>,
control: AnyElement,
cx: &Context<Self>,
) -> Div {
let theme = cx.theme();
let desc = desc.into();
h_flex()
.items_center()
.justify_between()
.gap_8()
.py_2()
.px_2p5()
.mx_neg_2p5()
.rounded_lg()
.hover(|h| h.bg(gpui::rgb(cx.global::<presets::Surfaces>().window.hover)))
.child(
v_flex()
.gap_0p5()
.min_w_0()
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(theme.foreground)
.child(label.into()),
)
// Rows without a description (the theme color editor) stay
// single-line instead of carrying an empty text child.
.when(!desc.is_empty(), |col| {
col.child(
div()
.text_xs()
.text_color(theme.muted_foreground)
.child(desc),
)
}),
)
.child(h_flex().flex_shrink_0().child(control))
}
/// A segmented control for a small set of mutually-exclusive options — the
/// refined stand-in for a raw row of radio circles, which read as an unstyled
/// form beside the sheet's tuned steppers and chips. Joined segments in a
/// single outlined track, one of them filled, speak the same segmented
/// language as the −│value│+ stepper right beside them; the 24px height
/// matches the selects in the same rows. `selected` is the active index;
/// `on_pick` fires with the newly chosen one.
///
/// # Why this is hand-rolled
///
/// It used to be gpui-component's `ButtonGroup::outline()` with
/// `Button::selected`, and that is what issue #197 was reported against. That
/// path derives the selected segment's fill from `Theme::input` and gives it
/// the *same* border and the *same* label color as its unselected siblings —
/// so the entire selection signal was one fill, and that fill came from a
/// grey unrelated to the active theme. On Dracula it measured **1.03:1**.
///
/// `Theme::input` is now themed (see `ui::theme::apply_theme`), which fixes
/// the stock control for inputs and selects. But a segmented control is the
/// one place in the app where several options sit visibly side by side with a
/// *static* selection, so it is precisely where a fill alone is not enough
/// (see `presets::Surface`) — and the stock button exposes no way to vary the
/// label's weight. Owning the 30 lines is cheaper than a fork patch, and it
/// puts the control on the same ladder every hand-rolled surface reads.
pub(crate) fn segmented(
&self,
id: impl Into<SharedString>,
options: &'static [&'static str],
selected: usize,
cx: &mut Context<Self>,
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + 'static,
) -> AnyElement {
let sf = cx.global::<presets::Surfaces>().window;
self.segmented_on(sf, id, options, selected, cx, on_pick)
}
/// [`Self::segmented`] for a control that does *not* sit on the settings
/// sheet. The track paints its own opaque ground, so it has to be told which
/// one: dropped on the right panel's sunk rail, a window-surface track reads
/// as a faintly darker box cut out of the column it sits in — and every rung
/// above it was derived against the wrong ground.
pub(crate) fn segmented_on(
&self,
sf: presets::Surface,
id: impl Into<SharedString>,
options: &'static [&'static str],
selected: usize,
cx: &mut Context<Self>,
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + 'static,
) -> AnyElement {
let border = cx.theme().border;
// Taken by name rather than as a `&'static str` so per-row controls (the
// forward rules) can carry an id derived from their index.
let id: SharedString = id.into();
let on_pick = std::rc::Rc::new(on_pick);
let count = options.len();
h_flex()
.id(gpui::ElementId::Name(id.clone()))
.h(px(24.))
.rounded(rounding::TRACK_RADIUS)
.border_1()
.border_color(border)
// The track paints its own ground rather than letting the sheet show
// through. Every rung of the ladder was derived against this colour,
// so painting it is what makes those ratios true — a control that
// leaves its ground to whatever it happens to be composited over is
// the shape of the bug this whole change is about.
.bg(gpui::rgb(sf.base))
// A backstop for content overflow, and nothing more. This used to be
// what shaped the end segments' fills to the track's rounding, and it
// cannot do that: gpui's overflow mask is a square, unantialiased
// scissor (issue #236, see `ui::rounding`). The segments carry their
// own radii below.
.overflow_hidden()
.children(options.iter().enumerate().map(|(i, label)| {
let active = i == selected;
let on_pick = on_pick.clone();
// The two end segments cap the track, so their fills have to draw
// the corner themselves — one border-width tighter than the
// track's own radius, so the arc nests inside the border instead
// of bulging past it into the square clip.
let corners =
rounding::segment_corners(i, count, rounding::TRACK_RADIUS, rounding::HAIRLINE);
h_flex()
// A per-segment id keeps each one unique across the several
// segmented controls on the page.
.id(gpui::ElementId::NamedInteger(id.clone(), i as u64))
.items_center()
.justify_center()
.h_full()
.px_2p5()
.text_sm()
.cursor_pointer()
.rounded_corners(corners)
// Hairlines *between* segments only — the track already owns
// its outer edge, and a border on the first segment would
// double it.
.when(i > 0, |s| s.border_l_1().border_color(border))
// Both channels, every time. The fill locates the selection in
// the row; the label color and weight say it is the one — and
// keep saying it on a translucent window, where the fill is
// washing over whatever is behind the sheet.
.when(active, |s| {
s.bg(gpui::rgb(sf.selected))
.text_color(gpui::rgb(sf.text_selected))
.font_weight(FontWeight::MEDIUM)
})
.when(!active, |s| {
s.text_color(gpui::rgb(sf.text_resting))
.hover(|h| h.bg(gpui::rgb(sf.hover)))
})
// Pressed reads past selected, so pushing the segment that is
// already chosen still acknowledges the click.
.active(|s| s.bg(gpui::rgb(sf.pressed)))
.child(*label)
.on_click(cx.listener(move |this, _, window, cx| {
on_pick(this, i, window, cx);
}))
}))
.into_any_element()
}
/// Appearance section: theme, font size, font family.
fn render_settings_appearance(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let foreground = theme.foreground;
let border = theme.border;
// Hover comes off the ladder; `stepper_bg` stays a soft resting tint —
// it decorates a container rather than signalling a state, which is the
// one job an alpha-multiplied grey is still fine for.
let hover_bg = gpui::rgb(cx.global::<presets::Surfaces>().window.hover);
let stepper_bg = theme.secondary.opacity(0.35);
let font_size = self.font_size;
let (font_select, font_bold_select, font_italic_select) = match self.active_settings() {
Some(s) => (
s.font_select.clone(),
s.font_bold_select.clone(),
s.font_italic_select.clone(),
),
None => return div().into_any_element(),
};
let cfg = cx.global::<Config>();
let cursor_style = cfg.cursor_style;
let cursor_blink = cfg.cursor_blink;
let font_ligatures = cfg.font_features.as_ref().is_some_and(|features| {
features.is_calt_enabled() == Some(true)
|| features
.tag_value_list()
.iter()
.any(|(tag, value)| tag == "liga" && *value != 0)
});
// Unified /value/+ stepper plus a quiet Reset. `slot` is the glyph's
// place in the three-slot track (−│value│+): it draws the internal
// hairline, and — because a hover fill in an end slot would otherwise
// square off the track's corner (issue #236) — the corner radii.
//
// `h_full` rather than `py_1` is load-bearing for that second job. A
// padded, auto-height glyph box measures 31px (14px text × gpui's φ line
// height, plus 8px of padding) against a 22px content box, so the track's
// `items_center` centres it and `overflow_hidden` crops the overhang —
// the fill still reaches the corner, but the box's own rounded corner is
// 4½px outside the visible strip, where it does nothing. Pinning the box
// to the track's content height puts the arc back where the corner is.
let step = move |id: &'static str, glyph: &'static str, slot: usize| {
let corners =
rounding::segment_corners(slot, 3, rounding::TRACK_RADIUS, rounding::HAIRLINE);
h_flex()
.id(id)
.items_center()
.justify_center()
.h_full()
.px_2p5()
.text_sm()
.cursor_pointer()
.text_color(foreground)
.when(slot > 0, |s| s.border_l_1().border_color(border))
.rounded_corners(corners)
.hover(|h| h.bg(hover_bg))
.child(glyph)
};
// One shared height for every small control in this section (matches
// gpui-component's own Size::Small button height) so the stepper pill
// and the font-family select sit at the same visual weight instead of
// each defaulting to its own padding.
let control_h = px(24.);
// The −│value│+ pill plus its quiet Reset — one shape shared by the
// font-size and line-height rows; callers hand in the wired buttons.
// Reset sits *before* the pill: with controls right-aligned, the pill
// holds the row's hard right edge and the quiet action tucks inboard.
let stepper_row =
move |dec: Stateful<Div>, value: String, inc: Stateful<Div>, reset: Button| {
h_flex()
.items_center()
.gap_3()
.child(reset)
.child(
h_flex()
.items_center()
.h(control_h)
.rounded(rounding::TRACK_RADIUS)
.bg(stepper_bg)
.border_1()
.border_color(border)
// Overflow backstop only — `step` rounds its own
// corners, because this clip is square (see
// `ui::rounding`).
.overflow_hidden()
.child(dec)
.child(
div()
.min_w(px(40.))
// Hairline on the value's left edge so both internal
// seams read (−│value│+); the `+` supplies the right one.
.border_l_1()
.border_color(border)
.py_1()
.text_center()
.text_sm()
.text_color(foreground)
.child(value),
)
.child(inc),
)
.into_any_element()
};
let font_size_control = stepper_row(
step("font-dec", "", 0).on_click(
cx.listener(|this, _, _w, cx| this.change_font_size(-FONT_SIZE_STEP, cx)),
),
format!("{:.0}", font_size),
step("font-inc", "+", 2)
.on_click(cx.listener(|this, _, _w, cx| this.change_font_size(FONT_SIZE_STEP, cx))),
Button::new("font-reset")
.label("Reset")
.ghost()
.small()
.on_click(cx.listener(|this, _, _w, cx| this.reset_font_size(cx))),
);
let line_height = self.line_height;
let line_height_control = stepper_row(
step("lh-dec", "", 0).on_click(
cx.listener(|this, _, _w, cx| this.change_line_height(-LINE_HEIGHT_STEP, cx)),
),
format!("{:.2}", line_height),
step("lh-inc", "+", 2).on_click(
cx.listener(|this, _, _w, cx| this.change_line_height(LINE_HEIGHT_STEP, cx)),
),
Button::new("lh-reset")
.label("Reset")
.ghost()
.small()
.on_click(cx.listener(|this, _, _w, cx| this.reset_line_height(cx))),
);
// One font dropdown, shared shape for primary / bold / italic pickers.
let font_dropdown = |state: &Entity<SelectState<SearchableVec<String>>>| {
Select::new(state)
.small()
.w(px(180.))
.h(control_h)
.search_placeholder("Search fonts…")
// Cap the popup's own height so browsing doesn't dump the
// OS's entire font catalog on screen at once — it just
// scrolls from here. Every font is still in the list and
// reachable by typing; this only trims what's shown.
.menu_max_h(px(224.))
.into_any_element()
};
let font_family_control = font_dropdown(&font_select);
let font_bold_control = font_dropdown(&font_bold_select);
let font_italic_control = font_dropdown(&font_italic_select);
let ligature_switch = crate::ui::theme::switch("font-ligatures", cx)
.checked(font_ligatures)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_font_ligatures(*on, cx)))
.into_any_element();
let cursor_idx = match cursor_style {
CursorStyle::Block => 0,
CursorStyle::Bar => 1,
CursorStyle::Underline => 2,
};
let cursor_style_control = self.segmented(
"cursor-style",
&["Block", "Bar", "Underline"],
cursor_idx,
cx,
|this, ix, _w, cx| {
let style = match ix {
0 => CursorStyle::Block,
1 => CursorStyle::Bar,
_ => CursorStyle::Underline,
};
this.set_cursor_style(style, cx);
},
);
// Blink lives here beside the shape — one Cursor home, not "shape is
// appearance, blink is behavior" split across two pages.
let blink_switch = crate::ui::theme::switch("cursor-blink", cx)
.checked(cursor_blink)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_cursor_blink(*on, cx)))
.into_any_element();
v_flex()
.child(self.section_intro(
"Theme",
"Pick a color theme. Each one sets its own light or dark look.",
cx,
))
.child(self.render_theme_selection(cx))
// Custom-theme management (duplicate / edit colors / open folder) is
// *about* themes, so it lives with the picker rather than stranded at
// the foot of the page after Cursor.
.child(self.render_custom_themes(cx))
.child(self.section_rule(cx))
.child(self.render_window_section(cx))
.child(self.section_rule(cx))
.child(self.section_header("Typography", cx))
.child(self.settings_row(
"Font size",
"Terminal text size in pixels.",
font_size_control,
cx,
))
.child(self.settings_row(
"Line height",
"Row spacing as a multiple of the font size.",
line_height_control,
cx,
))
.child(self.settings_row(
"Font family",
"Pick from fonts installed on your system.",
font_family_control,
cx,
))
.child(self.settings_row(
"Bold font",
"Face for bold text; Default synthesizes it from the primary.",
font_bold_control,
cx,
))
.child(self.settings_row(
"Italic font",
"Face for italic text; Default synthesizes it from the primary.",
font_italic_control,
cx,
))
.child(self.settings_row(
"Font ligatures",
"Enable common programming ligature features for terminal text.",
ligature_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Cursor", cx))
.child(self.settings_row(
"Cursor shape",
"How the terminal cursor is drawn.",
cursor_style_control,
cx,
))
.child(self.settings_row(
"Cursor blink",
"Pulse the cursor while the terminal is focused.",
blink_switch,
cx,
))
.into_any_element()
}
/// Window section (Appearance): the global opacity slider and blur switch
/// that apply to every theme, then the inactive-pane dimming switch. The
/// first two are config *overrides* — until touched they follow the active
/// theme's own `opacity`/`blur`, and "Follow theme" clears them back to that
/// state; the dimming switch is a plain flag no theme carries a value for,
/// so it sits below that button and "Follow theme" leaves it alone.
fn render_window_section(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(slider) = self
.active_settings()
.map(|s| s.window_opacity_slider.clone())
else {
return div().into_any_element();
};
let config = cx.global::<Config>();
let overridden = config.window_opacity.is_some() || config.window_blur.is_some();
let dim_inactive_panes = config.dim_inactive_panes;
let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
let opacity = Tty7App::effective_window_opacity(cx);
let blur = cx.global::<Config>().window_blur.unwrap_or(theme.blur);
let opacity_control = h_flex()
.items_center()
.gap_3()
.w(px(240.))
.child(div().flex_1().child(Slider::new(&slider)))
.child(
div()
.w(px(36.))
.text_sm()
.text_color(cx.theme().foreground)
.child(format!("{:.0}%", opacity * 100.)),
)
.into_any_element();
let blur_switch = crate::ui::theme::switch("window-blur", cx)
.checked(blur)
.on_click(
cx.listener(|this, on: &bool, window, cx| this.set_window_blur(*on, window, cx)),
)
.into_any_element();
let dim_switch = crate::ui::theme::switch("dim-inactive-panes", cx)
.checked(dim_inactive_panes)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_dim_inactive_panes(*on, cx)))
.into_any_element();
v_flex()
// Not "Window": Settings → Window & Tabs owns that word for the
// window's lifecycle, and two groups called Window on two pages is
// how a user ends up on the wrong one.
.child(self.section_header("Transparency", cx))
.child(self.settings_row(
"Opacity",
"How opaque the window background is, for every theme. Below \
100% the desktop shows through.",
opacity_control,
cx,
))
.child(self.settings_row(
"Blur",
"Blur whatever is behind a translucent window (macOS).",
blur_switch,
cx,
))
// Only offered while an override is active; otherwise the values
// already follow the theme and the button would be a no-op.
.when(overridden, |this| {
this.child(
h_flex().mt_2().child(
Button::new("follow-theme-window")
.label("Follow theme")
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.reset_window_overrides(window, cx)
})),
),
)
})
// Below "Follow theme", which resets the two rows above it and not
// this one — a plain setting with no theme value behind it.
.child(self.settings_row(
"Dim inactive panes",
"Fade unfocused panes in a split so the active one stands out.",
dim_switch,
cx,
))
.into_any_element()
}
/// Custom themes section. On an editable theme, the color editor; on a
/// read-only built-in / import, a "Duplicate to edit" button that forks it
/// into an editable file. The folder button is always available.
fn render_custom_themes(&self, cx: &mut Context<Self>) -> AnyElement {
let editor = self.active_settings().and_then(|s| s.theme_editor.as_ref());
let folder_button = Button::new("open-themes-folder")
.label("Open themes folder")
.small()
.on_click(cx.listener(|this, _, _w, cx| this.open_themes_folder(cx)));
if let Some(editor) = editor {
// Snapshot the picker handles so the render borrow of `self` ends.
let seed: Vec<_> = editor
.seed
.iter()
.map(|(_, label, state)| (label.clone(), state.clone()))
.collect();
let ansi: Vec<_> = editor
.ansi
.iter()
.map(|(_, label, state)| (label.clone(), state.clone()))
.collect();
let image_opacity_slider = editor.image_opacity_slider.clone();
// The theme's current image, for the filename label and the
// opacity readout (the slider owns its own thumb position).
let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
let image = theme.image.clone();
let image_name = image.as_ref().map(|i| {
i.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| i.path.display().to_string())
});
let image_control = h_flex()
.items_center()
.gap_2()
.w(px(240.))
.child(
Button::new("pick-theme-image")
.label(if image.is_some() {
"Change…"
} else {
"Choose…"
})
.small()
.on_click(cx.listener(|this, _, _w, cx| this.pick_theme_image(cx))),
)
.when_some(image_name, |this, name| {
this.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(name),
)
.child(
Button::new("remove-theme-image")
.label("Remove")
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.remove_theme_image(window, cx)
})),
)
})
.into_any_element();
let image_opacity_row = image_opacity_slider.map(|slider| {
let readout = image.as_ref().map(|i| i.opacity).unwrap_or(0.3);
let control = h_flex()
.items_center()
.gap_3()
.w(px(240.))
.child(div().flex_1().child(Slider::new(&slider)))
.child(
div()
.w(px(36.))
.text_sm()
.text_color(cx.theme().foreground)
.child(format!("{:.0}%", readout * 100.)),
)
.into_any_element();
self.settings_row(
"Image opacity",
"How strongly the image shows over the background color.",
control,
cx,
)
});
return v_flex()
.mt_5()
.child(self.section_intro(
"Edit theme",
"You're editing a copy. Changes save to its file in the themes \
folder and apply live.",
cx,
))
.children(
seed.into_iter()
.map(|(label, state)| self.render_theme_color_row(label, state, cx)),
)
.child(self.settings_row(
"Background image",
"Composited over the background color, under the text.",
image_control,
cx,
))
.children(image_opacity_row)
.child(self.section_header("ANSI colors", cx))
.children(
ansi.into_iter()
.map(|(label, state)| self.render_theme_color_row(label, state, cx)),
)
.child(h_flex().mt_4().child(folder_button))
.into_any_element();
}
// Read-only theme (built-in or import): offer to duplicate it into an
// editable copy, plus the folder affordance.
v_flex()
.mt_5()
.child(self.section_intro(
"Custom themes",
"Duplicate a theme to edit its colors here, or drop your own in the \
themes folder: a tty7 YAML theme or an iTerm2 .itermcolors scheme.",
cx,
))
.child(
h_flex()
.gap_3()
.child(
// Plain (not `.primary()`): a solid near-black fill reads
// far too heavy against this soft, mostly-outline sheet —
// it matches the "Open themes folder" button beside it.
Button::new("duplicate-theme")
.label("Duplicate to edit")
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.fork_active_theme(window, cx)
})),
)
.child(folder_button),
)
.into_any_element()
}
/// One color-editor row: a label paired with its picker. The picker's own
/// `Change` event (wired in `rebuild_theme_editor`) writes the edit to the
/// theme file, so the row itself is purely presentational.
fn render_theme_color_row(
&self,
label: String,
state: Entity<ColorPickerState>,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let control = ColorPicker::new(&state).small().into_any_element();
self.settings_row(label, "", control, cx)
}
/// SSH section: saved connection profiles plus the global security toggles
/// (host-key verification default and warn-on-close; a per-profile override
/// still wins where set).
///
/// A two-column master-detail (like the theme picker): the **left** column is
/// a fixed-width, self-scrolling master — Import / Add on top, then the profile
/// list; the **right** column is the flex-1, self-scrolling detail pane showing
/// the selected profile's edit form (or a "pick a profile" hint) with the
/// global security defaults always below. Selection is tracked in
/// [`SettingsState::ssh_detail`].
fn render_settings_ssh(&self, cx: &mut Context<Self>) -> AnyElement {
let border = cx.theme().border;
h_flex()
.size_full()
.items_start()
.child(
// LEFT (master): fixed width, its own scroll, a right divider.
v_flex()
.id("ssh-master")
.flex_shrink_0()
.w(px(280.))
.h_full()
.border_r_1()
.border_color(border)
.overflow_y_scroll()
.child(self.render_ssh_master(cx)),
)
.child(
// RIGHT (detail): flex-1, its own scroll.
v_flex()
.id("ssh-detail")
.flex_1()
.h_full()
.overflow_y_scroll()
.child(
// Clear the title-bar drag strip / close ✕ up top, and cap
// the detail width so the form stays readable on wide panes.
div()
.pt(px(crate::ui::app::TITLE_BAR_HEIGHT))
.px_8()
.pb_8()
.child(
div()
.w_full()
.max_w(px(720.))
.child(self.render_ssh_detail(cx)),
),
),
)
.into_any_element()
}
/// The left (master) column: a Hosts header carrying the add / overflow
/// affordances, a live filter, then the list — `Defaults` pinned on top and
/// every saved host bucketed by group into a collapsible section.
///
/// The filter leads, and Add / Import shrank to icon affordances, because
/// that is the order the column is actually used in: past a dozen hosts,
/// finding one *is* the job. Two full-width buttons on top read as a page
/// header while pushing the content that matters below the fold.
fn render_ssh_master(&self, cx: &mut Context<Self>) -> AnyElement {
let muted = cx.theme().muted_foreground;
// Rows in this list paint on the settings sheet, i.e. the window surface.
let sf = cx.global::<presets::Surfaces>().window;
let profiles = cx.global::<Config>().ssh_profiles.clone();
let (filter, collapsed, detail) = match self.active_settings() {
Some(s) => (
s.ssh_filter.clone(),
s.ssh_collapsed_groups.clone(),
s.ssh_detail,
),
None => return div().into_any_element(),
};
let query = filter.read(cx).value().trim().to_lowercase();
// Which profiles have a connected pane right now: each row's dot, and the
// count a collapsed group header keeps showing.
let live = self.live_ssh_profiles(cx);
let menu_app = cx.entity().downgrade();
let header = v_flex()
.gap_2()
.child(
// The same weight every other section leads with. Tried as the nav
// rail's small-caps label instead, and it read as a sub-header of
// the nav rather than the title of a column: every other page in
// Settings opens with a title at this size, and this column is
// where this page starts. So it gets the line to itself.
self.header_text("Hosts", cx),
)
.child(
// One toolbar row: the filter, then the two affordances that act on
// the list. The borderless magnifier-then-input is the nav header's
// settings search, laid out the same way — a boxed field would be
// the only outlined control on a sheet that has none, and would read
// as a different kind of search from the one two columns to its left.
h_flex()
.items_center()
.gap_2()
.child(
// Stock magnifier, not tty7's: at this size the redraw
// reads thin and its handle stubby. See `assets::STOCK_PREFIX`.
Icon::empty()
.path("stock/icons/search.svg")
.size(px(16.))
.text_color(muted),
)
.child(
div()
.flex_1()
.min_w_0()
.child(Input::new(&filter).appearance(false).pl_0()),
)
.child(
h_flex()
.flex_shrink_0()
.gap_0p5()
.child(
Button::new("ssh-profiles-add")
.icon(Icon::new(IconName::Plus))
.ghost()
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.add_new_profile(window, cx)
})),
)
.child(
Button::new("ssh-profiles-more")
// Stock `⋯`, not tty7's: the redraw's filled
// `r=2` dots smear at this size. See the row
// menu below and `assets::STOCK_PREFIX`.
.icon(Icon::empty().path("stock/icons/ellipsis.svg"))
.ghost()
.small()
.dropdown_menu_with_anchor(
gpui::Anchor::TopRight,
move |menu, _window, _cx| {
Self::ssh_master_menu(menu, &menu_app)
},
),
),
),
);
// Bucket by group, keeping each group's config order. Filtering happens
// before bucketing, so a group the query empties drops out whole instead
// of leaving a header standing over nothing.
let mut groups: Vec<(String, Vec<SshProfile>)> = Vec::new();
for p in profiles.iter().filter(|p| ssh_row_matches(p, &query)) {
let key = ssh_group_key(p).to_string();
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, bucket)) => bucket.push(p.clone()),
None => groups.push((key, vec![p.clone()])),
}
}
groups.sort_by(|a, b| {
ssh_group_rank(&a.0)
.cmp(&ssh_group_rank(&b.0))
.then_with(|| a.0.cmp(&b.0))
});
// Defaults sits above the groups and outside the filter: it owns the
// security toggles, and hiding those behind a query nobody thinks to type
// is how a setting becomes undiscoverable.
let mut list = v_flex().gap_0p5().w_full().child(self.render_ssh_row(
"ssh-defaults-row",
"Defaults",
"Inherited by every host",
detail == SshDetail::Defaults,
None,
sf,
cx.listener(|this, _, _w, cx| this.select_ssh_defaults(cx)),
None,
cx,
));
if profiles.is_empty() {
list = list.child(
div()
.py_4()
.text_sm()
.text_color(muted)
.child("No saved hosts yet."),
);
} else if groups.is_empty() {
list = list.child(
div()
.py_4()
.text_sm()
.text_color(muted)
.child(format!("Nothing matches {query}.")),
);
}
for (key, bucket) in groups {
// A live query force-expands every group: a match hiding inside a
// collapsed section is the same as no match at all.
let is_collapsed = query.is_empty() && collapsed.contains(&key);
let live_here = bucket.iter().filter(|p| live.contains(&p.id)).count();
list = list.child(self.render_ssh_group_header(
&key,
bucket.len(),
is_collapsed,
live_here,
cx,
));
if is_collapsed {
continue;
}
for p in &bucket {
list = list.child(self.render_ssh_host_row(
p,
detail == SshDetail::Profile(p.id),
live.contains(&p.id),
sf,
cx,
));
}
}
v_flex()
.p_2()
.gap_2()
// Clear the title-bar drag strip up top so the buttons stay clickable.
.pt(px(crate::ui::app::TITLE_BAR_HEIGHT))
.child(header)
.child(list)
.into_any_element()
}
/// The master column's overflow menu: the `~/.ssh/config` link lives here
/// rather than on a permanent full-width button, because it is a once-in-a-
/// while action and the list beneath it is not.
fn ssh_master_menu(menu: PopupMenu, app: &gpui::WeakEntity<Self>) -> PopupMenu {
menu.min_w(px(200.))
.item(PopupMenuItem::new("Import from ~/.ssh/config").on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.import_ssh_config_profiles(cx));
}
}))
.item(PopupMenuItem::new("Expand all groups").on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| {
if let Some(s) = this.active_settings_mut() {
s.ssh_collapsed_groups.clear();
}
cx.notify();
});
}
}))
}
/// One collapsible group header in the master list. Collapsed, it keeps
/// showing how many of its hosts are connected — folding a section away
/// should hide the rows, not the fact that something in there is live.
fn render_ssh_group_header(
&self,
key: &str,
count: usize,
collapsed: bool,
live_here: usize,
cx: &mut Context<Self>,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
let sf = cx.global::<presets::Surfaces>().window;
let owned_key = key.to_string();
let chevron = if collapsed {
IconName::ChevronRight
} else {
IconName::ChevronDown
};
h_flex()
.id(SharedString::from(format!("ssh-group-{key}")))
.items_center()
.gap_1()
.w_full()
.mt_2()
.py_1()
.px_1p5()
.rounded_md()
.cursor_pointer()
.text_xs()
.text_color(muted)
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _w, cx| {
cx.stop_propagation();
this.toggle_ssh_group(owned_key.clone(), cx);
}),
)
.child(Icon::new(chevron).size(px(10.)))
.child(div().truncate().child(ssh_group_label(key).to_string()))
.child(div().child(format!({count}")))
.child(div().flex_1())
.when(collapsed && live_here > 0, |row| {
row.child(
h_flex()
.items_center()
.gap_1()
.child(div().size(px(5.)).rounded_full().bg(cx.theme().success))
.child(div().child(live_here.to_string())),
)
})
.into_any_element()
}
/// One host row: status dot, name over `user@host:port`, and a hover `⋯`.
fn render_ssh_host_row(
&self,
p: &SshProfile,
selected: bool,
live: bool,
sf: presets::Surface,
cx: &mut Context<Self>,
) -> AnyElement {
let id = p.id;
let row_idx = id.as_u128() as usize;
let subtitle = to_connect_string(p);
let title = if p.name.is_empty() {
subtitle.clone()
} else {
p.name.clone()
};
self.render_ssh_row(
SharedString::from(format!("ssh-profile-row-{row_idx}")),
title,
subtitle,
selected,
Some(live),
sf,
cx.listener(move |this, _, window, cx| {
if let Some(profile) = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == id)
.cloned()
{
this.ssh_form_load(&profile, window, cx);
}
}),
Some(id),
cx,
)
}
/// The shared shape of a master-list row (Defaults and every host). `dot`
/// is `None` for rows that can't be connected; `menu_for` adds the hover `⋯`
/// and right-click menu for a saved profile.
#[allow(clippy::too_many_arguments)]
fn render_ssh_row(
&self,
element_id: impl Into<gpui::ElementId>,
title: impl Into<SharedString>,
subtitle: impl Into<SharedString>,
selected: bool,
dot: Option<bool>,
sf: presets::Surface,
on_select: impl Fn(&gpui::MouseDownEvent, &mut Window, &mut App) + 'static,
menu_for: Option<Uuid>,
cx: &mut Context<Self>,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
let success = cx.theme().success;
let border = cx.theme().border;
let title: SharedString = title.into();
let group_name = SharedString::from(format!("ssh-row-group-{title}"));
let hover_group = group_name.clone();
let row = h_flex()
.id(element_id)
.group(group_name)
.items_center()
.gap_2()
.w_full()
.py_2()
.px_2()
.rounded_md()
// The window ladder, both channels — see issue #197: multiplying a
// soft grey by alpha is how a fill silently disappears.
.when(selected, |r| r.bg(gpui::rgb(sf.selected)))
.when(!selected, |r| r.hover(|s| s.bg(gpui::rgb(sf.hover))))
.on_mouse_down(MouseButton::Left, move |ev, window, cx| {
cx.stop_propagation();
on_select(ev, window, cx);
})
.when_some(dot, |row, live| {
row.child(
div()
.flex_shrink_0()
.size(px(6.))
.rounded_full()
.when(live, |d| d.bg(success))
// A hollow ring when idle, so the dot column reads as a
// status slot rather than appearing only for live hosts
// and shunting every other row's text left.
.when(!live, |d| d.border_1().border_color(border)),
)
})
.child(
v_flex()
.min_w_0()
.flex_1()
.gap_0p5()
.child(
div()
.text_sm()
.truncate()
// The label channel: a selected row steps up in colour
// and weight, so which row is loaded reads from the
// type and not from the fill alone.
.when(selected, |d| {
d.text_color(gpui::rgb(sf.text_selected))
.font_weight(FontWeight::MEDIUM)
})
.when(!selected, |d| d.text_color(gpui::rgb(sf.text_resting)))
.child(title),
)
.child(
div()
.text_xs()
.text_color(muted)
.truncate()
.child(subtitle.into()),
),
);
// `.context_menu()` wraps the row in a different element type, so the two
// cases can't be a `when_some` — they're branched into `AnyElement` here.
let Some(id) = menu_for else {
return row.into_any_element();
};
// Weak handles so the hover `⋯` dropdown and the right-click menu drive
// the same handlers.
let menu_app = cx.entity().downgrade();
let ctx_app = cx.entity().downgrade();
let row_idx = id.as_u128() as usize;
row.child(
// The wrapper swallows the mouse-down so opening the menu never also
// fires the row's select click.
div()
.flex_shrink_0()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.when(!selected, move |s| {
s.opacity(0.).group_hover(hover_group, |s| s.opacity(1.))
})
.child(
Button::new(("ssh-prof-menu", row_idx))
.icon(Icon::empty().path("stock/icons/ellipsis.svg"))
.ghost()
.small()
.dropdown_menu_with_anchor(
gpui::Anchor::TopRight,
move |menu, _window, cx| {
Self::ssh_profile_row_menu(menu, id, cx.theme().danger, &menu_app)
},
),
),
)
.context_menu(move |menu, _window, cx| {
Self::ssh_profile_row_menu(menu, id, cx.theme().danger, &ctx_app)
})
.into_any_element()
}
/// Saved profiles with a connected pane open right now, by id. Read off the
/// live panes rather than tracked separately, so it can't go stale.
fn live_ssh_profiles(&self, cx: &App) -> std::collections::HashSet<Uuid> {
use crate::daemon::protocol::SshPhase;
let mut live = std::collections::HashSet::new();
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let v = leaf.read(cx);
if !matches!(v.ssh_phase(), Some(SshPhase::Connected)) || v.terminal.exited {
continue;
}
if let Some(id) = v
.ssh_spec()
.and_then(|s| s.profile_id.clone())
.and_then(|id| Uuid::parse_str(&id).ok())
{
live.insert(id);
}
}
}
live
}
/// Point the detail pane at the global defaults, dropping any open edit form.
pub(crate) fn select_ssh_defaults(&mut self, cx: &mut Context<Self>) {
if let Some(s) = self.active_settings_mut() {
s.ssh_form = None;
s.ssh_detail = SshDetail::Defaults;
}
cx.notify();
}
/// Collapse / expand one group of the master list.
///
/// Collapsing the group that holds the current selection hands the detail
/// pane back to Defaults: otherwise the form stays open on a host whose row
/// is no longer anywhere on screen, and nothing on the page says which host
/// is being edited.
fn toggle_ssh_group(&mut self, key: String, cx: &mut Context<Self>) {
let selected_here = match self.active_settings().map(|s| s.ssh_detail) {
Some(SshDetail::Profile(id)) => cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == id)
.is_some_and(|p| ssh_group_key(p) == key),
_ => false,
};
let Some(s) = self.active_settings_mut() else {
return;
};
let collapsing = !s.ssh_collapsed_groups.remove(&key);
if collapsing {
s.ssh_collapsed_groups.insert(key);
if selected_here {
s.ssh_form = None;
s.ssh_detail = SshDetail::Defaults;
}
}
cx.notify();
}
/// The right (detail) pane: a selected profile's edit form, or — with nothing
/// selected — a "pick a profile" hint. The global security defaults render
/// below either state: tucked into the empty state alone they vanished the
/// moment a profile was selected, so they were easy to never discover.
fn render_ssh_detail(&self, cx: &mut Context<Self>) -> AnyElement {
let detail = self
.active_settings()
.map(|s| s.ssh_detail)
.unwrap_or(SshDetail::None);
match detail {
SshDetail::Defaults => self.render_ssh_defaults_detail(cx),
SshDetail::Profile(_)
if self.active_settings().is_some_and(|s| s.ssh_form.is_some()) =>
{
self.render_ssh_profile_form(cx)
}
// No selection (or a stale profile whose form is gone).
_ => self.render_ssh_empty_state(cx),
}
}
/// The detail pane with nothing selected: a quick-connect box, and — when
/// `~/.ssh/config` holds aliases tty7 hasn't linked — an offer to link them.
///
/// This replaces a one-line "select a profile to edit" hint that left the
/// widest column on the page doing nothing.
fn render_ssh_empty_state(&self, cx: &mut Context<Self>) -> AnyElement {
let muted = cx.theme().muted_foreground;
let Some(input) = self.active_settings().map(|s| s.ssh_quick_connect.clone()) else {
return div().into_any_element();
};
let target = input.read(cx).value().trim().to_string();
let parsed = crate::core::ssh_profile::parse_quick_connect(&target);
let saved = cx.global::<Config>().ssh_profiles.len();
// How many `~/.ssh/config` aliases aren't in the list yet. Read on render:
// the file is small, this section is not on a hot path, and a stale count
// would advertise work that is already done.
let unlinked = {
let known: std::collections::HashSet<String> = cx
.global::<Config>()
.ssh_profiles
.iter()
.map(|p| p.name.clone())
.collect();
crate::core::ssh_config::import_profiles()
.into_iter()
.filter(|i| !known.contains(&i.profile.name))
.map(|i| i.profile.name)
.collect::<Vec<_>>()
};
let heading = if saved == 0 {
"No hosts yet"
} else {
"Nothing selected"
};
let mut body = v_flex()
.gap_1()
.child(self.header_text(heading, cx))
.child(
div()
.text_sm()
.text_color(muted)
.child("Type an address to connect now — tty7 offers to save it afterwards."),
)
.child(
h_flex()
.mt_3()
.gap_2()
.child(div().w(px(320.)).child(Input::new(&input).small()))
.child(
Button::new("ssh-quick-connect")
.label("Connect")
.primary()
.small()
// Off until the box holds something that parses: a
// Connect that can only fail is worse than one that
// says it isn't ready.
.disabled(parsed.is_none())
.on_click(cx.listener(|this, _, window, cx| {
this.ssh_quick_connect_from_settings(window, cx)
})),
),
);
if !unlinked.is_empty() {
let n = unlinked.len();
let names = unlinked.join(", ");
body = body.child(
h_flex()
.mt_6()
.gap_3()
.items_center()
.w_full()
.max_w(px(460.))
.p_3()
.rounded_lg()
.border_1()
.border_color(cx.theme().border)
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.child(format!("{n} more in ~/.ssh/config")),
)
.child(div().text_xs().text_color(muted).truncate().child(names)),
)
.child(
Button::new("ssh-empty-import")
.label("Link")
.small()
.on_click(
cx.listener(|this, _, _w, cx| this.import_ssh_config_profiles(cx)),
),
),
);
}
// No extra top padding: the detail pane already clears the title bar, and
// the heading here has to land on the same baseline as `Hosts` beside it.
body.into_any_element()
}
/// Connect the empty state's quick-connect target, closing Settings first so
/// the new session is what's on screen.
fn ssh_quick_connect_from_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(target) = self
.active_settings()
.map(|s| s.ssh_quick_connect.read(cx).value().trim().to_string())
else {
return;
};
let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) else {
return;
};
self.close_settings(window, cx);
self.quick_connect(qc, window, cx);
}
/// The `Defaults` row's detail: what every host inherits, plus the state of
/// the `~/.ssh/config` link. Its own page rather than a block pinned under
/// the profile form, where it read as part of whichever host was open.
fn render_ssh_defaults_detail(&self, cx: &mut Context<Self>) -> AnyElement {
let muted = cx.theme().muted_foreground;
let imported = cx
.global::<Config>()
.ssh_profiles
.iter()
.filter(|p| p.group.as_deref() == Some(crate::core::ssh_config::IMPORTED_GROUP))
.count();
let config_block = v_flex()
.child(self.section_intro(
"~/.ssh/config",
match imported {
0 => "No aliases linked yet.".to_string(),
1 => "1 alias linked.".to_string(),
n => format!("{n} aliases linked."),
},
cx,
))
.child(
self.settings_row(
"Import aliases",
"Re-reads the file and adds anything new. Edits you make here are \
stored by tty7 — the file itself is never written.",
Button::new("ssh-defaults-import")
.label("Import now")
.small()
.on_click(
cx.listener(|this, _, _w, cx| this.import_ssh_config_profiles(cx)),
)
.into_any_element(),
cx,
),
);
v_flex()
.child(
v_flex()
.gap_1()
.mb_6()
.child(self.header_text("Defaults", cx))
.child(div().text_sm().text_color(muted).child(
"Every host starts from these. Any host can override one under \
its own Advanced.",
)),
)
.child(self.render_ssh_security_block(cx))
.child(self.section_rule(cx))
.child(config_block)
.into_any_element()
}
/// Build the per-profile overflow menu shared by the hover ⋯ dropdown and the
/// row's right-click context menu: Connect, Copy address, Duplicate, then the
/// destructive Delete — rendered last, set apart by a separator and drawn in
/// danger red. Each item drives the same `Tty7App` handler the old inline
/// buttons did, via the weak `app` handle.
fn ssh_profile_row_menu(
menu: PopupMenu,
id: Uuid,
danger: gpui::Hsla,
app: &gpui::WeakEntity<Self>,
) -> PopupMenu {
let menu = menu
.min_w(px(180.))
.item(PopupMenuItem::new("Connect").on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.close_settings(window, cx);
this.connect_ssh_profile(id, window, cx);
});
}
}))
.item(PopupMenuItem::new("Copy address").on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.copy_profile_connect_string(id, cx));
}
}))
.item(PopupMenuItem::new("Duplicate").on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.duplicate_profile(id, window, cx));
}
}))
.item(PopupMenuItem::new("Forget password").on_click({
let app = app.clone();
move |_, window, cx| {
if let Some(msg) = app
.update(cx, |this, cx| this.forget_profile_password(id, cx))
.ok()
.flatten()
{
window.push_notification(msg, cx);
}
}
}))
.separator();
// Destructive, last, in danger red and set apart by the separator above.
menu.item(
PopupMenuItem::element(move |_window, _cx| div().text_color(danger).child("Delete"))
.on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.delete_profile(id, cx));
}
}),
)
}
/// Security block: the global host-key verification default and warn-on-close
/// toggle (both overridable per profile). Always visible in the detail pane,
/// under the form or the empty-state hint.
fn render_ssh_security_block(&self, cx: &mut Context<Self>) -> AnyElement {
let verify = cx.global::<Config>().verify_host_keys;
let verify_switch = crate::ui::theme::switch("ssh-verify-host-keys", cx)
.checked(verify)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_verify_host_keys(*on, cx)))
.into_any_element();
let warn_on_close = cx.global::<Config>().ssh_warn_on_close;
let warn_switch = crate::ui::theme::switch("ssh-warn-on-close", cx)
.checked(warn_on_close)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_warn_on_close(*on, cx)))
.into_any_element();
v_flex()
.child(self.section_intro(
"Security",
"A host can override either of these under its own Advanced.",
cx,
))
.child(self.settings_row(
"Verify host keys",
"Check each server's key against known_hosts and confirm unknown or \
changed keys before connecting. Off connects without checking, so a \
spoofed server would go unnoticed.",
verify_switch,
cx,
))
.child(self.settings_row(
"Warn before closing",
"Ask for confirmation before closing a tab or pane with a live SSH \
session.",
warn_switch,
cx,
))
.into_any_element()
}
// ── SSH profile edit form (folded into Settings → SSH) ───────────────────
/// The open SSH edit form, mutably (for section toggles / auth / switches).
fn ssh_form_mut(&mut self) -> Option<&mut SshProfileForm> {
self.active_settings_mut().and_then(|s| s.ssh_form.as_mut())
}
/// Build the edit-form inputs seeded from `profile` and open the form. A fresh
/// input set each call (the old set drops with the previous form), so the SSH
/// section never carries every profile's inputs at once.
pub(crate) fn ssh_form_load(
&mut self,
profile: &SshProfile,
window: &mut Window,
cx: &mut Context<Self>,
) {
let jump_name = profile
.jump_host
.and_then(|id| {
cx.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == id)
.map(|p| p.name.clone())
})
.unwrap_or_default();
let name = seed_input(window, cx, &profile.name, false);
let host = seed_input(window, cx, &profile.host, false);
let port = seed_input(window, cx, &profile.port.to_string(), false);
let user = seed_input(window, cx, &profile.user, false);
let jump = seed_input(window, cx, &jump_name, false);
let forwards: Vec<ForwardRuleForm> = profile
.forwards
.iter()
.map(|r| seed_forward_row(window, cx, r))
.collect();
let identity_files = seed_input(window, cx, &profile.identity_files.join("\n"), true);
let proxy_command = seed_input(
window,
cx,
profile.proxy_command.as_deref().unwrap_or(""),
false,
);
let socks = seed_input(window, cx, &host_port_text(&profile.socks_proxy), false);
let http = seed_input(window, cx, &host_port_text(&profile.http_proxy), false);
let kex = seed_input(window, cx, &profile.algorithms.kex.join(", "), false);
let cipher = seed_input(window, cx, &profile.algorithms.cipher.join(", "), false);
let mac = seed_input(window, cx, &profile.algorithms.mac.join(", "), false);
let hostkey = seed_input(window, cx, &profile.algorithms.hostkey.join(", "), false);
let compression = seed_input(
window,
cx,
&profile.algorithms.compression.join(", "),
false,
);
let keepalive_interval = seed_input(
window,
cx,
&profile
.keepalive_interval_s
.map(|n| n.to_string())
.unwrap_or_default(),
false,
);
let keepalive_count = seed_input(
window,
cx,
&profile
.keepalive_count_max
.map(|n| n.to_string())
.unwrap_or_default(),
false,
);
let connect_timeout = seed_input(
window,
cx,
&profile
.connect_timeout_s
.map(|n| n.to_string())
.unwrap_or_default(),
false,
);
let login_scripts = seed_input(window, cx, &profile.login_scripts.join("\n"), true);
// Every input in the form is subscribed, not just the few whose values
// are echoed elsewhere: the header's Save button is enabled by comparing
// the whole form against the saved profile, so any field going stale
// would leave Save claiming there is nothing to write.
let mut subs = Vec::new();
let mut watch = vec![
&name,
&host,
&port,
&user,
&jump,
&identity_files,
&proxy_command,
&socks,
&http,
&kex,
&cipher,
&mac,
&hostkey,
&compression,
&keepalive_interval,
&keepalive_count,
&connect_timeout,
&login_scripts,
];
for row in &forwards {
watch.extend(forward_row_inputs(row));
}
for input in watch {
subs.push(
cx.subscribe_in(input, window, |_this, _i, ev: &InputEvent, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
}),
);
}
let form = SshProfileForm {
editing: profile.id,
carry_group: profile.group.clone(),
carry_credential_ref: profile.credential_ref.clone(),
show_jump: profile.jump_host.is_some(),
show_forwards: !profile.forwards.is_empty(),
show_advanced: false,
name,
host,
port,
user,
auth: profile.auth,
jump,
forwards,
identity_files,
proxy_command,
socks,
http,
kex,
cipher,
mac,
hostkey,
compression,
keepalive_interval,
keepalive_count,
connect_timeout,
login_scripts,
agent_forward: profile.agent_forward,
x11: profile.x11,
skip_banner: profile.skip_banner,
shell_integration: profile.shell_integration,
verify_host_keys: profile.verify_host_keys,
warn_on_close: profile.warn_on_close,
_subs: subs,
};
let editing = form.editing;
if let Some(s) = self.active_settings_mut() {
s.ssh_form = Some(form);
// Loading a form selects that profile in the master-detail layout, so
// its row highlights and the detail pane shows the form.
s.ssh_detail = SshDetail::Profile(editing);
}
cx.notify();
}
/// Read the edit form back into an [`SshProfile`], preserving the id and the
/// carried-over group / credential_ref.
fn ssh_form_collect(&self, cx: &App) -> Option<SshProfile> {
let form = self.active_settings()?.ssh_form.as_ref()?;
let id = form.editing;
let val = |e: &Entity<InputState>| e.read(cx).value().trim().to_string();
let jump_name = val(&form.jump);
let jump_host = if jump_name.is_empty() {
None
} else {
cx.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.name == jump_name && p.id != id)
.map(|p| p.id)
};
Some(SshProfile {
id,
name: val(&form.name),
group: form.carry_group.clone(),
host: val(&form.host),
port: val(&form.port).parse().unwrap_or(22),
user: val(&form.user),
jump_host,
proxy_command: (!val(&form.proxy_command).is_empty()).then(|| val(&form.proxy_command)),
socks_proxy: parse_host_port(&val(&form.socks)),
http_proxy: parse_host_port(&val(&form.http)),
auth: form.auth,
identity_files: split_lines(&form.identity_files.read(cx).value()),
agent_forward: form.agent_forward,
credential_ref: form.carry_credential_ref.clone(),
forwards: form.forwards.iter().filter_map(|r| r.collect(cx)).collect(),
keepalive_interval_s: val(&form.keepalive_interval).parse().ok(),
keepalive_count_max: val(&form.keepalive_count).parse().ok(),
connect_timeout_s: val(&form.connect_timeout).parse().ok(),
warn_on_close: form.warn_on_close,
skip_banner: form.skip_banner,
shell_integration: form.shell_integration,
login_scripts: split_lines(&form.login_scripts.read(cx).value()),
x11: form.x11,
algorithms: Algorithms {
kex: split_list(&form.kex.read(cx).value()),
cipher: split_list(&form.cipher.read(cx).value()),
mac: split_list(&form.mac.read(cx).value()),
hostkey: split_list(&form.hostkey.read(cx).value()),
compression: split_list(&form.compression.read(cx).value()),
},
verify_host_keys: form.verify_host_keys,
})
}
/// Save the edit form into `Config::ssh_profiles` (upsert by id).
pub(crate) fn save_editing_profile(&mut self, cx: &mut Context<Self>) -> Option<Uuid> {
let profile = self.ssh_form_collect(cx)?;
let id = profile.id;
self.update_config(cx, |cfg| {
if let Some(slot) = cfg.ssh_profiles.iter_mut().find(|p| p.id == id) {
*slot = profile;
} else {
cfg.ssh_profiles.push(profile);
}
});
Some(id)
}
/// Save the form, leaving it open on the same host.
///
/// It used to close back to an empty pane. With the host list permanently
/// beside the form that reads as the selection being thrown away; staying
/// put also lets the now-disabled Save double as the "saved" acknowledgement.
pub(crate) fn save_ssh_form(&mut self, cx: &mut Context<Self>) {
self.save_editing_profile(cx);
cx.notify();
}
/// Save the current form, then close Settings and connect the saved profile.
pub(crate) fn save_and_connect_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(id) = self.save_editing_profile(cx) {
self.close_settings(window, cx);
self.connect_ssh_profile(id, window, cx);
}
}
/// Add a fresh blank profile and open it in the edit form.
pub(crate) fn add_new_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let profile = SshProfile::new(String::new());
self.ssh_form_load(&profile, window, cx);
}
/// Duplicate a saved profile (new id, "… (copy)" name) and edit the copy.
pub(crate) fn duplicate_profile(
&mut self,
id: Uuid,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(mut profile) = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == id)
.cloned()
else {
return;
};
profile.id = Uuid::new_v4();
profile.name = format!("{} (copy)", profile.name);
self.update_config(cx, |cfg| cfg.ssh_profiles.push(profile.clone()));
self.ssh_form_load(&profile, window, cx);
}
/// Delete a saved profile and its frecency entry.
pub(crate) fn delete_profile(&mut self, id: Uuid, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| {
cfg.ssh_profiles.retain(|p| p.id != id);
cfg.ssh_profile_frecency.remove(&id);
});
let editing_deleted =
self.active_settings().map(|s| s.ssh_detail) == Some(SshDetail::Profile(id));
if let Some(s) = self.active_settings_mut().filter(|_| editing_deleted) {
// The deleted profile was selected: drop its form and clear the
// selection back to the empty state.
s.ssh_form = None;
s.ssh_detail = SshDetail::None;
}
cx.notify();
}
/// Import `~/.ssh/config` aliases as profiles (idempotent upsert by name).
pub(crate) fn import_ssh_config_profiles(&mut self, cx: &mut Context<Self>) {
let imported = crate::core::ssh_config::import_profiles();
if imported.is_empty() {
return;
}
self.update_config(cx, |cfg| {
crate::core::ssh_config::merge_imported(&mut cfg.ssh_profiles, imported);
});
cx.notify();
}
/// Copy a saved profile's `user@host:port` to the clipboard (FR-P5).
pub(crate) fn copy_profile_connect_string(&mut self, id: Uuid, cx: &mut Context<Self>) {
if let Some(profile) = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == id)
{
let s = to_connect_string(profile);
cx.write_to_clipboard(gpui::ClipboardItem::new_string(s));
}
}
/// Remove any keychain-stored password for this profile's endpoint
/// (`user@host:port`). The profile itself is untouched — the next connect will
/// prompt again. A no-op if nothing was stored. Returns a status line for the
/// caller to surface as a notification. Credentials are keyed by endpoint, not
/// profile, so this only matches when the profile pins an explicit user.
pub(crate) fn forget_profile_password(
&mut self,
id: Uuid,
cx: &mut Context<Self>,
) -> Option<String> {
use crate::core::keychain::{CredentialStore, OsCredentialStore};
let (user, host, port) = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == id)
.map(|p| (p.user.clone(), p.host.clone(), p.port))?;
let endpoint = format!("{user}@{host}:{port}");
Some(
match OsCredentialStore.delete_password(&user, &host, port) {
Ok(()) => format!("Forgot saved password for {endpoint}"),
Err(e) => format!("Couldn't forget password for {endpoint}: {e}"),
},
)
}
/// The inline edit form: four core fields + collapsible jump / forwards /
/// advanced, rendered below the profile list for the selected profile.
fn render_ssh_profile_form(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else {
return div().into_any_element();
};
let editing = form.editing;
let muted = cx.theme().muted_foreground;
let success = cx.theme().success;
// The header identifies the host and offers the one action this page
// exists for. It used to say "Edit profile" beside a Back — a title
// that named the *screen*, on a screen whose subject is a machine.
let saved = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == editing)
.cloned();
let collected = self.ssh_form_collect(cx);
// A never-saved profile is always dirty; otherwise compare field by field
// so Save reads as "there is something to save".
let dirty = collected != saved;
let address = collected
.as_ref()
.map(to_connect_string)
.unwrap_or_default();
let jump_name = form.jump.read(cx).value().trim().to_string();
let live = self.live_ssh_profiles(cx).contains(&editing);
let name = form.name.read(cx).value().trim().to_string();
let host = form.host.read(cx).value().trim().to_string();
let title = match (name.is_empty(), host.is_empty()) {
(false, _) => name,
(true, false) => host,
(true, true) => "New host".to_string(),
};
let auth_idx = match form.auth {
AuthMode::Auto => 0,
AuthMode::Gssapi => 1,
AuthMode::Password => 2,
AuthMode::PublicKey => 3,
AuthMode::Agent => 4,
AuthMode::KeyboardInteractive => 5,
};
let header = h_flex()
.items_start()
.justify_between()
.gap_4()
.child(
v_flex()
.min_w_0()
.flex_1()
.gap_1()
.child(
div()
.text_lg()
.font_weight(FontWeight::SEMIBOLD)
.truncate()
.child(title),
)
.child(
h_flex()
.gap_1p5()
.text_xs()
.text_color(muted)
.child(div().truncate().child(address))
.when(!jump_name.is_empty(), |r| {
r.child(div().child(format!("· via {jump_name}")))
})
.when(live, |r| {
r.child(div().text_color(success).child("· connected"))
}),
),
)
.child(
h_flex()
.flex_shrink_0()
.gap_2()
.child(
Button::new("ssh-form-save")
.label("Save")
.small()
// Off with nothing to write: the button is the page's
// unsaved-changes indicator, so it has to be honest.
.disabled(!dirty)
.on_click(cx.listener(|this, _, _w, cx| this.save_ssh_form(cx))),
)
.child(
// The one action this page exists for, so it carries the
// solid fill — unlike the master column's Add, which sits
// over a list and would shout.
Button::new("ssh-form-connect")
.label("Connect")
.primary()
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.save_and_connect_profile(window, cx)
})),
),
);
let core = v_flex()
.gap_3()
.child(
self.settings_row(
"Name",
"A label for this connection.",
// Explicit widths on every text control: `settings_row` right-aligns
// the control in a shrink-to-fit slot, so a bare Input has no
// definite width to fill. 260px matches the Shell section's inputs.
div()
.w(px(260.))
.child(Input::new(&form.name).small())
.into_any_element(),
cx,
),
)
.child(
self.settings_row(
"Host",
"Hostname or IP address.",
// Host + port split the shared 260px control width.
h_flex()
.gap_2()
.child(div().w(px(172.)).child(Input::new(&form.host).small()))
.child(div().w(px(80.)).child(Input::new(&form.port).small()))
.into_any_element(),
cx,
),
)
.child(
self.settings_row(
"User",
"Login user (blank = resolve at connect).",
div()
.w(px(260.))
.child(Input::new(&form.user).small())
.into_any_element(),
cx,
),
)
.child(self.settings_row(
"Auth",
"Authentication method. Auto tries every applicable method.",
self.segmented(
"ssh-form-auth",
&["Auto", "GSSAPI", "Password", "Key", "Agent", "2FA"],
auth_idx,
cx,
|this, ix, _w, cx| {
if let Some(f) = this.ssh_form_mut() {
f.auth = match ix {
0 => AuthMode::Auto,
1 => AuthMode::Gssapi,
2 => AuthMode::Password,
3 => AuthMode::PublicKey,
4 => AuthMode::Agent,
_ => AuthMode::KeyboardInteractive,
};
cx.notify();
}
},
),
cx,
));
v_flex()
.gap_4()
.child(header)
.child(core)
.child(self.render_ssh_profile_jump_section(form, cx))
.child(self.render_ssh_profile_forwards_section(form, cx))
.child(self.render_ssh_profile_advanced_section(form, cx))
.into_any_element()
}
/// A collapsible section header (▸/▾ label + summary), toggling `open`.
fn disclosure_header(
&self,
id: &'static str,
label: &str,
summary: &str,
open: bool,
cx: &mut Context<Self>,
on_toggle: impl Fn(&mut Self, &mut Context<Self>) + 'static,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
let caret = if open { "▾" } else { "▸" };
h_flex()
.id(id)
.items_center()
.gap_2()
.py_2()
.cursor_pointer()
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _w, cx| on_toggle(this, cx)),
)
.child(div().text_color(muted).child(caret.to_string()))
.child(
div()
.font_weight(gpui::FontWeight::MEDIUM)
.child(label.to_string()),
)
.child(div().text_xs().text_color(muted).child(summary.to_string()))
.into_any_element()
}
fn render_ssh_profile_jump_section(
&self,
form: &SshProfileForm,
cx: &mut Context<Self>,
) -> AnyElement {
let summary = {
let name = form.jump.read(cx).value().trim().to_string();
if name.is_empty() {
"(none)".to_string()
} else {
name
}
};
let mut section = v_flex().child(self.disclosure_header(
"ssh-sec-jump",
"Jump host",
&summary,
form.show_jump,
cx,
|this, cx| {
if let Some(f) = this.ssh_form_mut() {
f.show_jump = !f.show_jump;
cx.notify();
}
},
));
if form.show_jump {
section = section.child(
self.settings_row(
"Jump host",
"Name of another profile to tunnel through (blank = direct).",
div()
.w(px(260.))
.child(Input::new(&form.jump).small())
.into_any_element(),
cx,
),
);
}
section.into_any_element()
}
fn render_ssh_profile_forwards_section(
&self,
form: &SshProfileForm,
cx: &mut Context<Self>,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
let count = form
.forwards
.iter()
.filter(|r| r.collect(cx).is_some())
.count();
let summary = match count {
0 => "none".to_string(),
1 => "1 rule, opened with the session".to_string(),
n => format!("{n} rules, opened with the session"),
};
let mut section = v_flex().child(self.disclosure_header(
"ssh-sec-fwd",
"Port forwarding",
&summary,
form.show_forwards,
cx,
|this, cx| {
if let Some(f) = this.ssh_form_mut() {
f.show_forwards = !f.show_forwards;
cx.notify();
}
},
));
if !form.show_forwards {
return section.into_any_element();
}
for (idx, row) in form.forwards.iter().enumerate() {
section = section.child(self.render_forward_rule_row(idx, row, cx));
}
section
.child(
h_flex().pt_1p5().child(
Button::new("ssh-fwd-add")
.label("+ Add rule")
.ghost()
.small()
.on_click(
cx.listener(|this, _, window, cx| this.add_forward_rule(window, cx)),
),
),
)
.child(
// The direction letters carry the whole meaning of a rule, and
// `L`/`R` are the one pair people reliably mix up.
h_flex()
.gap_3()
.pt_1()
.text_xs()
.text_color(muted)
.child("L — a local port reaches the remote side")
.child("R — a remote port reaches this machine")
.child("D — dynamic SOCKS proxy"),
)
.into_any_element()
}
/// One forward rule: direction, listener, target, description, remove.
fn render_forward_rule_row(
&self,
idx: usize,
row: &ForwardRuleForm,
cx: &mut Context<Self>,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
let danger = cx.theme().danger;
// Dynamic listens locally and proxies wherever the client asks, so it has
// no fixed target. The boxes stay in place (dimmed) rather than
// disappearing, so switching direction doesn't reflow the row.
let needs_target = row.kind != ForwardKind::Dynamic;
let kind_idx = match row.kind {
ForwardKind::Local => 0,
ForwardKind::Remote => 1,
ForwardKind::Dynamic => 2,
};
// Filled in but not connectable — flagged here rather than dropped
// silently on save, which is what the old text box did.
let incomplete = row.collect(cx).is_none() && !row.is_blank(cx);
// `xsmall` rather than the sheet's usual `small`: five controls share this
// row, and 24px is the height the segmented track beside them is fixed at.
// The row reads as one compact table cell — that internal alignment beats
// matching the full-width single inputs in the rows above.
let endpoint = |host: &Entity<InputState>, port: &Entity<InputState>| {
h_flex()
.gap_1()
.items_center()
.child(div().w(px(104.)).child(Input::new(host).xsmall()))
.child(div().text_xs().text_color(muted).child(":"))
.child(div().w(px(58.)).child(Input::new(port).xsmall()))
};
v_flex()
.gap_0p5()
.py_1()
.child(
h_flex()
.gap_2()
.items_center()
.child(self.segmented(
format!("ssh-fwd-kind-{idx}"),
&["L", "R", "D"],
kind_idx,
cx,
move |this, ix, _w, cx| {
let kind = match ix {
1 => ForwardKind::Remote,
2 => ForwardKind::Dynamic,
_ => ForwardKind::Local,
};
if let Some(f) = this.ssh_form_mut()
&& let Some(r) = f.forwards.get_mut(idx)
{
r.kind = kind;
cx.notify();
}
},
))
.child(endpoint(&row.bind_host, &row.bind_port))
.child(div().text_xs().text_color(muted).child("→"))
.child(
div()
.opacity(if needs_target { 1.0 } else { 0.35 })
.child(endpoint(&row.target_host, &row.target_port)),
)
.child(
div()
.flex_1()
.min_w(px(80.))
.child(Input::new(&row.description).xsmall()),
)
.child(
Button::new(("ssh-fwd-remove", idx))
.icon(Icon::new(IconName::Close))
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, _w, cx| {
this.remove_forward_rule(idx, cx)
})),
),
)
.when(incomplete, |col| {
col.child(div().text_xs().text_color(danger).child(if needs_target {
"Needs a listen port and a target host:port — won't be saved."
} else {
"Needs a listen port — won't be saved."
}))
})
.into_any_element()
}
/// Append a blank forward rule to the open form and subscribe its inputs.
fn add_forward_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let row = seed_forward_row(window, cx, &ForwardRule::default());
let subs: Vec<_> = forward_row_inputs(&row)
.into_iter()
.map(|input| {
cx.subscribe_in(input, window, |_this, _i, ev: &InputEvent, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
})
})
.collect();
if let Some(f) = self.ssh_form_mut() {
f.forwards.push(row);
f._subs.extend(subs);
f.show_forwards = true;
}
cx.notify();
}
/// Drop one forward rule from the open form.
fn remove_forward_rule(&mut self, idx: usize, cx: &mut Context<Self>) {
if let Some(f) = self.ssh_form_mut()
&& idx < f.forwards.len()
{
f.forwards.remove(idx);
}
cx.notify();
}
fn render_ssh_profile_advanced_section(
&self,
form: &SshProfileForm,
cx: &mut Context<Self>,
) -> AnyElement {
let mut section = v_flex().child(self.disclosure_header(
"ssh-sec-adv",
"Advanced",
"algorithms / keepalive / proxies / X11 / login scripts",
form.show_advanced,
cx,
|this, cx| {
if let Some(f) = this.ssh_form_mut() {
f.show_advanced = !f.show_advanced;
cx.notify();
}
},
));
if !form.show_advanced {
return section.into_any_element();
}
let text_row = |this: &Self,
label: &str,
desc: &str,
input: &Entity<InputState>,
cx: &mut Context<Self>| {
this.settings_row(
label.to_string(),
desc.to_string(),
// Same explicit control width as the core fields above — a bare
// Input has nothing to fill in the row's right-aligned slot.
div()
.w(px(260.))
.child(Input::new(input).small())
.into_any_element(),
cx,
)
};
// Verify host keys / warn-on-close tri-states (Default / On / Off).
let on_off = |b: bool| if b { "on" } else { "off" };
let vhk_default = on_off(cx.global::<Config>().verify_host_keys);
let woc_default = on_off(cx.global::<Config>().ssh_warn_on_close);
let vhk_idx = match form.verify_host_keys {
None => 0,
Some(true) => 1,
Some(false) => 2,
};
let woc_idx = match form.warn_on_close {
None => 0,
Some(true) => 1,
Some(false) => 2,
};
section = section
.child(text_row(
self,
"Identity files",
"Private-key paths, one per line (%h/%r expand).",
&form.identity_files,
cx,
))
.child(
self.settings_row(
"Agent forwarding",
"Forward the local ssh-agent to the session.",
crate::ui::theme::switch("ssh-form-agent", cx)
.checked(form.agent_forward)
.on_click(cx.listener(|this, on: &bool, _w, cx| {
if let Some(f) = this.ssh_form_mut() {
f.agent_forward = *on;
cx.notify();
}
}))
.into_any_element(),
cx,
),
)
.child(text_row(
self,
"ProxyCommand",
"Transport command (%h/%p/%r substituted).",
&form.proxy_command,
cx,
))
.child(text_row(
self,
"SOCKS5 proxy",
"host:port (blank = none).",
&form.socks,
cx,
))
.child(text_row(
self,
"HTTP proxy",
"host:port (blank = none).",
&form.http,
cx,
))
.child(text_row(
self,
"KEX algorithms",
"Comma-separated (blank = library default).",
&form.kex,
cx,
))
.child(text_row(
self,
"Ciphers",
"Comma-separated (blank = default).",
&form.cipher,
cx,
))
.child(text_row(
self,
"MACs",
"Comma-separated (blank = default).",
&form.mac,
cx,
))
.child(text_row(
self,
"Host-key algorithms",
"Comma-separated (blank = default).",
&form.hostkey,
cx,
))
.child(text_row(
self,
"Compression",
"Comma-separated (blank = default).",
&form.compression,
cx,
))
.child(text_row(
self,
"Keepalive interval (s)",
"Blank = library default.",
&form.keepalive_interval,
cx,
))
.child(text_row(
self,
"Keepalive count max",
"Missed keepalives before dead.",
&form.keepalive_count,
cx,
))
.child(text_row(
self,
"Connect timeout (s)",
"Blank = library default.",
&form.connect_timeout,
cx,
))
.child(
self.settings_row(
"X11 forwarding",
"Request X11 forwarding (needs XQuartz on macOS).",
crate::ui::theme::switch("ssh-form-x11", cx)
.checked(form.x11)
.on_click(cx.listener(|this, on: &bool, _w, cx| {
if let Some(f) = this.ssh_form_mut() {
f.x11 = *on;
cx.notify();
}
}))
.into_any_element(),
cx,
),
)
.child(
self.settings_row(
"Shell integration",
"Let the remote shell report prompts, exit codes and directory.",
crate::ui::theme::switch("ssh-form-shell-integration", cx)
.checked(form.shell_integration)
.on_click(cx.listener(|this, on: &bool, _w, cx| {
if let Some(f) = this.ssh_form_mut() {
f.shell_integration = *on;
cx.notify();
}
}))
.into_any_element(),
cx,
),
)
.child(text_row(
self,
"Login scripts",
"Commands sent after the shell opens, one per line.",
&form.login_scripts,
cx,
))
.child(
self.settings_row(
"Skip banner",
"Suppress the server login banner.",
crate::ui::theme::switch("ssh-form-banner", cx)
.checked(form.skip_banner)
.on_click(cx.listener(|this, on: &bool, _w, cx| {
if let Some(f) = this.ssh_form_mut() {
f.skip_banner = *on;
cx.notify();
}
}))
.into_any_element(),
cx,
),
)
.child(self.settings_row(
"Verify host keys",
// Name the value `Default` actually resolves to. "Overrides the
// global setting" tells you a mechanism exists but not what it
// currently does, which is the only part worth reading here.
format!("Default follows Defaults, which is {vhk_default}."),
self.segmented(
"ssh-form-vhk",
&["Default", "On", "Off"],
vhk_idx,
cx,
|this, ix, _w, cx| {
if let Some(f) = this.ssh_form_mut() {
f.verify_host_keys = match ix {
1 => Some(true),
2 => Some(false),
_ => None,
};
cx.notify();
}
},
),
cx,
))
.child(self.settings_row(
"Warn before closing",
format!("Default follows Defaults, which is {woc_default}."),
self.segmented(
"ssh-form-woc",
&["Default", "On", "Off"],
woc_idx,
cx,
|this, ix, _w, cx| {
if let Some(f) = this.ssh_form_mut() {
f.warn_on_close = match ix {
1 => Some(true),
2 => Some(false),
_ => None,
};
cx.notify();
}
},
),
cx,
));
section.into_any_element()
}
/// The Shell group at the top of the Terminal section: the program tty7
/// launches in each new pane, its launch arguments, and where a fresh shell
/// starts. All apply to *newly spawned* panes/tabs — existing shells keep
/// running until closed. An empty program falls back to the platform default
/// (the login shell on Unix; PowerShell 7 when installed, else Windows
/// PowerShell, on Windows).
///
/// This used to be a section of its own, which left a three-row page and no
/// way for a user to guess whether a given knob was filed under "Terminal"
/// or under "Shell". The program a pane runs is a property of the terminal,
/// so it opens the Terminal page instead.
fn render_shell_group(&self, cx: &mut Context<Self>) -> AnyElement {
let muted_fg = cx.theme().muted_foreground;
let (program_input, args_input, wd_path_input) = match self.active_settings() {
Some(s) => (
s.shell_program_input.clone(),
s.shell_args_input.clone(),
s.wd_path_input.clone(),
),
None => return div().into_any_element(),
};
let wd_strategy = cx.global::<Config>().working_directory.strategy;
// Name what an empty Program field falls back to, so the default
// behaviour is legible without the user having to know it.
let platform_default = if cfg!(windows) {
"PowerShell"
} else {
"your login shell"
};
let program_control = div()
.w(px(260.))
.child(Input::new(&program_input).small())
.into_any_element();
let args_control = div()
.w(px(260.))
.child(Input::new(&args_input).small())
.into_any_element();
use crate::core::config::WdStrategy;
let wd_idx = match wd_strategy {
WdStrategy::Inherit => 0,
WdStrategy::Home => 1,
WdStrategy::Custom => 2,
};
let wd_radio = self.segmented(
"wd-strategy",
&["Inherit", "Home", "Custom"],
wd_idx,
cx,
|this, ix, _w, cx| {
let s = match ix {
0 => WdStrategy::Inherit,
1 => WdStrategy::Home,
_ => WdStrategy::Custom,
};
this.set_working_directory_strategy(s, cx);
},
);
// The custom path input only matters for `Custom`; show it there.
let wd_path_control = if wd_strategy == WdStrategy::Custom {
div()
.w(px(260.))
.child(Input::new(&wd_path_input).small())
.into_any_element()
} else {
div().into_any_element()
};
v_flex()
.child(self.section_intro(
"Shell",
format!(
"The program each new terminal launches. Leave Program empty to use the platform default ({platform_default})."
),
cx,
))
.child(self.settings_row(
"Program",
"Executable name on PATH or an absolute path. e.g. zsh, fish, pwsh.",
program_control,
cx,
))
.child(self.settings_row(
"Arguments",
"Space-separated launch flags. e.g. -l for a login shell.",
args_control,
cx,
))
.child(self.settings_row(
"Start in",
"What a fresh shell starts in: tty7's launch directory, your home folder, or a fixed path.",
wd_radio,
cx,
))
.when(wd_strategy == crate::core::config::WdStrategy::Custom, |v| {
v.child(self.settings_row(
"Custom path",
"The directory new shells start in.",
wd_path_control,
cx,
))
})
.child(
div()
.mt_3()
.text_xs()
.text_color(muted_fg)
.child("Applies to shells with nothing to inherit — like the first tab of a window. New tabs and splits keep inheriting the active pane's directory, and shells already open keep running."),
)
.into_any_element()
}
/// Terminal section: what a pane runs and how the terminal surface itself
/// behaves — the shell, scrolling, the mouse, the bell, links. Plain
/// switches and segmented controls driven straight off the `Config` global
/// (each control's handler mutates + saves it). Small groups on purpose:
/// each header names exactly what it contains, so it doubles as the landmark
/// you scan for.
///
/// Typing, selection and the clipboard used to live down here too, under
/// four more headers; they moved to their own Input section, which is both
/// findable by name and short enough to read in one screen.
fn render_settings_terminal(&self, cx: &mut Context<Self>) -> AnyElement {
let foreground = cx.theme().foreground;
let cfg = cx.global::<Config>();
let link_url = cfg.link_url;
let ssh_loopback_forward = cfg.ssh_loopback_forward;
let mouse_hide = cfg.mouse_hide_while_typing;
let focus_follows = cfg.focus_follows_mouse;
let scroll_mult = cfg.mouse_scroll_multiplier;
let mouse_reporting = cfg.mouse_reporting;
let bell = cfg.bell;
// Map the persisted scrollback depth onto its preset radio index (default
// to 10k's slot for any off-preset value a hand-edit might leave).
let scrollback_idx = match cfg.scrollback_limit {
n if n <= 1_000 => 0,
n if n <= 10_000 => 1,
_ => 2,
};
let scroll_slider = match self.active_settings() {
Some(s) => s.scroll_slider.clone(),
None => return div().into_any_element(),
};
let link_file_command_input = match self.active_settings() {
Some(s) => s.link_file_command_input.clone(),
None => return div().into_any_element(),
};
let link_switch = crate::ui::theme::switch("term-link-url", cx)
.checked(link_url)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_link_url(*on, cx)))
.into_any_element();
let ssh_loopback_switch = crate::ui::theme::switch("term-ssh-loopback-forward", cx)
.checked(ssh_loopback_forward)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_loopback_forward(*on, cx)))
.into_any_element();
let link_file_command_control = div()
.w(px(300.))
.child(Input::new(&link_file_command_input).small())
.into_any_element();
let scrollback_radio = self.segmented(
"term-scrollback",
&["1,000", "10,000", "100,000"],
scrollback_idx,
cx,
|this, ix, _w, cx| {
let lines = match ix {
0 => 1_000,
1 => 10_000,
_ => 100_000,
};
this.set_scrollback_limit(lines, cx);
},
);
let focus_switch = crate::ui::theme::switch("term-focus-follows", cx)
.checked(focus_follows)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_focus_follows_mouse(*on, cx)))
.into_any_element();
let mouse_hide_switch = crate::ui::theme::switch("term-mouse-hide", cx)
.checked(mouse_hide)
.on_click(
cx.listener(|this, on: &bool, _w, cx| this.set_mouse_hide_while_typing(*on, cx)),
)
.into_any_element();
let mouse_report_switch = crate::ui::theme::switch("term-mouse-report", cx)
.checked(mouse_reporting)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx)))
.into_any_element();
let bell_idx = match bell {
BellMode::None => 0,
BellMode::Visual => 1,
BellMode::Audible => 2,
};
let bell_control = self.segmented(
"term-bell",
&["Off", "Visual", "Audible"],
bell_idx,
cx,
|this, ix, _w, cx| {
let mode = match ix {
0 => BellMode::None,
1 => BellMode::Visual,
_ => BellMode::Audible,
};
this.set_bell_mode(mode, cx);
},
);
// Slider + a live readout of the current multiplier beside it.
let scroll_control = h_flex()
.items_center()
.gap_3()
.w(px(240.))
.child(div().flex_1().child(Slider::new(&scroll_slider)))
.child(
div()
.w(px(36.))
.text_sm()
.text_color(foreground)
.child(format!("{scroll_mult:.2}×")),
)
.into_any_element();
v_flex()
.child(self.render_shell_group(cx))
.child(self.section_rule(cx))
.child(self.section_header("Scrolling", cx))
.child(self.settings_row(
"Scrollback",
"Lines of history kept per pane. Applies to new panes.",
scrollback_radio,
cx,
))
.child(self.settings_row(
"Scroll speed",
"Multiplier applied to mouse-wheel scrolling.",
scroll_control,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Mouse", cx))
.child(self.settings_row(
"Focus follows mouse",
"Hovering a pane focuses it without a click.",
focus_switch,
cx,
))
.child(self.settings_row(
"Hide mouse while typing",
"Hide the pointer as you type; it returns on the next move.",
mouse_hide_switch,
cx,
))
.child(self.settings_row(
"Report mouse to apps",
"Let full-screen apps (vim, tmux) handle clicks and scrolling; hold Shift to keep a gesture local.",
mouse_report_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Bell", cx))
.child(self.settings_row(
"Terminal bell",
"How a bell (^G) is signalled: silenced, a brief flash, or the system sound.",
bell_control,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Links", cx))
.child(self.settings_row(
"Detect URLs",
format!("Underline links on hover and open them on {LINK_MODIFIER_LABEL}-click."),
link_switch,
cx,
))
.child(self.settings_row(
"Forward SSH loopback links",
"When a pane is in SSH, open localhost links through a temporary port forward.",
ssh_loopback_switch,
cx,
))
.child(self.settings_row(
"Open files with",
format!(
"Command run when {LINK_MODIFIER_LABEL}-clicking a file link, instead of \
the default app. Use {{path}}, {{line}}, {{column}}; a flag whose value \
is absent is dropped (e.g. herdr edit {{path}} --line={{line}}). Empty \
uses the default app."
),
link_file_command_control,
cx,
))
.into_any_element()
}
/// Input section: everything about putting text *in* and taking text *out* —
/// the completion and history menus at the prompt, the Option/Meta split,
/// and how selection reaches the clipboard.
///
/// A section of its own because these are the settings that distinguish tty7
/// from a plain terminal, and they were previously the last four groups of a
/// seven-group Terminal page — findable only by scrolling past everything
/// else, and not findable by search at all (completion and history search
/// had no index entries).
fn render_settings_input(&self, cx: &mut Context<Self>) -> AnyElement {
let cfg = cx.global::<Config>();
let option_as_alt = cfg.macos_option_as_alt;
let tab_completion = cfg.tab_completion;
let history_search = cfg.history_search;
let smart_select = cfg.smart_select;
let copy_on_select = cfg.copy_on_select;
let clip_trim = cfg.clipboard_trim_trailing_spaces;
let tab_completion_switch = crate::ui::theme::switch("term-tab-completion", cx)
.checked(tab_completion)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx)))
.into_any_element();
let history_search_switch = crate::ui::theme::switch("term-history-search", cx)
.checked(history_search)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_history_search(*on, cx)))
.into_any_element();
let smart_select_switch = crate::ui::theme::switch("term-smart-select", cx)
.checked(smart_select)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx)))
.into_any_element();
let copy_on_select_switch = crate::ui::theme::switch("term-copy-on-select", cx)
.checked(copy_on_select)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_copy_on_select(*on, cx)))
.into_any_element();
let trim_switch = crate::ui::theme::switch("term-clip-trim", cx)
.checked(clip_trim)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx)))
.into_any_element();
// macOS only: the Option/special-character split this toggle resolves
// doesn't exist on other platforms, where Alt always carries Meta.
let option_alt_row = cfg!(target_os = "macos").then(|| {
let switch = crate::ui::theme::switch("term-option-as-alt", cx)
.checked(option_as_alt)
.on_click(
cx.listener(|this, on: &bool, _w, cx| this.set_macos_option_as_alt(*on, cx)),
)
.into_any_element();
self.settings_row(
"Option (⌥) acts as Meta",
"⌥+key sends the escape chord shells expect (⌥B = back one word) \
instead of typing a special character (∫).",
switch,
cx,
)
});
v_flex()
.child(self.section_intro(
"Prompt",
"tty7's own menus at the shell prompt. Turn one off to hand the key back to the shell.",
cx,
))
.child(self.settings_row(
"Tab completion",
"Tab at the prompt opens tty7's completion menu. When off, Tab goes to the \
shell's own completion instead.",
tab_completion_switch,
cx,
))
.child(self.settings_row(
"History search",
"⌃R at the prompt opens tty7's fuzzy history menu. When off, ⌃R goes to the \
shell instead — its own reverse-i-search, or whatever you've bound there \
(fzf, percol).",
history_search_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Selection & clipboard", cx))
.child(self.settings_row(
"Smart selection",
"Double-click selects the whole URL, file path, email, or bracket pair under the cursor.",
smart_select_switch,
cx,
))
.child(self.settings_row(
"Copy on select",
"Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed.",
copy_on_select_switch,
cx,
))
.child(self.settings_row(
"Trim trailing spaces on copy",
"Strip trailing whitespace from each copied line.",
trim_switch,
cx,
))
.when_some(option_alt_row, |v, row| {
v.child(self.section_rule(cx))
.child(self.section_header("Keyboard", cx))
.child(row)
})
.into_any_element()
}
/// Agents section: a machine picker, then one row per hook-capable agent on
/// that machine — install state + actions per row, copy kept terse.
///
/// The picker is first because everything under it is *about* the chosen
/// machine: the paths, the states, and what Install writes. An agent running
/// in a remote workspace's pane runs on the remote box and reads that box's
/// `~/.claude/settings.json`, so installing here and expecting status there
/// was the whole gap this page closes.
fn render_settings_agents(&self, cx: &mut Context<Self>) -> AnyElement {
use crate::core::agent_hooks::HooksState;
let theme = cx.theme();
let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground);
let (success, warning) = (theme.success, theme.warning);
let (view, note, selected_host) = match self.active_settings() {
Some(s) => (
s.agent_hooks_states.clone(),
s.agent_hooks_note.clone(),
s.agent_hooks_host,
),
None => (AgentHooksView::Loading, None, HostId::LOCAL),
};
let mut page = v_flex().child(self.section_intro(
"Agents",
"Hook integrations give panes running these agents live session status \
(working / waiting / done) in the tab bar. Only active inside tty7.",
cx,
));
page = page.children(self.agent_hooks_machine_picker(selected_host, cx));
match view {
// A spinner would be four agents' worth of motion for a read that is
// usually instant; the page just says what it is doing and keeps its
// shape, so nothing jumps when the rows arrive.
AgentHooksView::Loading => {
return page
.child(
div()
.py_4()
.text_sm()
.text_color(muted_fg)
.child("Reading this machine's agent config…"),
)
.into_any_element();
}
// §17: a resting state that says which hop gave up and what to do
// next, rather than rows that would silently write nowhere.
AgentHooksView::Unavailable(reason) => {
return page
.child(div().py_4().text_sm().text_color(warning).child(reason))
.into_any_element();
}
AgentHooksView::Ready(rows) => {
for (i, row) in rows.into_iter().enumerate() {
let agent = row.agent;
// Status: a colored dot + one word; the dot is the only color
// on the page, so state reads at a glance.
let (dot_color, status_text) = match row.state {
HooksState::NotInstalled => (muted_fg, "Not installed"),
HooksState::Installed => (success, "Installed"),
HooksState::Outdated => (warning, "Outdated"),
};
// The primary action reads as what it will *do* from this
// state.
let primary_label = match row.state {
HooksState::NotInstalled => "Install",
HooksState::Installed => "Reinstall",
HooksState::Outdated => "Update",
};
let row_note = note
.as_ref()
.filter(|(for_agent, _)| *for_agent == agent)
.map(|(_, text)| text.clone());
// items_end: the whole stack shares the row's right edge, so
// status, buttons, and note line up across every agent row.
let control = v_flex()
.gap_2()
.items_end()
.child(
h_flex()
.gap_2()
.items_center()
.child(div().size_2().rounded_full().bg(dot_color))
.child(div().text_sm().text_color(foreground).child(status_text)),
)
.child(
h_flex()
.gap_2()
.child(
Button::new(("agent-hooks-install", i))
.label(primary_label)
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
this.settings_install_agent_hooks(agent, cx)
})),
)
.when(row.state != HooksState::NotInstalled, |r| {
r.child(
Button::new(("agent-hooks-uninstall", i))
.label("Uninstall")
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
this.settings_uninstall_agent_hooks(agent, cx)
})),
)
}),
)
// Width-capped so a long note (error text) wraps instead
// of inflating the shrink-proof control column and
// crushing the label to zero width.
.when_some(row_note, |col, text| {
col.child(
div()
.max_w_80()
.text_xs()
.text_right()
.text_color(muted_fg)
.child(text),
)
})
.into_any_element();
page = page.child(self.settings_row(
agent.display_name(),
row.target,
control,
cx,
));
}
}
}
page.into_any_element()
}
/// The Agents section's machine picker: this computer plus every connected
/// remote, one chosen at a time.
///
/// `None` when this computer is the only machine there is — a picker with a
/// single choice is a control that asks a question with one answer, and the
/// page below it already says where the files go.
///
/// Hand-rolled rather than [`Self::segmented`] because the options are
/// machines, not a fixed `&'static [&'static str]` — but it reads off the
/// same interaction ladder, so it is the same control the rest of the sheet
/// speaks.
fn agent_hooks_machine_picker(&self, selected: HostId, cx: &mut Context<Self>) -> Option<Div> {
let sf = cx.global::<presets::Surfaces>().window;
let border = cx.theme().border;
let muted_fg = cx.theme().muted_foreground;
let machines = self.agent_hooks_machines(cx);
let offline = self.agent_hooks_offline_count(cx);
if machines.len() < 2 && offline == 0 {
return None;
}
Some(
v_flex()
.gap_2()
.mb_4()
.child(
h_flex()
.flex_wrap()
.gap_1p5()
.children(machines.into_iter().map(|machine| {
let active = machine.host == selected;
let host = machine.host;
h_flex()
.id(("agent-hooks-machine", host.0 as usize))
.h(px(24.))
.px_2p5()
.items_center()
.rounded_lg()
.border_1()
.border_color(border)
.bg(rgb(sf.base))
.text_sm()
.cursor_pointer()
// Both channels, every time: the fill locates the
// selection, the label colour and weight say it is
// the one — and keep saying it on a translucent
// window, where the fill washes over the desktop.
.when(active, |s| {
s.bg(rgb(sf.selected))
.text_color(rgb(sf.text_selected))
.font_weight(FontWeight::MEDIUM)
})
.when(!active, |s| {
s.text_color(rgb(sf.text_resting))
.hover(|h| h.bg(rgb(sf.hover)))
})
.active(|s| s.bg(rgb(sf.pressed)))
.child(machine.label)
.on_click(cx.listener(move |this, _, _w, cx| {
this.select_agent_hooks_host(host, cx)
}))
})),
)
// A saved machine that isn't connected is absent from the row above,
// and an absence explains nothing. Say the count and the next move
// rather than listing fifty `~/.ssh/config` aliases, most of which
// are git transports that could never host a workspace anyway.
.when(offline > 0, |col| {
col.child(div().text_xs().text_color(muted_fg).child(format!(
"{offline} more saved machine{} not connected — open a workspace on one to \
install its hooks there.",
if offline == 1 { " is" } else { "s are" }
)))
}),
)
}
/// Window & Tabs section: the app window's lifecycle and tab placement.
fn render_settings_window_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let cfg = cx.global::<Config>();
let startup_idx = match cfg.startup_mode {
crate::core::config::StartupMode::Normal => 0,
crate::core::config::StartupMode::Maximized => 1,
crate::core::config::StartupMode::Fullscreen => 2,
};
let new_tab_idx = match cfg.new_tab_position {
NewTabPosition::AfterCurrent => 0,
NewTabPosition::End => 1,
};
let restore_session = cfg.restore_session;
let remember_window_size = cfg.remember_window_size;
let show_tray_icon = cfg.show_tray_icon;
let confirm_window_close = cfg.confirm_window_close;
let tab_bar_idx = match cfg.tab_bar_position {
TabBarPosition::Top => 0,
TabBarPosition::Left => 1,
};
let sidebar_diff_preview = cfg.sidebar_diff_preview;
let sidebar_grouping_idx = match cfg.sidebar_grouping {
crate::core::config::SidebarGrouping::Repo => 0,
crate::core::config::SidebarGrouping::None => 1,
};
// Notifications are app-level, not terminal-level: the tray menu already
// exposed the same `NotifyMode` at the top of its own menu while the
// setting itself sat at the bottom of the Terminal page.
let notify_idx = match cfg.notify_on_command_finish {
NotifyMode::Never => 0,
NotifyMode::Unfocused => 1,
NotifyMode::Always => 2,
};
// Map the persisted threshold onto its preset radio index (nearest slot
// for any off-preset value a hand-edit might leave).
let threshold_idx = match cfg.notify_threshold_secs {
n if n <= 5 => 0,
n if n <= 10 => 1,
n if n <= 30 => 2,
_ => 3,
};
let notify_radio = self.segmented(
"wt-notify",
// Same order and casing as the tray's Notifications submenu, which
// writes this very setting — the two used to disagree on both.
&["Never", "When Unfocused", "Always"],
notify_idx,
cx,
|this, ix, _w, cx| {
let mode = match ix {
0 => NotifyMode::Never,
1 => NotifyMode::Unfocused,
_ => NotifyMode::Always,
};
this.set_notify_mode(mode, cx);
},
);
let threshold_radio = self.segmented(
"wt-notify-threshold",
&["5s", "10s", "30s", "1m"],
threshold_idx,
cx,
|this, ix, _w, cx| {
let secs = match ix {
0 => 5,
1 => 10,
2 => 30,
_ => 60,
};
this.set_notify_threshold(secs, cx);
},
);
let restore_switch = crate::ui::theme::switch("wt-restore-session", cx)
.checked(restore_session)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_restore_session(*on, cx)))
.into_any_element();
let remember_window_switch = crate::ui::theme::switch("wt-remember-window", cx)
.checked(remember_window_size)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx)))
.into_any_element();
let confirm_close_switch = crate::ui::theme::switch("wt-confirm-window-close", cx)
.checked(confirm_window_close)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_confirm_window_close(*on, cx)))
.into_any_element();
let tray_switch = crate::ui::theme::switch("wt-tray-icon", cx)
.checked(show_tray_icon)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_show_tray_icon(*on, cx)))
.into_any_element();
let startup_radio = self.segmented(
"wt-startup",
&["Normal", "Maximized", "Fullscreen"],
startup_idx,
cx,
|this, ix, _w, cx| {
let mode = match ix {
0 => crate::core::config::StartupMode::Normal,
1 => crate::core::config::StartupMode::Maximized,
_ => crate::core::config::StartupMode::Fullscreen,
};
this.set_startup_mode(mode, cx);
},
);
let new_tab_radio = self.segmented(
"wt-new-tab-pos",
&["After current", "At end"],
new_tab_idx,
cx,
|this, ix, _w, cx| {
let pos = if ix == 0 {
NewTabPosition::AfterCurrent
} else {
NewTabPosition::End
};
this.set_new_tab_position(pos, cx);
},
);
let tab_bar_radio = self.segmented(
"wt-tab-bar-pos",
&["Top", "Left"],
tab_bar_idx,
cx,
|this, ix, _w, cx| {
let pos = if ix == 0 {
TabBarPosition::Top
} else {
TabBarPosition::Left
};
this.set_tab_bar_position(pos, cx);
},
);
let sidebar_diff_switch = crate::ui::theme::switch("wt-sidebar-diff-preview", cx)
.checked(sidebar_diff_preview)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_sidebar_diff_preview(*on, cx)))
.into_any_element();
let sidebar_grouping_radio = self.segmented(
"wt-sidebar-grouping",
&["By repo", "Flat"],
sidebar_grouping_idx,
cx,
|this, ix, _w, cx| {
let grouping = if ix == 0 {
crate::core::config::SidebarGrouping::Repo
} else {
crate::core::config::SidebarGrouping::None
};
this.set_sidebar_grouping(grouping, cx);
},
);
v_flex()
.child(self.section_header("Window", cx))
.child(self.settings_row(
"Startup window",
"Window state when tty7 launches.",
startup_radio,
cx,
))
.child(self.settings_row(
"Remember window size & position",
"Reopen at the size and position the window had when tty7 last quit. Off opens centered at the default size.",
remember_window_switch,
cx,
))
// "Session" already means "a shell running in the background" all
// over this app; using it here for "the saved arrangement of tabs"
// made the one word mean two things on the same page. The thing
// being restored is the layout.
.child(self.settings_row(
"Restore last layout",
"Reopen the last window's tabs, splits, and directories on launch. Off starts with a single fresh terminal.",
restore_switch,
cx,
))
// Phrased around what stays true either way: the prompt is there to
// teach that closing isn't ending, so the row that turns it off is
// the last chance to say so.
.child(self.settings_row(
"Confirm before closing the last window",
"Ask first, since that close also quits tty7. Off closes straight away — \
either way your shells keep running in the background.",
confirm_close_switch,
cx,
))
.child(self.settings_row(
"Show tray icon",
"Keep a status item in the system tray / menu bar: it signals when a \
coding agent needs your input, and its menu jumps to agent panes.",
tray_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Tabs", cx))
.child(self.settings_row(
"New tab position",
"Where a freshly opened tab is inserted.",
new_tab_radio,
cx,
))
.child(self.settings_row(
"Tab bar position",
"Show tabs as a horizontal strip on top or a vertical sidebar on the left.",
tab_bar_radio,
cx,
))
.child(self.settings_row(
"Sidebar grouping",
"Group sidebar tabs under a header per git repository, with non-repo tabs \
in a Scratch section. Only applies to the left sidebar.",
sidebar_grouping_radio,
cx,
))
// Phrased around what *stays*: the worry this row answers is "will
// turning it off cost me the branch and the numbers", and the
// answer is no — only the click goes.
.child(self.settings_row(
"Open diff preview from sidebar counts",
"Click a row's +N N to open the working-tree diff in an overlay. Off keeps the \
branch and the counts on the row and just stops them being clickable.",
sidebar_diff_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Notifications", cx))
.child(self.settings_row(
"Notify on command finish",
"Desktop alert after a long foreground command completes.",
notify_radio,
cx,
))
.child(self.settings_row(
"Notify threshold",
"How long a command must run to qualify as \"long\".",
threshold_radio,
cx,
))
.into_any_element()
}
/// Theme gallery: one clickable card per theme (built-ins + user files), each
/// a mini-terminal preview painted in its own colors. The selected card gets a
/// soft ring + a check; clicking switches the active theme live via
/// `set_preset`.
/// The mini terminal preview for a theme: thin "lines of code" bars in the
/// theme's own colors over its background. Fills its container's width, so a
/// narrow "Current theme" card and the wider picker panel reuse one shape.
fn theme_preview(&self, p: &presets::Theme) -> Div {
let to_u32 = |(r, g, b): (u8, u8, u8)| (r as u32) << 16 | (g as u32) << 8 | b as u32;
let accent = rgb(p.accent);
let ansi = |i: usize| rgb(to_u32(p.ansi16[i]));
let fg = rgb(p.foreground);
// A "line of code": thin rounded bars whose widths are *fractions* of the
// preview, so the same shape reads well in the narrow "Current theme" card
// and the wider picker instead of clustering at the left edge. Rows stay
// ragged-right like real terminal text.
let bar = |frac: f32, color: gpui::Rgba| {
div().h(px(4.)).w(relative(frac)).rounded(px(1.5)).bg(color)
};
v_flex()
.w_full()
.bg(rgb(p.background_color()))
.rounded(px(8.))
.overflow_hidden()
.px_3()
.py_3()
.gap(px(10.))
.child(
h_flex()
.items_center()
.gap_2()
.child(div().text_size(px(11.)).text_color(accent).child(""))
.child(bar(0.5, fg)),
)
.child(
h_flex()
.gap_2()
.child(bar(0.2, ansi(2)))
.child(bar(0.36, ansi(4)))
.child(bar(0.12, ansi(3))),
)
.child(
h_flex()
.gap_2()
.child(bar(0.14, ansi(1)))
.child(bar(0.44, fg)),
)
.child(
h_flex()
.gap_2()
.child(bar(0.1, ansi(6)))
.child(bar(0.32, accent)),
)
}
/// The theme choice block on the Appearance page: the "Sync with system"
/// switch, then either the single manual-theme card or — while following
/// the OS — one card per light/dark slot.
fn render_theme_selection(&self, cx: &mut Context<Self>) -> AnyElement {
let follow = cx.global::<Config>().theme_follow_system;
let follow_switch = crate::ui::theme::switch("theme-follow-system", cx)
.checked(follow)
.on_click(cx.listener(|this, on: &bool, window, cx| {
this.set_theme_follow_system(*on, window, cx)
}))
.into_any_element();
let root = v_flex().child(self.settings_row(
"Sync with system",
"Follow the OS appearance with separate light and dark themes.",
follow_switch,
cx,
));
if follow {
root.child(self.render_theme_card(ThemeSlot::Light, cx))
.child(self.render_theme_card(ThemeSlot::Dark, cx))
.into_any_element()
} else {
root.child(self.render_theme_card(ThemeSlot::Manual, cx))
.into_any_element()
}
}
/// One compact theme card: a preview of the slot's theme beside its caption
/// (kind + light/dark mode for the manual card, the slot's role for the
/// follow-system cards), its name, and its six chromatic ANSI swatches; the
/// whole row a click target that opens the picker panel on the right,
/// aimed at this slot.
fn render_theme_card(&self, slot: ThemeSlot, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let border = theme.border;
let foreground = theme.foreground;
let muted_fg = theme.muted_foreground;
let hover_bg = gpui::rgb(cx.global::<presets::Surfaces>().window.hover);
let surface = theme.secondary.opacity(0.28);
let config = cx.global::<Config>();
let (card_id, active_id) = match slot {
ThemeSlot::Manual => ("theme-card-manual", config.theme_preset.clone()),
ThemeSlot::Light => ("theme-card-light", config.theme_preset_light.clone()),
ThemeSlot::Dark => ("theme-card-dark", config.theme_preset_dark.clone()),
};
let active = presets::by_id(cx, &active_id);
let name = active.name.clone();
// A user file (duplicated or dropped in the themes folder) vs a built-in.
let kind = if active.path.is_some() {
"Custom"
} else {
"Built-in"
};
let caption = match slot {
ThemeSlot::Manual => {
let mode = if active.dark { "Dark" } else { "Light" };
format!("{kind} · {mode}")
}
// The slot cards are captioned by their role; the one matching the
// current OS appearance is the theme actually on screen.
ThemeSlot::Light if !crate::ui::theme::system_dark(cx) => {
format!("Light mode · {kind} · Active")
}
ThemeSlot::Light => format!("Light mode · {kind}"),
ThemeSlot::Dark if crate::ui::theme::system_dark(cx) => {
format!("Dark mode · {kind} · Active")
}
ThemeSlot::Dark => format!("Dark mode · {kind}"),
};
// The six chromatic ANSI slots (red…cyan) as tiny swatches — the part of
// a theme the mini preview's few bars can't show, and what actually
// distinguishes two same-background themes at a glance.
let to_u32 = |(r, g, b): (u8, u8, u8)| (r as u32) << 16 | (g as u32) << 8 | b as u32;
let swatches = h_flex().gap_1().mt_1p5().children((1..=6).map(|i| {
div()
.w(px(10.))
.h(px(10.))
.rounded(px(3.))
.bg(rgb(to_u32(active.ansi16[i])))
}));
let preview = self.theme_preview(&active);
let open = self
.active_settings()
.is_some_and(|s| s.theme_panel_open && s.theme_panel_slot == slot);
div()
.id(card_id)
.mt_1()
.mb_2()
.w_full()
.cursor_pointer()
.on_click(cx.listener(move |this, _, _w, cx| this.toggle_theme_panel(slot, cx)))
.child(
h_flex()
.items_center()
.gap_4()
.p_3()
.rounded_xl()
.border_1()
.border_color(if open {
foreground.opacity(0.35)
} else {
border
})
.bg(surface)
.hover(|h| h.bg(hover_bg))
.child(div().w(px(150.)).flex_shrink_0().child(preview))
.child(
v_flex()
.gap_0p5()
.child(div().text_xs().text_color(muted_fg).child(caption))
.child(
div()
.text_sm()
.font_weight(FontWeight::SEMIBOLD)
.text_color(foreground)
.child(name),
)
.child(swatches),
)
.child(div().flex_1())
.child(
h_flex()
.items_center()
.gap_1()
.text_sm()
.text_color(muted_fg)
.child("Change theme")
.child(Icon::new(IconName::ChevronRight).small()),
),
)
.into_any_element()
}
/// The theme picker: a right-hand column of searchable preview
/// cards. Opened from the "Current theme" card; applying a theme keeps the
/// panel open (with its own `×`) so several looks can be tried in a row.
fn render_theme_panel(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let border = theme.border;
let foreground = theme.foreground;
let muted_fg = theme.muted_foreground;
// A hair off the content pane (like the settings rail) so the panel reads
// as its own surface rather than an extension of the page.
let bg = theme.sidebar;
let (search, query, slot) = match self.active_settings() {
Some(s) => (
s.theme_search.clone(),
s.theme_search.read(cx).value().trim().to_lowercase(),
s.theme_panel_slot,
),
None => return div().into_any_element(),
};
let config = cx.global::<Config>();
// Guard against a slot that no longer exists in the current mode (the
// sync switch flipped while the panel was open re-aims it, but stale
// state must still render something sensible).
let slot = match (config.theme_follow_system, slot) {
(false, _) => ThemeSlot::Manual,
(true, ThemeSlot::Manual) => {
if crate::ui::theme::system_dark(cx) {
ThemeSlot::Dark
} else {
ThemeSlot::Light
}
}
(true, s) => s,
};
let active_id = match slot {
ThemeSlot::Manual => config.theme_preset.clone(),
ThemeSlot::Light => config.theme_preset_light.clone(),
ThemeSlot::Dark => config.theme_preset_dark.clone(),
};
let header = h_flex()
.items_center()
.justify_between()
.px_4()
.pt_4()
.pb_1()
.child(
div()
.text_base()
.font_weight(FontWeight::SEMIBOLD)
.text_color(foreground)
.child("Themes"),
)
.child(
// The panel is docked to the window's top edge, so this `×` sits
// inside the settings overlay's stand-in title-bar strip — an
// absolute `WindowControlArea::Drag` band across the top 40px
// (see `root` below). On Windows that band is `HTCAPTION`, and
// unless something on top registers a mouse-blocking hitbox the
// OS takes the press as a window-drag and the button's `on_click`
// never fires. `occlude()` stops hit-testing here, the same way
// the tab-strip chips and the page's own `×` do. No-op elsewhere;
// the rest of the header still drags the window.
div().occlude().child(
Button::new("theme-panel-close")
.icon(IconName::Close)
.ghost()
.small()
.on_click(cx.listener(|this, _, _w, cx| this.close_theme_panel(cx))),
),
);
let subtitle = div()
.px_4()
.pb_3()
.text_xs()
.text_color(muted_fg)
.child(match slot {
ThemeSlot::Manual => "Change your current theme.",
ThemeSlot::Light => "Choose the theme for light mode.",
ThemeSlot::Dark => "Choose the theme for dark mode.",
});
// Plain text input, the same shape the Shell section uses — our own
// field, not a bespoke pill. The Input fills its parent, but a percent
// width needs a *definite* one to resolve against, so the wrapper is sized
// explicitly (panel 300 px_4 gutters). Placeholder labels it as search;
// a leading magnifier keeps that reading at a glance.
let search_box = div().px_4().pb_3().child(
div().w(px(268.)).child(
Input::new(&search)
.small()
// Stock magnifier — same reason as the page header's.
.prefix(
Icon::empty()
.path("stock/icons/search.svg")
.small()
.text_color(muted_fg),
),
),
);
let mut list = v_flex().px_4().pb_4().gap_4();
for p in presets::all(cx) {
if !query.is_empty() && !p.name.to_lowercase().contains(&query) {
continue;
}
let id = p.id.clone();
let is_active = active_id == id;
// Here the preview sits *flush* inside the card's border (the
// "Current theme" card pads it, so it keeps its own 8px there). Flush
// means its corner has to nest one hairline inside the card's, or it
// bulges past the border into the square overflow clip — the corner
// then reads as a hard step instead of an arc (issue #236).
let preview = self.theme_preview(&p).rounded(rounding::inner_radius(
rounding::TRACK_RADIUS,
rounding::HAIRLINE,
));
let click_id = id.clone();
list = list.child(
v_flex()
.id(SharedString::from(format!("panel-theme-{id}")))
.gap_1p5()
.cursor_pointer()
.child(
// Percent width (`w_full` in the preview) only resolves
// against a *definite* parent, so pin the card to the
// panel's content width (300 px_4 gutters) — same reason
// the search box above is sized explicitly.
div()
.w(px(268.))
.rounded(rounding::TRACK_RADIUS)
.overflow_hidden()
.border_1()
.border_color(if is_active {
foreground.opacity(0.5)
} else {
border
})
.when(is_active, |s| s.shadow_md())
.when(!is_active, |s| {
s.hover(|h| h.border_color(foreground.opacity(0.25)))
})
.child(preview),
)
.child(
h_flex()
.items_center()
.gap_1p5()
.child(
div()
.text_sm()
.font_weight(if is_active {
FontWeight::SEMIBOLD
} else {
FontWeight::MEDIUM
})
.text_color(if is_active { foreground } else { muted_fg })
.child(p.name.clone()),
)
.when(is_active, |s| {
s.child(Icon::new(IconName::Check).small().text_color(foreground))
}),
)
.on_click(cx.listener(move |this, _, window, cx| match slot {
ThemeSlot::Manual => this.set_preset(&click_id, window, cx),
ThemeSlot::Light => this.set_slot_preset(false, &click_id, window, cx),
ThemeSlot::Dark => this.set_slot_preset(true, &click_id, window, cx),
})),
);
}
v_flex()
.w(px(300.))
.h_full()
.flex_shrink_0()
.bg(bg)
.border_l_1()
.border_color(border)
.child(header)
.child(subtitle)
.child(search_box)
.child(
v_flex()
.id("theme-panel-list")
.flex_1()
.overflow_y_scroll()
.child(list),
)
.into_any_element()
}
/// Keybindings section: the effective shortcut list (defaults + overrides).
fn render_settings_keybindings(&self, cx: &mut Context<Self>) -> AnyElement {
let (foreground, muted, border, kbd_bg, accent) = {
let t = cx.theme();
(
t.foreground,
t.muted_foreground,
t.border,
t.secondary.opacity(0.6),
t.primary,
)
};
// Config-derived state, read into owned values so the `cx` borrow is
// free for `effective_bindings` and the click listeners below.
let (preset, prefix, overridden) = {
let cfg = cx.global::<Config>();
let overridden: std::collections::HashSet<String> =
cfg.keybindings.keys().cloned().collect();
(
cfg.keybinding_preset.clone(),
cfg.prefix.clone(),
overridden,
)
};
let tmux = preset == "tmux";
let effective = crate::ui::keymap::effective_bindings(cx);
// The row currently capturing a shortcut (action + chords so far), and
// any pending takeover note.
let recording = self
.active_settings()
.and_then(|s| s.recording.as_ref())
.map(|r| (r.action.clone(), r.chords.clone()));
let note = self
.active_settings()
.and_then(|s| s.rebinding_note.clone());
// One key glyph as a small keycap, so a shortcut reads like real keys.
let keycap = move |tok: String| {
div()
.flex()
.items_center()
.justify_center()
.min_w(px(22.))
.h(px(22.))
.px_1p5()
.rounded_md()
.bg(kbd_bg)
.border_1()
.border_color(border)
.text_xs()
.text_color(foreground)
.child(tok)
};
// Preset and prefix are each a one-of-two choice among visible siblings —
// a segmented control, and now built as one. They used to be loose
// `Button::selected` pairs, which put them on gpui-component's
// `tokens.button_active`: another field nothing set, so the "on" button
// wore a stock grey with no relation to the theme (issue #197's failure
// mode, in a different corner of the same page).
let preset_control = self.segmented(
"kb-preset",
&["Default", "tmux"],
usize::from(tmux),
cx,
|this, ix, _w, cx| {
this.set_keybinding_preset(if ix == 0 { "default" } else { "tmux" }, cx)
},
);
let prefix_control = self.segmented(
"kb-prefix",
&["Ctrl-B", "Ctrl-A"],
usize::from(prefix == "ctrl-a"),
cx,
|this, ix, _w, cx| {
this.set_keybinding_prefix(if ix == 0 { "ctrl-b" } else { "ctrl-a" }, cx)
},
);
let preset_row = h_flex()
.items_center()
.justify_between()
.py_2()
.child(
v_flex()
.gap_0p5()
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child("Preset"),
)
.child(div().text_xs().text_color(muted).child(
"tmux remaps pane/tab actions onto prefix sequences (e.g. Ctrl-B then C).",
)),
)
.child(h_flex().flex_shrink_0().child(preset_control));
let prefix_row = h_flex()
.items_center()
.justify_between()
.py_2()
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child("Prefix"),
)
.child(h_flex().flex_shrink_0().child(prefix_control));
let count = effective.len();
let mut list = v_flex().mt_2();
for (i, (action, key)) in effective.into_iter().enumerate() {
let is_recording = recording.as_ref().is_some_and(|(a, _)| a == &action);
let is_overridden = overridden.contains(&action);
// Keycap clusters for a spec: one cluster per whitespace-separated
// chord (a sequence like `ctrl-b x` draws as two clusters), with a
// wider gap between clusters than within one.
let keycaps = |spec: &str| {
h_flex().gap_2().children(
crate::ui::keymap::key_chords(spec)
.into_iter()
.map(|chord| h_flex().gap_1().children(chord.into_iter().map(&keycap))),
)
};
// Right side: the live capture (chords so far + hint), the keycap
// sequence, or "—".
let captured: gpui::AnyElement = if is_recording {
let chords = recording
.as_ref()
.map(|(_, c)| c.clone())
.unwrap_or_default();
let row = h_flex().gap_2().items_center();
let row = if chords.is_empty() {
row.child(div().text_xs().text_color(accent).child("Press keys…"))
} else {
row.child(keycaps(&chords.join(" "))).child(
div()
.text_xs()
.text_color(muted)
.child("pause to save · Esc"),
)
};
row.into_any_element()
} else if key.is_empty() {
div()
.text_sm()
.text_color(muted)
.child("—")
.into_any_element()
} else {
keycaps(&key).into_any_element()
};
// The whole right cell is clickable to start capturing this row.
let action_for_click = action.clone();
let capture = div()
.id(SharedString::from(format!("kb-{action}")))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded_md()
.cursor_pointer()
.when(is_recording, |d| d.border_1().border_color(accent))
.hover(|d| d.bg(kbd_bg))
.child(captured)
.on_click(cx.listener(move |this, _, window, cx| {
this.start_recording_key(action_for_click.clone(), window, cx)
}));
let action_for_reset = action.clone();
let right = h_flex()
.items_center()
.gap_1()
.child(capture)
.when(is_overridden, |r| {
r.child(
Button::new(SharedString::from(format!("reset-{action}")))
.label("Reset")
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
this.reset_keybinding(action_for_reset.clone(), cx)
})),
)
});
list = list.child(
h_flex()
.items_center()
.justify_between()
.py_1p5()
.when(i + 1 < count, |s| s.border_b_1().border_color(border))
.child(
div()
.text_sm()
.text_color(foreground)
.child(humanize_action(&action)),
)
.child(right),
);
}
v_flex()
.child(self.section_intro(
"Keybindings",
"Click a shortcut, then press the new keys — it saves after a brief pause. Chain keys for a sequence like Ctrl-B then X. Esc cancels; Backspace removes the last key, or resets the shortcut to default when pressed first.",
cx,
))
.child(preset_row)
.when(tmux, |v| v.child(prefix_row))
.when(tmux, |v| {
v.child(div().py_1().text_xs().text_color(muted).child(
"With a prefix active, a bare prefix key reaches the shell after a ~1s pause, and prefix + an unbound key is sent through to the terminal.",
))
})
.when_some(note, |v, note| {
v.child(div().py_1().text_xs().text_color(accent).child(note))
})
.child(
h_flex().justify_end().py_2().child(
Button::new("kb-restore-all")
.label("Restore all defaults")
.small()
.on_click(cx.listener(|this, _, _w, cx| {
this.restore_default_keybindings(cx)
})),
),
)
.child(list)
.into_any_element()
}
/// "How sessions work": the four-line explanation of the app's own model —
/// what closing a window does, what Stop does, what Delete does, what Quit
/// does.
///
/// This is tty7's central idea and the thing that most surprises a user
/// arriving from another terminal, and until now it was explained *only*
/// inside the confirmation dialogs — that is, at the moment the user is
/// already committing to an action, and never before. Stating it once, in
/// the one page that describes what the app is, means the dialogs confirm a
/// model the user has already met instead of teaching it under pressure.
///
/// Deliberately a plain definition list rather than settings rows: nothing
/// here is configurable, and giving it switch-shaped chrome would suggest
/// otherwise.
fn render_session_model(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground);
let entry = |term: &'static str, meaning: &'static str| {
v_flex()
.gap_0p5()
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child(term),
)
.child(div().text_xs().text_color(muted_fg).child(meaning))
};
v_flex()
.mt_6()
.gap_2()
.child(self.section_rule(cx))
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child("How sessions work"),
)
.child(div().text_xs().text_color(muted_fg).child(
"Your shells run in a background daemon, not in this window. That is what lets them outlive a quit or a reboot — and it means \"close\" and \"end\" are different things here.",
))
.child(
v_flex()
.mt_2()
.gap_3()
.child(entry(
"Closing a window (⌘W on the last tab)",
"Detaches the workspace. Every shell keeps running; the workspace waits on the home page and in the title-bar menu.",
))
.child(entry(
"Quitting tty7 (⌘Q)",
"Same deal, for every window. Nothing running is interrupted.",
))
.child(entry(
"Stop Workspace",
"Ends that workspace's shells but keeps its layout, so you can start it again with fresh ones.",
))
.child(entry(
"Delete Workspace",
"Ends the shells and forgets the layout. The only step here you can't undo.",
)),
)
.into_any_element()
}
/// About section: app identity and stack.
fn render_settings_about(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground);
// Startup update check (see `core::update`): a newer release, if one was
// found, plus the toggle that controls whether we look at all.
let update = cx
.try_global::<crate::core::update::UpdateStatus>()
.and_then(|s| s.available.clone());
let check_for_updates = cx.global::<Config>().check_for_updates;
let logo = Arc::new(Image::from_bytes(
ImageFormat::Png,
include_bytes!("../../assets/logo@256.png").to_vec(),
));
v_flex()
.child(self.section_header("About", cx))
.child(
h_flex()
.gap_4()
.items_center()
.child(img(logo).size_12().rounded_lg())
.child(
v_flex()
.gap_0p5()
.child(
div()
.text_xl()
.font_weight(FontWeight::SEMIBOLD)
.text_color(foreground)
.child("tty7"),
)
.child(div().text_sm().text_color(muted_fg).child(format!(
"Version {}",
env!("CARGO_PKG_VERSION")
)))
.child(
Link::new("about-github")
.href("https://github.com/l0ng-ai/tty7")
.text_sm()
.child("github.com/l0ng-ai/tty7"),
),
),
)
.child(
v_flex()
.mt_5()
.gap_2()
// Mirrors the README's positioning line and stack sub-line, so
// the app and the repo describe tty7 in the same words.
.child(
div()
.text_sm()
.text_color(foreground)
.child("A terminal workbench: shells, sessions, SSH, coding agents."),
)
.child(div().text_sm().text_color(muted_fg).child(
"Editor-grade input in every shell, sessions that survive quits and reboots without tmux, a native SSH stack with profiles and port forwarding, and live status for panes running coding agents.",
))
.child(
div()
.text_xs()
.text_color(muted_fg)
.child("Pure Rust · GPU rendering on Zed's gpui · VT core from Alacritty"),
),
)
.child(self.render_session_model(cx))
// Updates: the startup check drops a newer version here if it found
// one. We never self-update — "Download" just opens the Releases
// page; the toggle turns the check off (see `core::update`).
.child(
v_flex()
.mt_6()
.gap_2()
.child(self.section_rule(cx))
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child("Updates"),
)
.when_some(update, |this, upd| {
this.child(
h_flex()
.gap_3()
.items_center()
.child(div().text_sm().text_color(foreground).child(
format!("Version {} is available.", upd.version),
))
.child(
// Match the sibling "Restart daemon…" button
// (default style, not the dark `.primary()`
// fill) so About reads as one panel.
Button::new("download-update")
.label("Download")
.small()
.on_click(cx.listener(|this, _, _w, _cx| {
this.open_releases_page()
})),
),
)
})
.child(div().text_sm().text_color(muted_fg).child(
"Check GitHub for a newer release on launch and show it here. tty7 never updates itself — downloading happens on the Releases page.",
))
.child(
h_flex()
.gap_2()
.items_center()
.child(
crate::ui::theme::switch("check-updates", cx)
.checked(check_for_updates)
.on_click(cx.listener(|this, on: &bool, _w, cx| {
this.set_check_for_updates(*on, cx)
})),
)
.child(
div()
.text_sm()
.text_color(foreground)
.child("Check for updates on launch"),
),
),
)
// Manage that daemon. A fresh process is the only way to pick up a
// macOS permission granted after it started (e.g. Full Disk Access),
// to recover if it wedges, or to start clean — quitting/reopening the
// window alone never restarts it. Ends every running session, so the
// action confirms first.
.child(
v_flex()
.mt_6()
.gap_2()
.child(self.section_rule(cx))
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child("Daemon"),
)
.child(div().text_sm().text_color(muted_fg).child(
"Restart the daemon to pick up a newly granted macOS permission, recover if it stops responding, or start from a clean slate. This ends all running sessions; your tabs and layout reopen with fresh shells.",
))
.child(
h_flex().child(
Button::new("restart-daemon")
.label("Restart daemon…")
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.restart_daemon(window, cx)
})),
),
),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every section must carry at least one index entry, or the search box can
/// annotate the nav with a count it can never jump to — and, worse, a whole
/// page of settings becomes unreachable by search.
#[test]
fn every_section_has_search_entries() {
for section in SettingsSection::ALL {
let n = settings_search_entries()
.iter()
.filter(|e| e.section == section)
.count();
assert!(
n > 0,
"section {:?} has no search entries",
section.profile_label()
);
}
}
/// `best_matching_section` must be able to reach every section — it used to
/// be driven by a hand-written list that had fallen behind by two.
#[test]
fn best_matching_section_can_reach_every_section() {
for section in SettingsSection::ALL {
let entry = settings_search_entries()
.iter()
.find(|e| e.section == section)
.expect("checked by every_section_has_search_entries");
let query = entry.title.to_lowercase();
let landed = best_matching_section(&query);
assert!(
landed.is_some(),
"query {query:?} matched nothing at all (section {:?})",
section.profile_label()
);
}
}
/// Settings that had no index entry at all before this pass — searching for
/// any of them returned an empty result on a page that plainly had the knob.
#[test]
fn previously_unsearchable_settings_are_findable() {
use SettingsSection::*;
let cases: &[(&str, SettingsSection)] = &[
("opacity", Appearance),
("blur", Appearance),
("completion", Input),
("ctrl-r", Input),
("grouping", WindowTabs),
("threshold", WindowTabs),
("report mouse", Terminal),
("open files with", Terminal),
("bell", Terminal),
("known_hosts", Ssh),
("claude", Agents),
];
for (query, expected) in cases {
assert_eq!(
best_matching_section(query).map(|s| s.profile_label()),
Some(expected.profile_label()),
"query {query:?} should land on {:?}",
expected.profile_label()
);
}
}
/// The close-confirmation toggle is the one people go looking for *after*
/// the dialog has annoyed them, so it has to be reachable by what they'd
/// type in that moment — not just by its own title.
#[test]
fn close_confirmation_toggle_is_findable() {
// Not a bare "confirm": SSH's own close warning owns that word just as
// legitimately, and the nav's per-section counts are what disambiguate.
for query in [
"ask again",
"closing the last window",
"dialog",
"cmd-w",
"ctrl-w",
] {
assert_eq!(
best_matching_section(query).map(|s| s.profile_label()),
Some(SettingsSection::WindowTabs.profile_label()),
"query {query:?} should land on Window & Tabs"
);
}
}
/// The index names rows, so a title that no longer matches the rendered row
/// sends the user to the right page and then leaves them hunting. This
/// pins the ones that had drifted (the index said "Working directory"; the
/// row says "Start in").
#[test]
fn index_titles_match_rendered_row_labels() {
for title in [
"Start in",
"Restore last layout",
"Confirm before closing the last window",
"Terminal bell",
"Report mouse to apps",
"Open files with",
"Sidebar grouping",
"Tab completion",
"History search",
"Dim inactive panes",
] {
assert!(
settings_search_entries().iter().any(|e| e.title == title),
"no index entry titled {title:?}"
);
}
}
#[test]
fn humanize_action_splits_on_capitals() {
assert_eq!(humanize_action("NewTab"), "New Tab");
assert_eq!(
humanize_action("ToggleMaximizePane"),
"Toggle Maximize Pane"
);
assert_eq!(humanize_action("Quit"), "Quit");
}
/// A host is findable by every part of the address it is displayed with,
/// not just its name — an imported alias often *is* its hostname.
#[test]
fn the_host_filter_matches_name_address_and_port() {
let mut p = SshProfile::new("prod-web");
p.host = "10.0.1.21".to_string();
p.user = "deploy".to_string();
p.port = 2222;
assert!(ssh_row_matches(&p, ""), "an empty query keeps everything");
assert!(ssh_row_matches(&p, "prod"));
assert!(ssh_row_matches(&p, "10.0.1"));
assert!(ssh_row_matches(&p, "deploy"));
assert!(ssh_row_matches(&p, "2222"));
assert!(!ssh_row_matches(&p, "staging"));
}
/// The filter is case-insensitive on a lowercased query, which is what the
/// master column hands it.
#[test]
fn the_host_filter_ignores_case() {
let mut p = SshProfile::new("Prod-Web");
p.host = "Example.COM".to_string();
assert!(ssh_row_matches(&p, "prod"));
assert!(ssh_row_matches(&p, "example.com"));
}
/// Imported aliases lead, ungrouped hosts trail, and anything the user named
/// sits between them — so the `~/.ssh/config` bucket is never buried.
#[test]
fn group_buckets_sort_imported_first_and_ungrouped_last() {
let mut keys = vec!["", "Work", crate::core::ssh_config::IMPORTED_GROUP];
keys.sort_by_key(|k| ssh_group_rank(k));
assert_eq!(
keys,
vec![crate::core::ssh_config::IMPORTED_GROUP, "Work", ""]
);
}
/// The import bucket is labelled by the file it mirrors: it is a live link
/// to something edited elsewhere, not a record of a past import.
#[test]
fn group_labels_name_the_file_and_the_app() {
assert_eq!(
ssh_group_label(crate::core::ssh_config::IMPORTED_GROUP),
"~/.ssh/config"
);
assert_eq!(ssh_group_label(""), "In tty7");
assert_eq!(ssh_group_label("Work"), "Work");
}
/// A profile's bucket comes off its own `group`, with `None` collapsing to
/// the same key the ungrouped section uses.
#[test]
fn group_key_falls_back_to_the_ungrouped_bucket() {
let mut p = SshProfile::new("a");
assert_eq!(ssh_group_key(&p), "");
p.group = Some("Work".to_string());
assert_eq!(ssh_group_key(&p), "Work");
}
#[test]
fn parse_host_port_handles_blank_and_ports() {
assert!(parse_host_port(" ").is_none());
let hp = parse_host_port("example.com:2222").unwrap();
assert_eq!(hp.host, "example.com");
assert_eq!(hp.port, 2222);
// No colon → host only, port 0.
assert_eq!(parse_host_port("host").unwrap().port, 0);
}
}
#[cfg(test)]
mod gpui_tests {
use super::SettingsSection;
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext, px, size};
fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) {
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
});
// Wrapped in a `Root` like `main.rs` does — the gpui-component widgets on
// the settings sheet reach for it on the window.
let window = cx.add_window(|window, cx| {
let app =
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
gpui_component::Root::new(app, window, cx)
});
cx.background_executor.run_until_parked();
let app = window
.update(cx, |root, _, _| {
root.view()
.clone()
.downcast::<Tty7App>()
.unwrap_or_else(|_| panic!("window root wraps a Tty7App"))
})
.unwrap();
let vcx = VisualTestContext::from_window(window.into(), cx);
(app, vcx)
}
/// Appearance is where issue #236's controls live: the cursor-shape segmented
/// track, the /value/+ steppers and the theme picker's flush previews all
/// compute their own corner radii now (`ui::rounding`) instead of leaning on
/// `overflow_hidden`, and the stepper's glyph boxes were re-laid-out to
/// `h_full` so those radii land on the track's content box.
///
/// gpui's test platform lays out and paints but never rasterizes, so this
/// cannot assert what the corners *look* like. What it can do is put the page
/// through a real layout and paint pass — twice, at two widths, so the tracks
/// are measured more than once — which is the cheapest seam that catches a
/// panic or a broken constraint in that arithmetic before a human ever sees
/// the window.
#[gpui::test]
fn appearance_section_lays_out_with_its_rounded_controls(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
app.update_in(&mut vcx, |app, window, cx| {
app.open_settings_section(SettingsSection::Appearance, window, cx);
});
vcx.simulate_resize(size(px(1100.), px(800.)));
vcx.run_until_parked();
// The flush-mounted theme previews only render with the picker docked
// open, so the page has to be paint-tested in both states to reach every
// site this change touched.
app.update_in(&mut vcx, |app, _, cx| {
if let Some(s) = app.active_settings_mut() {
s.theme_panel_open = true;
}
cx.notify();
});
// Narrow enough that the theme list re-measures against a different
// available width than the pass above.
vcx.simulate_resize(size(px(720.), px(560.)));
vcx.run_until_parked();
let section = vcx.update(|_, cx| app.read(cx).active_settings().map(|s| s.section));
assert!(
matches!(section, Some(SettingsSection::Appearance)),
"the panel should still be on Appearance after two paint passes",
);
}
}