Files
tty7/src/ui/settings.rs
T
l0ng-aiandl0ng-ai 4dbbe95be9 refactor(settings): trim the About page down to what it is for (#358)
The About page carried a marketing paragraph and two multi-sentence
explanations that walked through updater internals and per-platform
support. None of it helps someone who is already running the app.

Drop the feature-list paragraph, cut the update and server text to a
single sentence each, move the tech credits to the bottom of the page,
and render the update toggle with settings_row so it matches every
other switch in Settings. The tagline and credits line now match the
README.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-06 12:11:39 +08:00

5198 lines
186 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.
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::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::presets;
use crate::ui::rounding;
use crate::ui::rounding::RoundedCorners as _;
fn settings_row_id(label: &str, _desc: &str) -> SharedString {
SharedString::from(format!("settings-row-{label}"))
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SettingsSection {
Appearance,
Terminal,
Input,
Ssh,
Agents,
WindowTabs,
Keybindings,
About,
}
impl SettingsSection {
pub(crate) const ALL: [SettingsSection; 8] = [
SettingsSection::Appearance,
SettingsSection::Terminal,
SettingsSection::Input,
SettingsSection::Ssh,
SettingsSection::Agents,
SettingsSection::WindowTabs,
SettingsSection::Keybindings,
SettingsSection::About,
];
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",
}
}
}
struct SearchEntry {
section: SettingsSection,
title: L10nKey,
keywords: L10nKey,
}
fn localized_update_phase(phase: &crate::core::update::UpdatePhase) -> Option<String> {
use crate::core::update::{UpdateFailure, UpdatePhase};
match phase {
UpdatePhase::Idle => None,
UpdatePhase::Checking => Some(t(L10nKey::SettingsUpdateChecking).to_string()),
UpdatePhase::UpToDate => Some(t(L10nKey::SettingsUpdateUpToDate).to_string()),
UpdatePhase::Downloading => Some(t(L10nKey::SettingsUpdateDownloading).to_string()),
UpdatePhase::Installing => Some(t(L10nKey::SettingsUpdateInstalling).to_string()),
UpdatePhase::Failed(failure) => {
let (key, error) = match failure {
UpdateFailure::Check(error) => (L10nKey::SettingsUpdateCheckFailed, error),
UpdateFailure::Prepare(error) => (L10nKey::SettingsUpdatePrepareFailed, error),
UpdateFailure::Launch(error) => (L10nKey::SettingsUpdateLaunchFailed, error),
};
Some(t_fmt(key, &[("error", error)]))
}
}
}
fn localized_update_install_hint(hint: &crate::core::update::UpdateInstallHint) -> String {
use crate::core::update::UpdateInstallHint;
match hint {
#[cfg(target_os = "macos")]
UpdateInstallHint::UnsupportedMacos => {
t(L10nKey::SettingsUpdateUnsupportedMacos).to_string()
}
#[cfg(target_os = "linux")]
UpdateInstallHint::UnsupportedLinux => {
t(L10nKey::SettingsUpdateUnsupportedLinux).to_string()
}
#[cfg(target_os = "windows")]
UpdateInstallHint::UnsupportedWindows => {
t(L10nKey::SettingsUpdateUnsupportedWindows).to_string()
}
#[cfg(target_os = "windows")]
UpdateInstallHint::WindowsAllUsersInstall => {
t(L10nKey::SettingsUpdateWindowsAllUsers).to_string()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
UpdateInstallHint::UnsupportedPlatform => {
t(L10nKey::SettingsUpdateUnsupportedPlatform).to_string()
}
UpdateInstallHint::MissingPackage(name) => {
t_fmt(L10nKey::SettingsUpdateMissingPackage, &[("name", name)])
}
UpdateInstallHint::MissingChecksums => {
t(L10nKey::SettingsUpdateMissingChecksums).to_string()
}
}
}
fn settings_search_entries() -> &'static [SearchEntry] {
use L10nKey::*;
use SettingsSection::*;
&[
SearchEntry {
section: Appearance,
title: SettingsLanguage,
keywords: SettingsSearchLanguageKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsThemeIntroTitle,
keywords: SettingsSearchThemeKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsSyncWithSystem,
keywords: SettingsSearchSyncWithSystemKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsCustomThemes,
keywords: SettingsSearchCustomThemesKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsOpacity,
keywords: SettingsSearchOpacityKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsBlur,
keywords: SettingsSearchBlurKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsDimInactivePanes,
keywords: SettingsSearchDimInactivePanesKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsFontSize,
keywords: SettingsSearchFontSizeKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsLineHeight,
keywords: SettingsSearchLineHeightKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsFontFamily,
keywords: SettingsSearchFontFamilyKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsBoldFont,
keywords: SettingsSearchBoldFontKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsItalicFont,
keywords: SettingsSearchItalicFontKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsFontLigatures,
keywords: SettingsSearchFontLigaturesKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsCursorShape,
keywords: SettingsSearchCursorShapeKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsCursorBlink,
keywords: SettingsSearchCursorBlinkKeywords,
},
SearchEntry {
section: Appearance,
title: SettingsAnsiColors,
keywords: SettingsSearchAnsiColorsKeywords,
},
SearchEntry {
section: Terminal,
title: SettingsProgram,
keywords: SettingsSearchProgramKeywords,
},
SearchEntry {
section: Terminal,
title: SettingsArguments,
keywords: SettingsSearchArgumentsKeywords,
},
SearchEntry {
section: Terminal,
title: SettingsStartIn,
keywords: SettingsSearchStartInKeywords,
},
SearchEntry {
section: Terminal,
title: SettingsScrollback,
keywords: SettingsSearchScrollbackKeywords,
},
SearchEntry {
section: Terminal,
title: SettingsScrollSpeed,
keywords: SettingsSearchScrollSpeedKeywords,
},
SearchEntry {
section: Terminal,
title: SettingsFocusFollowsMouse,
keywords: SettingsSearchFocusFollowsMouseKeywords,
},
SearchEntry {
section: Terminal,
title: SettingsHideMouseWhileTyping,
keywords: SettingsSearchHideMouseWhileTypingKeywords,
},
SearchEntry {
section: Terminal,
title: SettingsReportMouseToApps,
keywords: SettingsSearchReportMouseToAppsKeywords,
},
SearchEntry {
section: Terminal,
title: SettingsTerminalBell,
keywords: SettingsSearchTerminalBellKeywords,
},
SearchEntry {
section: Terminal,
title: DetectUrls,
keywords: SettingsSearchDetectUrlsKeywords,
},
SearchEntry {
section: Terminal,
title: ForwardSshLoopbackLinks,
keywords: SettingsSearchForwardSshLoopbackLinksKeywords,
},
SearchEntry {
section: Terminal,
title: OpenFilesWith,
keywords: SettingsSearchOpenFilesWithKeywords,
},
SearchEntry {
section: Input,
title: SettingsTabCompletion,
keywords: SettingsSearchTabCompletionKeywords,
},
SearchEntry {
section: Input,
title: SettingsHistorySearch,
keywords: SettingsSearchHistorySearchKeywords,
},
SearchEntry {
section: Input,
title: SettingsOptionAsMeta,
keywords: SettingsSearchOptionAsMetaKeywords,
},
SearchEntry {
section: Input,
title: SettingsSmartSelection,
keywords: SettingsSearchSmartSelectionKeywords,
},
SearchEntry {
section: Input,
title: SettingsCopyOnSelect,
keywords: SettingsSearchCopyOnSelectKeywords,
},
SearchEntry {
section: Input,
title: SettingsTrimTrailingSpaces,
keywords: SettingsSearchTrimTrailingSpacesKeywords,
},
SearchEntry {
section: Ssh,
title: SettingsHosts,
keywords: SettingsSearchHostsKeywords,
},
SearchEntry {
section: Ssh,
title: SettingsVerifyHostKeys,
keywords: SettingsSearchVerifyHostKeysKeywords,
},
SearchEntry {
section: Ssh,
title: WarnBeforeClosing,
keywords: SettingsSearchWarnBeforeClosingKeywords,
},
SearchEntry {
section: Ssh,
title: SettingsPortForwarding,
keywords: SettingsSearchPortForwardingKeywords,
},
SearchEntry {
section: Agents,
title: SettingsAgentClaudeCode,
keywords: SettingsSearchClaudeCodeKeywords,
},
SearchEntry {
section: Agents,
title: SettingsAgentCodex,
keywords: SettingsSearchCodexKeywords,
},
SearchEntry {
section: Agents,
title: SettingsAgentCopilotCli,
keywords: SettingsSearchCopilotCliKeywords,
},
SearchEntry {
section: Agents,
title: SettingsAgentOpencode,
keywords: SettingsSearchOpencodeKeywords,
},
SearchEntry {
section: Agents,
title: SettingsAgentPi,
keywords: SettingsSearchPiKeywords,
},
SearchEntry {
section: Agents,
title: SettingsAgentGrokBuild,
keywords: SettingsSearchGrokBuildKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsStartupWindow,
keywords: SettingsSearchStartupWindowKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsRememberWindowSize,
keywords: SettingsSearchRememberWindowSizeKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsRestoreLastLayout,
keywords: SettingsSearchRestoreLastLayoutKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsConfirmLastWindowClose,
keywords: SettingsSearchConfirmLastWindowCloseKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsShowTrayIcon,
keywords: SettingsSearchShowTrayIconKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsTaskbarStatusIcon,
keywords: SettingsSearchTaskbarStatusIconKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsNewTabPosition,
keywords: SettingsSearchNewTabPositionKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsTabBarPosition,
keywords: SettingsSearchTabBarPositionKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsSidebarGrouping,
keywords: SettingsSearchSidebarGroupingKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsDiffPreviewFromCounts,
keywords: SettingsSearchDiffPreviewFromCountsKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsNotifyOnCommandFinish,
keywords: SettingsSearchNotifyOnCommandFinishKeywords,
},
SearchEntry {
section: WindowTabs,
title: SettingsNotifyThreshold,
keywords: SettingsSearchNotifyThresholdKeywords,
},
SearchEntry {
section: Keybindings,
title: SettingsSearchKeybindingsTitle,
keywords: SettingsSearchKeybindingsKeywords,
},
SearchEntry {
section: About,
title: SettingsNavAbout,
keywords: SettingsSearchAboutKeywords,
},
SearchEntry {
section: About,
title: SettingsSearchHowShellsWorkTitle,
keywords: SettingsSearchHowShellsWorkKeywords,
},
SearchEntry {
section: Agents,
title: SettingsSearchCommandLineToolTitle,
keywords: SettingsSearchCommandLineToolKeywords,
},
]
}
fn entry_matches(entry: &SearchEntry, query: &str) -> bool {
t(entry.title).to_lowercase().contains(query)
|| t(entry.keywords).to_lowercase().contains(query)
}
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()
}
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)
.reduce(|best, cur| if cur.1 > best.1 { cur } else { best })
.map(|(s, _)| s)
}
pub(crate) struct ThemeEditor {
#[allow(dead_code)]
pub(crate) for_id: String,
pub(crate) seed: Vec<(ThemeEdit, String, Entity<ColorPickerState>)>,
pub(crate) ansi: Vec<(ThemeEdit, String, Entity<ColorPickerState>)>,
pub(crate) image_opacity_slider: Option<Entity<SliderState>>,
pub(crate) _subs: Vec<Subscription>,
}
pub(crate) struct SettingsState {
pub(crate) focus_handle: gpui::FocusHandle,
pub(crate) section: SettingsSection,
pub(crate) search: Entity<InputState>,
pub(crate) font_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) font_bold_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) font_italic_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) language_select: Entity<SelectState<SearchableVec<String>>>,
pub(crate) shell_program_input: Entity<InputState>,
pub(crate) shell_args_input: Entity<InputState>,
pub(crate) wd_path_input: Entity<InputState>,
pub(crate) link_file_command_input: Entity<InputState>,
pub(crate) scroll_slider: Entity<SliderState>,
pub(crate) window_opacity_slider: Entity<SliderState>,
pub(crate) theme_editor: Option<ThemeEditor>,
pub(crate) theme_panel_open: bool,
pub(crate) theme_panel_slot: ThemeSlot,
pub(crate) theme_search: Entity<InputState>,
pub(crate) recording: Option<Recording>,
pub(crate) rebinding_note: Option<String>,
pub(crate) ssh_form: Option<SshProfileForm>,
pub(crate) ssh_detail: SshDetail,
pub(crate) ssh_filter: Entity<InputState>,
pub(crate) ssh_collapsed_groups: std::collections::HashSet<String>,
pub(crate) ssh_quick_connect: Entity<InputState>,
pub(crate) agent_hooks_host: HostId,
pub(crate) agent_hooks_states: AgentHooksView,
pub(crate) agent_hooks_seq: u64,
pub(crate) agent_hooks_note: Option<(crate::core::agent_hooks::HookAgent, String)>,
pub(crate) _subs: Vec<Subscription>,
}
#[derive(Clone)]
pub(crate) enum AgentHooksView {
Loading,
Ready(Vec<AgentHookRow>),
Unavailable(String),
}
#[derive(Clone)]
pub(crate) struct AgentHookRow {
pub(crate) agent: crate::core::agent_hooks::HookAgent,
pub(crate) state: crate::core::agent_hooks::HooksState,
pub(crate) target: String,
}
#[derive(Clone)]
pub(crate) struct AgentHooksMachine {
pub(crate) host: HostId,
pub(crate) label: String,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ThemeSlot {
Manual,
Light,
Dark,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SshDetail {
None,
Defaults,
Profile(Uuid),
}
fn ssh_group_key(p: &SshProfile) -> &str {
p.group.as_deref().unwrap_or("")
}
fn ssh_group_label(key: &str) -> &str {
match key {
crate::core::ssh_config::IMPORTED_GROUP => "~/.ssh/config",
"" => t(L10nKey::SettingsInTty7),
other => other,
}
}
fn ssh_group_rank(key: &str) -> u8 {
match key {
crate::core::ssh_config::IMPORTED_GROUP => 0,
"" => 2,
_ => 1,
}
}
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())
}
pub(crate) struct SshProfileForm {
editing: Uuid,
carry_group: Option<String>,
carry_credential_ref: Option<CredentialRef>,
show_jump: bool,
show_forwards: bool,
show_advanced: bool,
name: Entity<InputState>,
host: Entity<InputState>,
port: Entity<InputState>,
user: Entity<InputState>,
auth: AuthMode,
jump: Entity<InputState>,
forwards: Vec<ForwardRuleForm>,
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>,
agent_forward: bool,
x11: bool,
skip_banner: bool,
shell_integration: bool,
verify_host_keys: Option<bool>,
warn_on_close: Option<bool>,
_subs: Vec<Subscription>,
}
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 {
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),
})
}
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())
}
}
pub(crate) struct Recording {
pub(crate) action: String,
pub(crate) chords: Vec<String>,
pub(crate) _intercept: Subscription,
}
pub(crate) fn font_default_label() -> &'static str {
t(L10nKey::SettingsFontDefault)
}
#[cfg(target_os = "macos")]
const LINK_MODIFIER_LABEL: &str = "⌘";
#[cfg(not(target_os = "macos"))]
const LINK_MODIFIER_LABEL: &str = "Ctrl";
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
}
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)),
}
}
fn host_port_text(hp: &Option<HostPort>) -> String {
hp.as_ref()
.map(|h| format!("{}:{}", h.host, h.port))
.unwrap_or_default()
}
fn split_list(s: &str) -> Vec<String> {
s.split([',', ' ', '\n'])
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
fn split_lines(s: &str) -> Vec<String> {
s.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
fn forward_row_inputs(row: &ForwardRuleForm) -> [&Entity<InputState>; 5] {
[
&row.bind_host,
&row.bind_port,
&row.target_host,
&row.target_port,
&row.description,
]
}
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,
t(L10nKey::ForwardDescriptionPlaceholder),
),
}
}
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)
})
}
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 {
pub(crate) fn render_settings(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
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(),
};
let query = search.read(cx).value().trim().to_lowercase();
let show_theme_panel = theme_panel_open && section == SettingsSection::Appearance;
let prof = crate::ui::perf::enabled()
.then(|| (std::time::Instant::now(), section.profile_label()));
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
}
};
let nav_body = SidebarMenu::new()
.child(nav_item(
t(L10nKey::SettingsNavAppearance),
SettingsSection::Appearance,
Icon::new(IconName::Palette),
))
.child(nav_item(
t(L10nKey::SettingsNavTerminal),
SettingsSection::Terminal,
Icon::new(IconName::SquareTerminal),
))
.child(nav_item(
t(L10nKey::SettingsNavInput),
SettingsSection::Input,
Icon::new(IconName::Settings2),
))
.child(nav_item(
t(L10nKey::SettingsNavSsh),
SettingsSection::Ssh,
Icon::new(IconName::Globe),
))
.child(nav_item(
t(L10nKey::SettingsNavAgents),
SettingsSection::Agents,
Icon::new(IconName::Bot),
))
.child(nav_item(
t(L10nKey::SettingsNavWindowTabs),
SettingsSection::WindowTabs,
Icon::new(IconName::WindowRestore),
))
.child(nav_item(
t(L10nKey::SettingsNavKeybindings),
SettingsSection::Keybindings,
Icon::new(IconName::CaseSensitive),
))
.child(nav_item(
t(L10nKey::SettingsNavAbout),
SettingsSection::About,
Icon::empty().path("icons/circle-info.svg"),
));
let sidebar = Sidebar::new("settings-sidebar")
.collapsible(SidebarCollapsible::None)
.w(px(220.))
.header(
v_flex()
.w_full()
.px_2()
.gap_2()
.pt(px(crate::ui::app::TITLE_BAR_HEIGHT))
.pb_1()
.child(
div()
.text_xs()
.font_weight(FontWeight::MEDIUM)
.text_color(header_muted)
.child(t(L10nKey::SettingsHeader)),
)
.child(
h_flex()
.items_center()
.gap_2()
.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),
};
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()
.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);
}
}))
.child(sidebar)
.child(content_pane)
.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)))
.when(!show_theme_panel, |r| {
r.child(
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()
.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
}
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())
}
pub(crate) fn section_header(&self, title: &str, cx: &Context<Self>) -> Div {
self.header_text(title, cx).mb_4()
}
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()),
)
}
pub(crate) fn section_rule(&self, cx: &Context<Self>) -> Div {
div().h(px(1.)).my_7().bg(cx.theme().border)
}
pub(crate) fn settings_row(
&self,
label: impl Into<String>,
desc: impl Into<String>,
control: AnyElement,
cx: &Context<Self>,
) -> Stateful<Div> {
let theme = cx.theme();
let label = label.into();
let desc = desc.into();
// Descriptions can contain live status (for example an agent hook target), so they
// must not participate in the identity that preserves GPUI's hover state.
let element_id = settings_row_id(&label, &desc);
h_flex()
.id(element_id)
.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)))
.on_hover(cx.listener(|_this, _hovered, _window, cx| cx.notify()))
.child(
v_flex()
.gap_0p5()
.min_w_0()
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(theme.foreground)
.child(label),
)
.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))
}
pub(crate) fn segmented(
&self,
id: impl Into<SharedString>,
options: &[&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)
}
pub(crate) fn segmented_on(
&self,
sf: presets::Surface,
id: impl Into<SharedString>,
options: &[&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;
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)
.bg(gpui::rgb(sf.base))
.overflow_hidden()
.children(options.iter().enumerate().map(|(i, label)| {
let active = i == selected;
let on_pick = on_pick.clone();
let corners =
rounding::segment_corners(i, count, rounding::TRACK_RADIUS, rounding::HAIRLINE);
h_flex()
.id(gpui::ElementId::NamedInteger(id.clone(), i as u64))
.items_center()
.justify_center()
.h_full()
.px_2p5()
.text_sm()
.cursor_pointer()
.rounded_corners(corners)
.when(i > 0, |s| s.border_l_1().border_color(border))
.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)))
})
.active(|s| s.bg(gpui::rgb(sf.pressed)))
.child(label.to_string())
.on_click(cx.listener(move |this, _, window, cx| {
on_pick(this, i, window, cx);
}))
}))
.into_any_element()
}
fn render_settings_appearance(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let foreground = theme.foreground;
let border = theme.border;
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, language_select) =
match self.active_settings() {
Some(s) => (
s.font_select.clone(),
s.font_bold_select.clone(),
s.font_italic_select.clone(),
s.language_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)
});
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)
};
let control_h = px(24.);
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_hidden()
.child(dec)
.child(
div()
.min_w(px(40.))
.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(t(L10nKey::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(t(L10nKey::Reset))
.ghost()
.small()
.on_click(cx.listener(|this, _, _w, cx| this.reset_line_height(cx))),
);
let font_dropdown = |state: &Entity<SelectState<SearchableVec<String>>>| {
Select::new(state)
.small()
.w(px(180.))
.h(control_h)
.search_placeholder(crate::ui::i18n::t(crate::ui::i18n::L10nKey::SearchFonts))
.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 language_control = Select::new(&language_select)
.small()
.w(px(180.))
.h(control_h)
.menu_max_h(px(224.))
.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);
},
);
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(
t(L10nKey::SettingsThemeIntroTitle),
t(L10nKey::SettingsThemeIntroDesc),
cx,
))
.child(self.render_theme_selection(cx))
.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(t(L10nKey::SettingsLanguage), cx))
.child(self.settings_row(
t(L10nKey::SettingsLanguage),
t(L10nKey::SettingsLanguageDesc),
language_control,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsTypography), cx))
.child(self.settings_row(
t(L10nKey::SettingsFontSize),
t(L10nKey::SettingsFontSizeDesc),
font_size_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsLineHeight),
t(L10nKey::SettingsLineHeightDesc),
line_height_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsFontFamily),
t(L10nKey::SettingsFontFamilyDesc),
font_family_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsBoldFont),
t(L10nKey::SettingsBoldFontDesc),
font_bold_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsItalicFont),
t(L10nKey::SettingsItalicFontDesc),
font_italic_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsFontLigatures),
t(L10nKey::SettingsFontLigaturesDesc),
ligature_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsCursor), cx))
.child(self.settings_row(
t(L10nKey::SettingsCursorShape),
t(L10nKey::SettingsCursorShapeDesc),
cursor_style_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsCursorBlink),
t(L10nKey::SettingsCursorBlinkDesc),
blink_switch,
cx,
))
.into_any_element()
}
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(38.))
.flex_shrink_0()
.whitespace_nowrap()
.text_right()
.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()
.child(self.section_header(t(L10nKey::SettingsTransparency), cx))
.child(self.settings_row(
t(L10nKey::SettingsOpacity),
t(L10nKey::SettingsOpacityDesc),
opacity_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsBlur),
t(L10nKey::SettingsBlurDesc),
blur_switch,
cx,
))
.when(overridden, |this| {
this.child(
h_flex().mt_2().child(
Button::new("follow-theme-window")
.label(t(L10nKey::FollowTheme))
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.reset_window_overrides(window, cx)
})),
),
)
})
.child(self.settings_row(
t(L10nKey::SettingsDimInactivePanes),
t(L10nKey::SettingsDimInactivePanesDesc),
dim_switch,
cx,
))
.into_any_element()
}
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(t(L10nKey::SettingsOpenThemesFolder))
.small()
.on_click(cx.listener(|this, _, _w, cx| this.open_themes_folder(cx)));
if let Some(editor) = editor {
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();
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() {
t(L10nKey::SettingsChangeThemeImage)
} else {
t(L10nKey::SettingsChooseThemeImage)
})
.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(t(L10nKey::SettingsRemoveThemeImage))
.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(38.))
.flex_shrink_0()
.whitespace_nowrap()
.text_right()
.text_sm()
.text_color(cx.theme().foreground)
.child(format!("{:.0}%", readout * 100.)),
)
.into_any_element();
self.settings_row(
t(L10nKey::SettingsImageOpacity),
t(L10nKey::SettingsImageOpacityDesc),
control,
cx,
)
});
return v_flex()
.mt_5()
.child(self.section_intro(
t(L10nKey::SettingsEditTheme),
t(L10nKey::SettingsEditThemeIntro),
cx,
))
.children(
seed.into_iter()
.map(|(label, state)| self.render_theme_color_row(label, state, cx)),
)
.child(self.settings_row(
t(L10nKey::SettingsBackgroundImage),
t(L10nKey::SettingsBackgroundImageDesc),
image_control,
cx,
))
.children(image_opacity_row)
.child(self.section_header(t(L10nKey::SettingsAnsiColors), 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();
}
v_flex()
.mt_5()
.child(self.section_intro(
t(L10nKey::SettingsCustomThemes),
t(L10nKey::SettingsCustomThemesIntro),
cx,
))
.child(
h_flex()
.gap_3()
.child(
Button::new("duplicate-theme")
.label(t(L10nKey::SettingsDuplicateToEdit))
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.fork_active_theme(window, cx)
})),
)
.child(folder_button),
)
.into_any_element()
}
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)
}
fn render_settings_ssh(&self, cx: &mut Context<Self>) -> AnyElement {
let border = cx.theme().border;
h_flex()
.size_full()
.items_start()
.child(
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(
v_flex()
.id("ssh-detail")
.flex_1()
.h_full()
.overflow_y_scroll()
.child(
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()
}
fn render_ssh_master(&self, cx: &mut Context<Self>) -> AnyElement {
let muted = cx.theme().muted_foreground;
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();
let live = self.live_ssh_profiles(cx);
let menu_app = cx.entity().downgrade();
let header = v_flex()
.gap_2()
.child(self.header_text(t(L10nKey::SettingsHosts), cx))
.child(
h_flex()
.items_center()
.gap_2()
.child(
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")
.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)
},
),
),
),
);
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))
});
let mut list = v_flex().gap_0p5().w_full().child(self.render_ssh_row(
"ssh-defaults-row",
t(L10nKey::SettingsDefaults),
t(L10nKey::SettingsInheritedByEveryHost),
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(t(L10nKey::SettingsNoSavedHosts)),
);
} else if groups.is_empty() {
list = list.child(
div()
.py_4()
.text_sm()
.text_color(muted)
.child(t_fmt(L10nKey::SettingsNothingMatches, &[("query", &query)])),
);
}
for (key, bucket) in groups {
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()
.pt(px(crate::ui::app::TITLE_BAR_HEIGHT))
.child(header)
.child(list)
.into_any_element()
}
fn ssh_master_menu(menu: PopupMenu, app: &gpui::WeakEntity<Self>) -> PopupMenu {
menu.min_w(px(200.))
.item(
PopupMenuItem::new(t(L10nKey::SettingsImportFromSshConfig)).on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.import_ssh_config_profiles(cx));
}
}),
)
.item(
PopupMenuItem::new(t(L10nKey::SettingsExpandAllGroups)).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();
});
}
}),
)
}
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()
}
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,
)
}
#[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()
.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))
.when(!live, |d| d.border_1().border_color(border)),
)
})
.child(
v_flex()
.min_w_0()
.flex_1()
.gap_0p5()
.child(
div()
.text_sm()
.truncate()
.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()),
),
);
let Some(id) = menu_for else {
return row.into_any_element();
};
let menu_app = cx.entity().downgrade();
let ctx_app = cx.entity().downgrade();
let row_idx = id.as_u128() as usize;
row.child(
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()
}
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
}
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();
}
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();
}
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)
}
_ => self.render_ssh_empty_state(cx),
}
}
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();
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 {
t(L10nKey::SettingsNoHostsYet)
} else {
t(L10nKey::SettingsNothingSelected)
};
let mut body = v_flex()
.gap_1()
.child(self.header_text(heading, cx))
.child(
div()
.text_sm()
.text_color(muted)
.child(t(L10nKey::SettingsTypeAddressToConnect)),
)
.child(
h_flex()
.mt_3()
.gap_2()
.child(div().w(px(320.)).child(Input::new(&input).small()))
.child(
Button::new("ssh-quick-connect")
.label(t(L10nKey::Connect))
.primary()
.small()
.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(t_fmt(
L10nKey::SettingsMoreInSshConfig,
&[("count", &n.to_string())],
)))
.child(div().text_xs().text_color(muted).truncate().child(names)),
)
.child(
Button::new("ssh-empty-import")
.label(t(L10nKey::Link))
.small()
.on_click(
cx.listener(|this, _, _w, cx| this.import_ssh_config_profiles(cx)),
),
),
);
}
body.into_any_element()
}
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);
}
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",
t_plural(L10nKey::SettingsAliasesLinked, imported, &[]),
cx,
))
.child(
self.settings_row(
t(L10nKey::SettingsImportAliases),
t(L10nKey::SettingsImportAliasesDesc),
Button::new("ssh-defaults-import")
.label(t(L10nKey::SettingsImportNow))
.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(t(L10nKey::SettingsDefaults), cx))
.child(
div()
.text_sm()
.text_color(muted)
.child(t(L10nKey::SettingsDefaultsIntro)),
),
)
.child(self.render_ssh_security_block(cx))
.child(self.section_rule(cx))
.child(config_block)
.into_any_element()
}
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(t(L10nKey::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(t(L10nKey::SettingsCopyAddress)).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(t(L10nKey::SettingsDuplicate)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.duplicate_profile(id, window, cx));
}
}))
.item(
PopupMenuItem::new(t(L10nKey::SettingsForgetPassword)).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();
menu.item(
PopupMenuItem::element(move |_window, _cx| {
div().text_color(danger).child(t(L10nKey::Delete))
})
.on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.delete_profile(id, cx));
}
}),
)
}
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(
t(L10nKey::SettingsSecurity),
t(L10nKey::SettingsSecurityIntro),
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsVerifyHostKeys),
t(L10nKey::SettingsVerifyHostKeysDesc),
verify_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::WarnBeforeClosing),
t(L10nKey::SettingsWarnBeforeClosingDesc),
warn_switch,
cx,
))
.into_any_element()
}
fn ssh_form_mut(&mut self) -> Option<&mut SshProfileForm> {
self.active_settings_mut().and_then(|s| s.ssh_form.as_mut())
}
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);
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);
s.ssh_detail = SshDetail::Profile(editing);
}
cx.notify();
}
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,
})
}
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)
}
pub(crate) fn save_ssh_form(&mut self, cx: &mut Context<Self>) {
self.save_editing_profile(cx);
cx.notify();
}
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);
}
}
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);
}
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 = t_fmt(L10nKey::SettingsProfileCopied, &[("name", &profile.name)]);
self.update_config(cx, |cfg| cfg.ssh_profiles.push(profile.clone()));
self.ssh_form_load(&profile, window, cx);
}
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) {
s.ssh_form = None;
s.ssh_detail = SshDetail::None;
}
cx.notify();
}
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();
}
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));
}
}
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(()) => t_fmt(
L10nKey::SettingsForgotPasswordFor,
&[("endpoint", &endpoint)],
),
Err(e) => t_fmt(
L10nKey::SettingsCouldntForgetPassword,
&[("endpoint", &endpoint), ("error", &e.to_string())],
),
},
)
}
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;
let saved = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == editing)
.cloned();
let collected = self.ssh_form_collect(cx);
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) => t(L10nKey::SettingsNewHost).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(t_fmt(
L10nKey::SettingsJumpHostVia,
&[("jump_name", &jump_name)],
)))
})
.when(live, |r| {
r.child(
div()
.text_color(success)
.child(format!({}", t(L10nKey::SettingsConnected))),
)
}),
),
)
.child(
h_flex()
.flex_shrink_0()
.gap_2()
.child(
Button::new("ssh-form-save")
.label(t(L10nKey::Save))
.small()
.disabled(!dirty)
.on_click(cx.listener(|this, _, _w, cx| this.save_ssh_form(cx))),
)
.child(
Button::new("ssh-form-connect")
.label(t(L10nKey::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(
t(L10nKey::SettingsName),
t(L10nKey::SettingsNameDesc),
div()
.w(px(260.))
.child(Input::new(&form.name).small())
.into_any_element(),
cx,
),
)
.child(
self.settings_row(
t(L10nKey::SettingsHost),
t(L10nKey::SettingsHostDesc),
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(
t(L10nKey::SettingsUser),
t(L10nKey::SettingsUserDesc),
div()
.w(px(260.))
.child(Input::new(&form.user).small())
.into_any_element(),
cx,
),
)
.child(self.settings_row(
t(L10nKey::SettingsAuth),
t(L10nKey::SettingsAuthDesc),
self.segmented(
"ssh-form-auth",
&[
t(L10nKey::SettingsAuthModeAuto),
"GSSAPI",
t(L10nKey::SettingsAuthModePassword),
t(L10nKey::SettingsAuthModeKey),
t(L10nKey::SettingsAuthModeAgent),
t(L10nKey::SettingsAuthMode2Fa),
],
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()
}
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() {
t(L10nKey::SettingsNoneSummary).to_string()
} else {
name
}
};
let mut section = v_flex().child(self.disclosure_header(
"ssh-sec-jump",
t(L10nKey::SettingsJumpHost),
&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(
t(L10nKey::SettingsJumpHost),
t(L10nKey::SettingsJumpHostDesc),
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 => t(L10nKey::SettingsNoneLower).to_string(),
_ => t_plural(L10nKey::SettingsRulesOpenedWithConnection, count, &[]),
};
let mut section = v_flex().child(self.disclosure_header(
"ssh-sec-fwd",
t(L10nKey::SettingsPortForwarding),
&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(t(L10nKey::SettingsAddRule))
.ghost()
.small()
.on_click(
cx.listener(|this, _, window, cx| this.add_forward_rule(window, cx)),
),
),
)
.child(
h_flex()
.gap_3()
.pt_1()
.text_xs()
.text_color(muted)
.child(t(L10nKey::SettingsFwdLegendLocal))
.child(t(L10nKey::SettingsFwdLegendRemote))
.child(t(L10nKey::SettingsFwdLegendDynamic)),
)
.into_any_element()
}
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;
let needs_target = row.kind != ForwardKind::Dynamic;
let kind_idx = match row.kind {
ForwardKind::Local => 0,
ForwardKind::Remote => 1,
ForwardKind::Dynamic => 2,
};
let incomplete = row.collect(cx).is_none() && !row.is_blank(cx);
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 {
t(L10nKey::SettingsFwdNeedsBoth)
} else {
t(L10nKey::SettingsFwdNeedsListen)
}))
})
.into_any_element()
}
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();
}
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",
t(L10nKey::SettingsAdvanced),
t(L10nKey::SettingsAdvancedSummary),
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(),
div()
.w(px(260.))
.child(Input::new(input).small())
.into_any_element(),
cx,
)
};
let on_off = |b: bool| {
if b {
t(L10nKey::SettingsValueOn)
} else {
t(L10nKey::SettingsValueOff)
}
};
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,
t(L10nKey::SettingsIdentityFiles),
t(L10nKey::SettingsIdentityFilesDesc),
&form.identity_files,
cx,
))
.child(
self.settings_row(
t(L10nKey::SettingsAgentForwarding),
t(L10nKey::SettingsAgentForwardingDesc),
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,
t(L10nKey::SettingsProxyCommand),
t(L10nKey::SettingsProxyCommandDesc),
&form.proxy_command,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsSocks5Proxy),
t(L10nKey::SettingsSocks5ProxyDesc),
&form.socks,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsHttpProxy),
t(L10nKey::SettingsHttpProxyDesc),
&form.http,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsKexAlgorithms),
t(L10nKey::SettingsKexAlgorithmsDesc),
&form.kex,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsCiphers),
t(L10nKey::SettingsCiphersDesc),
&form.cipher,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsMacs),
t(L10nKey::SettingsMacsDesc),
&form.mac,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsHostKeyAlgorithms),
t(L10nKey::SettingsHostKeyAlgorithmsDesc),
&form.hostkey,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsCompression),
t(L10nKey::SettingsCompressionDesc),
&form.compression,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsKeepaliveInterval),
t(L10nKey::SettingsKeepaliveIntervalDesc),
&form.keepalive_interval,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsKeepaliveCountMax),
t(L10nKey::SettingsKeepaliveCountMaxDesc),
&form.keepalive_count,
cx,
))
.child(text_row(
self,
t(L10nKey::SettingsConnectTimeout),
t(L10nKey::SettingsConnectTimeoutDesc),
&form.connect_timeout,
cx,
))
.child(
self.settings_row(
t(L10nKey::SettingsX11Forwarding),
t(L10nKey::SettingsX11ForwardingDesc),
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(
t(L10nKey::SettingsShellIntegration),
t(L10nKey::SettingsShellIntegrationDesc),
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,
t(L10nKey::SettingsLoginScripts),
t(L10nKey::SettingsLoginScriptsDesc),
&form.login_scripts,
cx,
))
.child(
self.settings_row(
t(L10nKey::SettingsSkipBanner),
t(L10nKey::SettingsSkipBannerDesc),
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(
t(L10nKey::SettingsVerifyHostKeys),
t_fmt(
L10nKey::SettingsDefaultFollowsDefaults,
&[("value", vhk_default)],
),
self.segmented(
"ssh-form-vhk",
&[
t(L10nKey::SettingsDefault),
t(L10nKey::SettingsOn),
t(L10nKey::SettingsOff),
],
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(
t(L10nKey::WarnBeforeClosing),
t_fmt(
L10nKey::SettingsDefaultFollowsDefaults,
&[("value", woc_default)],
),
self.segmented(
"ssh-form-woc",
&[
t(L10nKey::SettingsDefault),
t(L10nKey::SettingsOn),
t(L10nKey::SettingsOff),
],
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()
}
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;
let platform_default = if cfg!(windows) {
"PowerShell"
} else {
t(L10nKey::SettingsShellDefaultLoginShell)
};
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",
&[
t(L10nKey::SettingsWdInherit),
t(L10nKey::SettingsWdHome),
t(L10nKey::SettingsWdCustom),
],
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);
},
);
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(
t(L10nKey::SettingsShell),
t_fmt(
L10nKey::SettingsShellIntro,
&[("default", platform_default)],
),
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsProgram),
t(L10nKey::SettingsProgramDesc),
program_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsArguments),
t(L10nKey::SettingsArgumentsDesc),
args_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsStartIn),
t(L10nKey::SettingsStartInDesc),
wd_radio,
cx,
))
.when(
wd_strategy == crate::core::config::WdStrategy::Custom,
|v| {
v.child(self.settings_row(
t(L10nKey::SettingsCustomPath),
t(L10nKey::SettingsCustomPathDesc),
wd_path_control,
cx,
))
},
)
.child(
div()
.mt_3()
.text_xs()
.text_color(muted_fg)
.child(t(L10nKey::SettingsShellFooter)),
)
.into_any_element()
}
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;
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,
BellMode::Both => 3,
};
let bell_control = self.segmented(
"term-bell",
&[
t(L10nKey::SettingsBellModeOff),
t(L10nKey::SettingsBellModeVisual),
t(L10nKey::SettingsBellModeAudible),
t(L10nKey::SettingsBellModeBoth),
],
bell_idx,
cx,
|this, ix, _w, cx| {
let mode = match ix {
0 => BellMode::None,
1 => BellMode::Visual,
2 => BellMode::Audible,
3 => BellMode::Both,
_ => BellMode::default(),
};
this.set_bell_mode(mode, cx);
},
);
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(38.))
.flex_shrink_0()
.whitespace_nowrap()
.text_right()
.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(t(L10nKey::SettingsScrolling), cx))
.child(self.settings_row(
t(L10nKey::SettingsScrollback),
t(L10nKey::SettingsScrollbackDesc),
scrollback_radio,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsScrollSpeed),
t(L10nKey::SettingsScrollSpeedDesc),
scroll_control,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsMouse), cx))
.child(self.settings_row(
t(L10nKey::SettingsFocusFollowsMouse),
t(L10nKey::SettingsFocusFollowsMouseDesc),
focus_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsHideMouseWhileTyping),
t(L10nKey::SettingsHideMouseWhileTypingDesc),
mouse_hide_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsReportMouseToApps),
t(L10nKey::SettingsReportMouseToAppsDesc),
mouse_report_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsBell), cx))
.child(self.settings_row(
t(L10nKey::SettingsTerminalBell),
t(L10nKey::SettingsTerminalBellDesc),
bell_control,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsLinks), cx))
.child(self.settings_row(
t(L10nKey::DetectUrls),
t_fmt(
L10nKey::SettingsDetectUrlsDesc,
&[("modifier", LINK_MODIFIER_LABEL)],
),
link_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::ForwardSshLoopbackLinks),
t(L10nKey::SettingsForwardSshLoopbackLinksDesc),
ssh_loopback_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::OpenFilesWith),
t_fmt(
L10nKey::SettingsOpenFilesWithDesc,
&[
("modifier", LINK_MODIFIER_LABEL),
("path", "{path}"),
("line", "{line}"),
("column", "{column}"),
],
),
link_file_command_control,
cx,
))
.into_any_element()
}
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();
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(
t(L10nKey::SettingsOptionAsMeta),
t(L10nKey::SettingsOptionAsMetaDesc),
switch,
cx,
)
});
v_flex()
.child(self.section_intro(
t(L10nKey::SettingsPrompt),
t(L10nKey::SettingsPromptIntro),
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsTabCompletion),
t(L10nKey::SettingsTabCompletionDesc),
tab_completion_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsHistorySearch),
t(L10nKey::SettingsHistorySearchDesc),
history_search_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsSelectionClipboard), cx))
.child(self.settings_row(
t(L10nKey::SettingsSmartSelection),
t(L10nKey::SettingsSmartSelectionDesc),
smart_select_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsCopyOnSelect),
t(L10nKey::SettingsCopyOnSelectDesc),
copy_on_select_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsTrimTrailingSpaces),
t(L10nKey::SettingsTrimTrailingSpacesDesc),
trim_switch,
cx,
))
.when_some(option_alt_row, |v, row| {
v.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsKeyboard), cx))
.child(row)
})
.into_any_element()
}
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(
t(L10nKey::SettingsAgentsIntro),
t(L10nKey::SettingsAgentsIntroDesc),
cx,
));
page = page.children(self.agent_hooks_machine_picker(selected_host, cx));
// The hook rows describe whichever machine is selected above; the
// command-line section below is always about this GUI's own host, so it
// is appended after the match rather than inside the ready arm.
match view {
AgentHooksView::Loading => {
page = page.child(
div()
.py_4()
.text_sm()
.text_color(muted_fg)
.child(t(L10nKey::SettingsReadingAgentConfig)),
);
}
AgentHooksView::Unavailable(reason) => {
page = page.child(div().py_4().text_sm().text_color(warning).child(reason));
}
AgentHooksView::Ready(rows) => {
for (i, row) in rows.into_iter().enumerate() {
let agent = row.agent;
let (dot_color, status_text) = match row.state {
HooksState::NotInstalled => {
(muted_fg, t(L10nKey::SettingsStatusNotInstalled))
}
HooksState::Installed => (success, t(L10nKey::SettingsStatusInstalled)),
HooksState::Outdated => (warning, t(L10nKey::SettingsStatusOutdated)),
};
let primary_label = match row.state {
HooksState::NotInstalled => t(L10nKey::SettingsInstall),
HooksState::Installed => t(L10nKey::SettingsReinstall),
HooksState::Outdated => t(L10nKey::SettingsUpdate),
};
let row_note = note
.as_ref()
.filter(|(for_agent, _)| *for_agent == agent)
.map(|(_, text)| text.clone());
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(t(L10nKey::SettingsUninstall))
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
this.settings_uninstall_agent_hooks(agent, cx)
})),
)
}),
)
.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,
));
}
}
}
let install_cli_on_path = cx.global::<Config>().install_cli_on_path;
page.child(
v_flex()
.mt_6()
.gap_2()
.child(self.section_rule(cx))
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child(t(L10nKey::SettingsCommandLine)),
)
.child(
div()
.text_sm()
.text_color(muted_fg)
.child(t(L10nKey::SettingsCommandLineDesc)),
)
.child(
h_flex()
.gap_2()
.items_center()
.child(
crate::ui::theme::switch("install-cli-on-path", cx)
.checked(install_cli_on_path)
.on_click(cx.listener(|this, on: &bool, _w, cx| {
this.set_install_cli_on_path(*on, cx)
})),
)
.child(
div()
.text_sm()
.text_color(foreground)
.child(t(L10nKey::SettingsInstallCliOnPath)),
),
),
)
.into_any_element()
}
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()
.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)
}))
})),
)
.when(offline > 0, |col| {
col.child(div().text_xs().text_color(muted_fg).child(t_plural(
L10nKey::SettingsOfflineMachines,
offline,
&[],
)))
}),
)
}
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 taskbar_status_icon = cfg.taskbar_status_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,
};
let notify_idx = match cfg.notify_on_command_finish {
NotifyMode::Never => 0,
NotifyMode::Unfocused => 1,
NotifyMode::Always => 2,
};
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",
&[
t(L10nKey::NotifyModeNever),
t(L10nKey::NotifyModeUnfocused),
t(L10nKey::NotifyModeAlways),
],
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 taskbar_switch = crate::ui::theme::switch("wt-taskbar-status", cx)
.checked(taskbar_status_icon)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_taskbar_status_icon(*on, cx)))
.into_any_element();
let startup_radio = self.segmented(
"wt-startup",
&[
t(L10nKey::SettingsStartupNormal),
t(L10nKey::SettingsStartupMaximized),
t(L10nKey::SettingsStartupFullscreen),
],
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",
&[t(L10nKey::SettingsAfterCurrent), t(L10nKey::SettingsAtEnd)],
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",
&[t(L10nKey::SettingsTop), t(L10nKey::SettingsLeft)],
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",
&[t(L10nKey::SettingsByRepo), t(L10nKey::SettingsFlat)],
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(t(L10nKey::SettingsWindow), cx))
.child(self.settings_row(
t(L10nKey::SettingsStartupWindow),
t(L10nKey::SettingsStartupWindowDesc),
startup_radio,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsRememberWindowSize),
t(L10nKey::SettingsRememberWindowSizeDesc),
remember_window_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsRestoreLastLayout),
t(L10nKey::SettingsRestoreLastLayoutDesc),
restore_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsConfirmLastWindowClose),
t(L10nKey::SettingsConfirmLastWindowCloseDesc),
confirm_close_switch,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsShowTrayIcon),
t(L10nKey::SettingsShowTrayIconDesc),
tray_switch,
cx,
))
// Windows only: `SetOverlayIcon` is a taskbar concept. The row is
// simply absent elsewhere rather than shown disabled — a switch
// that can never do anything is noise, not a setting.
.children(cfg!(windows).then(|| {
self.settings_row(
t(L10nKey::SettingsTaskbarStatusIcon),
t(L10nKey::SettingsTaskbarStatusIconDesc),
taskbar_switch,
cx,
)
}))
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsTabs), cx))
.child(self.settings_row(
t(L10nKey::SettingsNewTabPosition),
t(L10nKey::SettingsNewTabPositionDesc),
new_tab_radio,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsTabBarPosition),
t(L10nKey::SettingsTabBarPositionDesc),
tab_bar_radio,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsSidebarGrouping),
t(L10nKey::SettingsSidebarGroupingDesc),
sidebar_grouping_radio,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsDiffPreviewFromCounts),
t(L10nKey::SettingsDiffPreviewFromCountsDesc),
sidebar_diff_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsNotifications), cx))
.child(self.settings_row(
t(L10nKey::SettingsNotifyOnCommandFinish),
t(L10nKey::SettingsNotifyOnCommandFinishDesc),
notify_radio,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsNotifyThreshold),
t(L10nKey::SettingsNotifyThresholdDesc),
threshold_radio,
cx,
))
.into_any_element()
}
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);
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)),
)
}
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(
t(L10nKey::SettingsSyncWithSystem),
t(L10nKey::SettingsSyncWithSystemDesc),
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()
}
}
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();
let kind = if active.path.is_some() {
t(L10nKey::SettingsCustom)
} else {
t(L10nKey::SettingsBuiltIn)
};
let mode = if active.dark {
t(L10nKey::SettingsDark)
} else {
t(L10nKey::SettingsLight)
};
let mode_label = if active.dark {
t(L10nKey::SettingsDarkMode)
} else {
t(L10nKey::SettingsLightMode)
};
let caption = match slot {
ThemeSlot::Manual => format!("{kind} · {mode}"),
ThemeSlot::Light if !crate::ui::theme::system_dark(cx) => {
format!("{mode_label} · {kind} · {}", t(L10nKey::SettingsActive))
}
ThemeSlot::Light => format!("{mode_label} · {kind}"),
ThemeSlot::Dark if crate::ui::theme::system_dark(cx) => {
format!("{mode_label} · {kind} · {}", t(L10nKey::SettingsActive))
}
ThemeSlot::Dark => format!("{mode_label} · {kind}"),
};
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(t(L10nKey::SettingsChangeTheme))
.child(Icon::new(IconName::ChevronRight).small()),
),
)
.into_any_element()
}
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;
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>();
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(t(L10nKey::SettingsThemes)),
)
.child(
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 => t(L10nKey::SettingsThemePanelManual),
ThemeSlot::Light => t(L10nKey::SettingsThemePanelLight),
ThemeSlot::Dark => t(L10nKey::SettingsThemePanelDark),
});
let search_box = div().px_4().pb_3().child(
div().w(px(268.)).child(
Input::new(&search).small().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;
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(
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()
}
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,
)
};
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);
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());
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)
};
let preset_control = self.segmented(
"kb-preset",
&[t(L10nKey::SettingsDefault), "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(t(L10nKey::SettingsPreset)),
)
.child(
div()
.text_xs()
.text_color(muted)
.child(t(L10nKey::SettingsPresetDesc)),
),
)
.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(t(L10nKey::SettingsPrefix)),
)
.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);
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))),
)
};
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(t(L10nKey::SettingsPressKeys)),
)
} else {
row.child(keycaps(&chords.join(" "))).child(
div()
.text_xs()
.text_color(muted)
.child(t(L10nKey::SettingsPauseToSaveEsc)),
)
};
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()
};
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(t(L10nKey::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(
t(L10nKey::SettingsNavKeybindings),
t(L10nKey::SettingsKeybindingsIntroDesc),
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(t(L10nKey::SettingsPrefixNote)),
)
})
.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(t(L10nKey::SettingsRestoreAllDefaults))
.small()
.on_click(
cx.listener(|this, _, _w, cx| this.restore_default_keybindings(cx)),
),
),
)
.child(list)
.into_any_element()
}
fn render_settings_about(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground);
let update_status = cx
.try_global::<crate::core::update::UpdateStatus>()
.cloned()
.unwrap_or_default();
let update = update_status.available.clone();
let update_busy = matches!(
update_status.phase,
crate::core::update::UpdatePhase::Checking
| crate::core::update::UpdatePhase::Downloading
| crate::core::update::UpdatePhase::Installing
);
let phase_text = localized_update_phase(&update_status.phase);
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(t(L10nKey::SettingsNavAbout), 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!(
"{} {}",
t(L10nKey::SettingsVersion),
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(
div()
.mt_4()
.text_sm()
.text_color(muted_fg)
.child(t(L10nKey::SettingsAboutDesc1)),
)
.child(
v_flex()
.mt_6()
.gap_2()
.child(self.section_rule(cx))
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child(t(L10nKey::SettingsUpdates)),
)
.when_some(update, |this, upd| {
let button_label = if upd.installable {
t(L10nKey::SettingsUpdateAndRelaunch).to_string()
} else {
t(L10nKey::SettingsUpdateViewRelease).to_string()
};
let availability = t_fmt(
L10nKey::SettingsVersionAvailable,
&[("version", &upd.version)],
);
this.child(
v_flex()
.gap_1()
.child(
h_flex()
.gap_3()
.items_center()
.child(
div()
.text_sm()
.text_color(foreground)
.child(availability),
)
.child(
Button::new("install-update")
.label(button_label)
.small()
.disabled(update_busy)
.on_click(cx.listener(|_, _, _window, cx| {
crate::core::update::install_available(cx)
})),
),
)
.when_some(upd.install_hint, |this, hint| {
this.child(
div()
.text_xs()
.text_color(muted_fg)
.child(localized_update_install_hint(&hint)),
)
}),
)
})
.when_some(phase_text, |this, text| {
this.child(div().text_sm().text_color(muted_fg).child(text))
})
.child(
h_flex().child(
Button::new("check-update-now")
.label(
if matches!(
update_status.phase,
crate::core::update::UpdatePhase::Checking
) {
t(L10nKey::SettingsUpdateChecking)
} else {
t(L10nKey::SettingsUpdateCheckNow)
},
)
.small()
.disabled(update_busy)
.on_click(cx.listener(|_, _, _window, cx| {
crate::core::update::spawn_check_forced(cx)
})),
),
)
.child(
self.settings_row(
t(L10nKey::SettingsCheckUpdatesOnLaunch),
t(L10nKey::SettingsCheckUpdatesDesc),
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)
}))
.into_any_element(),
cx,
),
),
)
.child(
v_flex()
.mt_6()
.gap_2()
.child(self.section_rule(cx))
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child(t(L10nKey::SettingsServer)),
)
.child(
div()
.text_sm()
.text_color(muted_fg)
.child(t(L10nKey::SettingsServerDesc)),
)
.child(
h_flex().child(
Button::new("restart-daemon")
.label(t(L10nKey::SettingsRestartServer))
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.restart_daemon(window, cx)
})),
),
),
)
.child(
div()
.mt_8()
.text_xs()
.text_color(muted_fg)
.child(t(L10nKey::SettingsAboutTech)),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_row_identity_depends_only_on_its_stable_label() {
assert_eq!(
settings_row_id("Claude Code", "Installing…"),
settings_row_id("Claude Code", "Installed in C:\\tools")
);
assert_ne!(
settings_row_id("Claude Code", "Installed"),
settings_row_id("Codex", "Installed")
);
}
#[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()
);
}
}
#[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 = t(entry.title).to_lowercase();
let landed = best_matching_section(&query);
assert!(
landed.is_some(),
"query {query:?} matched nothing at all (section {:?})",
section.profile_label()
);
}
}
#[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),
("nushell", Terminal),
("open files with", Terminal),
("bell", Terminal),
("known_hosts", Ssh),
("claude", Agents),
("symlink", 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 `tty7` CLI exists so scripts and coding agents can drive tty7, so it
/// lives with the other agent integrations rather than under About.
#[test]
fn command_line_tool_is_searchable_under_agents() {
let entry = settings_search_entries()
.iter()
.find(|entry| entry.title == L10nKey::SettingsSearchCommandLineToolTitle)
.expect("the CLI setting should be searchable");
assert_eq!(entry.section.profile_label(), "settings:agents");
}
#[test]
fn close_confirmation_toggle_is_findable() {
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"
);
}
}
#[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",
"Option (⌥) acts as Meta",
] {
assert!(
settings_search_entries()
.iter()
.any(|e| t(e.title) == title),
"no index entry titled {title:?}"
);
}
}
#[test]
fn agent_rows_are_in_the_search_index() {
for agent in crate::core::agent_hooks::HookAgent::ALL {
assert!(
settings_search_entries()
.iter()
.any(|e| e.section == SettingsSection::Agents
&& t(e.title) == agent.display_name()),
"no Agents index entry titled {:?}",
agent.display_name()
);
}
}
#[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");
}
#[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"));
}
#[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"));
}
#[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", ""]
);
}
#[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");
}
#[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);
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);
});
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)
}
#[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();
app.update_in(&mut vcx, |app, _, cx| {
if let Some(s) = app.active_settings_mut() {
s.theme_panel_open = true;
}
cx.notify();
});
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",
);
}
}