mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Four update strings called it a service — including a "Restart Service" button that fires the same action, on the same page, as "Restart server…" two sections below. Every other line in the app, the menu bar and the README say server. Fold the two buttons into one string and say server in English, Chinese and Japanese.
5663 lines
210 KiB
Rust
5663 lines
210 KiB
Rust
use gpui::{
|
||
Animation, AnimationExt as _, 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, UpdateChannel,
|
||
};
|
||
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,
|
||
}
|
||
|
||
use crate::core::update::{localized_update_install_hint, localized_update_phase};
|
||
|
||
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: SettingsShowTrayIcon,
|
||
keywords: SettingsSearchShowTrayIconKeywords,
|
||
},
|
||
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: SettingsAppHttpProxy,
|
||
keywords: SettingsSearchAppHttpProxyKeywords,
|
||
},
|
||
SearchEntry {
|
||
section: About,
|
||
title: SettingsSearchHowShellsWorkTitle,
|
||
keywords: SettingsSearchHowShellsWorkKeywords,
|
||
},
|
||
SearchEntry {
|
||
section: Agents,
|
||
title: SettingsInstallCliOnPath,
|
||
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()
|
||
}
|
||
|
||
/// Whether a rendered row is one of the ones the section's `(n)` badge counted.
|
||
/// A row can match on its own label, or through the keyword list the search
|
||
/// index carries for it — "persist" finds "How shells work" and nothing on that
|
||
/// page contains the word.
|
||
fn row_matches_query(section: SettingsSection, label: &str, query: &str) -> bool {
|
||
if query.is_empty() {
|
||
return false;
|
||
}
|
||
if label.to_lowercase().contains(query) {
|
||
return true;
|
||
}
|
||
settings_search_entries()
|
||
.iter()
|
||
.any(|e| e.section == section && t(e.title) == label && entry_matches(e, query))
|
||
}
|
||
|
||
pub(crate) fn total_match_count(query: &str) -> usize {
|
||
SettingsSection::ALL
|
||
.into_iter()
|
||
.map(|s| section_match_count(s, query))
|
||
.sum()
|
||
}
|
||
|
||
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) http_proxy_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 note_bg = theme.secondary.opacity(0.5);
|
||
|
||
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),
|
||
};
|
||
|
||
// A query that matches nothing anywhere leaves the nav badge-less and
|
||
// `autoselect_settings_search` with nowhere to go, so without this the
|
||
// page just sits there looking like the search did nothing.
|
||
let no_match_note = (!query.is_empty() && total_match_count(&query) == 0).then(|| {
|
||
div()
|
||
.mb_6()
|
||
.px_3()
|
||
.py_2()
|
||
.rounded_lg()
|
||
.bg(note_bg)
|
||
.text_sm()
|
||
.text_color(header_muted)
|
||
.child(t_fmt(
|
||
L10nKey::SettingsNothingMatches,
|
||
&[("query", query.as_str())],
|
||
))
|
||
});
|
||
|
||
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.))
|
||
.children(no_match_note)
|
||
.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_checked(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()
|
||
.tooltip(t(L10nKey::Close))
|
||
.on_click(cx.listener(|this, _, window, cx| {
|
||
this.close_settings_checked(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())
|
||
}
|
||
|
||
/// A heading *inside* a section — quieter than `section_header`, for
|
||
/// breaking a long run of rows into groups you can scan.
|
||
fn subgroup_header(&self, key: L10nKey, cx: &Context<Self>) -> Div {
|
||
div()
|
||
.pt_4()
|
||
.pb_1()
|
||
.text_xs()
|
||
.font_weight(FontWeight::MEDIUM)
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(t(key))
|
||
}
|
||
|
||
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);
|
||
// The nav badge says "Appearance (2)"; this is what makes those two
|
||
// findable once you are on the page. Only mark rows when the section
|
||
// actually holds a match — otherwise a query that landed elsewhere
|
||
// would grey out a page the user is simply reading.
|
||
let (hit, miss) = match self.active_settings() {
|
||
Some(s) => {
|
||
let query = s.search.read(cx).value().trim().to_lowercase();
|
||
match query.is_empty() || section_match_count(s.section, &query) == 0 {
|
||
true => (false, false),
|
||
false => {
|
||
let hit = row_matches_query(s.section, &label, &query);
|
||
(hit, !hit)
|
||
}
|
||
}
|
||
}
|
||
None => (false, false),
|
||
};
|
||
h_flex()
|
||
.id(element_id)
|
||
.items_center()
|
||
.justify_between()
|
||
.gap_8()
|
||
.py_2()
|
||
.px_2p5()
|
||
.mx_neg_2p5()
|
||
.rounded_lg()
|
||
.when(hit, |row| row.bg(theme.accent.opacity(0.16)))
|
||
.when(miss, |row| row.opacity(0.45))
|
||
.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(w, 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()
|
||
// Without this the flex default `min-width: auto` keeps the
|
||
// column at its content width, so a narrow window pushes the
|
||
// form off the right edge instead of squeezing the labels.
|
||
.min_w_0()
|
||
.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()
|
||
.tooltip(t(L10nKey::TabTooltipMore))
|
||
.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()
|
||
.tooltip(t(L10nKey::TabTooltipMore))
|
||
.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, window, 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();
|
||
}
|
||
|
||
/// Whether the SSH profile form on screen holds edits that were never
|
||
/// saved. Save is enabled off exactly this, so closing on it is the same
|
||
/// question the button already answers.
|
||
pub(crate) fn ssh_form_dirty(&self, cx: &App) -> bool {
|
||
let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else {
|
||
return false;
|
||
};
|
||
let saved = cx
|
||
.global::<Config>()
|
||
.ssh_profiles
|
||
.iter()
|
||
.find(|p| p.id == form.editing)
|
||
.cloned();
|
||
self.ssh_form_collect(cx) != saved
|
||
}
|
||
|
||
/// Closing from Escape or the X is the user leaving; every other caller
|
||
/// closes as the tail of something they explicitly chose, and has already
|
||
/// saved or does not care.
|
||
pub(crate) fn close_settings_checked(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
if !self.ssh_form_dirty(cx) {
|
||
self.close_settings(window, cx);
|
||
return;
|
||
}
|
||
let answer = window.prompt(
|
||
gpui::PromptLevel::Warning,
|
||
t(L10nKey::SettingsDiscardChangesTitle),
|
||
Some(t(L10nKey::SettingsDiscardChangesBody)),
|
||
&crate::ui::confirm_answers(t(L10nKey::EditorDiscard), t(L10nKey::SettingsKeepEditing)),
|
||
cx,
|
||
);
|
||
cx.spawn_in(window, async move |this, cx| {
|
||
let Ok(0) = answer.await else { return };
|
||
let _ = this.update_in(cx, |this, window, cx| this.close_settings(window, cx));
|
||
})
|
||
.detach();
|
||
}
|
||
|
||
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, window: &mut Window, cx: &mut Context<Self>) {
|
||
let Some(name) = cx
|
||
.global::<Config>()
|
||
.ssh_profiles
|
||
.iter()
|
||
.find(|p| p.id == id)
|
||
.map(|p| p.name.clone())
|
||
else {
|
||
return;
|
||
};
|
||
let answer = window.prompt(
|
||
gpui::PromptLevel::Warning,
|
||
&t_fmt(L10nKey::FileTreeDeleteTitle, &[("name", &name)]),
|
||
Some(t(L10nKey::SettingsDeleteProfileBody)),
|
||
&crate::ui::confirm_answers(t(L10nKey::Delete), t(L10nKey::Cancel)),
|
||
cx,
|
||
);
|
||
cx.spawn_in(window, async move |this, cx| {
|
||
let Ok(0) = answer.await else { return };
|
||
let _ = this.update(cx, |this, cx| this.delete_profile_confirmed(id, cx));
|
||
})
|
||
.detach();
|
||
}
|
||
|
||
fn delete_profile_confirmed(&mut self, id: Uuid, cx: &mut Context<Self>) {
|
||
// "Forget password" lives on the menu that is about to stop existing,
|
||
// so deleting the profile used to strand its keychain entry with no UI
|
||
// left to remove it. Only let go of the secret when nothing else on the
|
||
// list still points at the same endpoint.
|
||
let cfg = cx.global::<Config>();
|
||
let endpoint = cfg
|
||
.ssh_profiles
|
||
.iter()
|
||
.find(|p| p.id == id)
|
||
.map(|p| (p.user.clone(), p.host.clone(), p.port));
|
||
let shared = endpoint.as_ref().is_some_and(|(user, host, port)| {
|
||
cfg.ssh_profiles
|
||
.iter()
|
||
.any(|p| p.id != id && (&p.user, &p.host, p.port) == (user, host, *port))
|
||
});
|
||
if let Some((user, host, port)) = endpoint.filter(|_| !shared) {
|
||
use crate::core::keychain::{CredentialStore, OsCredentialStore};
|
||
let _ = OsCredentialStore.delete_password(&user, &host, port);
|
||
}
|
||
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()
|
||
// Was pinned at 104px, which does not hold
|
||
// `ip-10-0-3-217.eu-west-1.compute.internal`. Same floor, but
|
||
// the field now takes a share of the row's slack instead of
|
||
// handing all of it to the free-text description beside it.
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_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(
|
||
div()
|
||
.flex_1()
|
||
.child(endpoint(&row.bind_host, &row.bind_port)),
|
||
)
|
||
.child(div().flex_shrink_0().text_xs().text_color(muted).child("→"))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.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(
|
||
crate::ui::tab_strip::hit_target(
|
||
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(self.subgroup_header(L10nKey::SettingsGroupAuthentication, cx))
|
||
.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(self.subgroup_header(L10nKey::SettingsGroupProxies, 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(self.subgroup_header(L10nKey::SettingsGroupAlgorithms, 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(self.subgroup_header(L10nKey::SettingsGroupConnection, 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.subgroup_header(L10nKey::SettingsGroupSession, 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.subgroup_header(L10nKey::SettingsGroupSecurity, 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 smooth_scroll = cfg.smooth_scroll;
|
||
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();
|
||
let smooth_scroll_switch = crate::ui::theme::switch("term-smooth-scroll", cx)
|
||
.checked(smooth_scroll)
|
||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smooth_scroll(*on, cx)))
|
||
.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.settings_row(
|
||
t(L10nKey::SettingsSmoothScroll),
|
||
t(L10nKey::SettingsSmoothScrollDesc),
|
||
smooth_scroll_switch,
|
||
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;
|
||
let cli_switch = 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)));
|
||
// Built from the same pieces as every other setting in the app: a
|
||
// section header, then a row whose label and description sit left of
|
||
// its control. Hand-rolled, this was the one switch that stood to the
|
||
// left of its own label — and the one row the settings search could
|
||
// neither highlight nor dim, so a query that counted it in the nav
|
||
// badge left nothing on the page looking like the match.
|
||
page.child(self.section_rule(cx))
|
||
.child(self.section_header(t(L10nKey::SettingsCommandLine), cx))
|
||
.child(self.settings_row(
|
||
t(L10nKey::SettingsInstallCliOnPath),
|
||
t(L10nKey::SettingsCommandLineDesc),
|
||
cli_switch.into_any_element(),
|
||
cx,
|
||
))
|
||
.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 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 tray_switch = crate::ui::theme::switch("wt-tray-icon", cx)
|
||
.checked(show_tray_icon)
|
||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_show_tray_icon(*on, cx)))
|
||
.into_any_element();
|
||
let startup_radio = self.segmented(
|
||
"wt-startup",
|
||
&[
|
||
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::SettingsShowTrayIcon),
|
||
t(L10nKey::SettingsShowTrayIconDesc),
|
||
tray_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),
|
||
),
|
||
),
|
||
);
|
||
|
||
// A theme file that fails to parse used to log a warning and then just
|
||
// not be in the list. This is a folder the user opens and drops files
|
||
// into; "it isn't there" needs a reason attached to it.
|
||
let rejected = presets::rejected(cx);
|
||
let rejected_note = (!rejected.is_empty() && query.is_empty()).then(|| {
|
||
let mut note = v_flex()
|
||
.mx_4()
|
||
.mb_4()
|
||
.p_3()
|
||
.gap_1p5()
|
||
.rounded(rounding::TRACK_RADIUS)
|
||
.border_1()
|
||
.border_color(theme.danger.opacity(0.4))
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.font_weight(FontWeight::MEDIUM)
|
||
.text_color(foreground)
|
||
.child(t(L10nKey::SettingsThemesRejected)),
|
||
);
|
||
for (name, why) in &rejected {
|
||
note = note.child(
|
||
v_flex()
|
||
.gap_0p5()
|
||
.child(div().text_xs().text_color(theme.danger).child(name.clone()))
|
||
.child(div().text_xs().text_color(muted_fg).child(why.clone())),
|
||
);
|
||
}
|
||
note
|
||
});
|
||
|
||
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()
|
||
.children(rejected_note)
|
||
.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 record_gen = self.record_gen;
|
||
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));
|
||
|
||
// Eighty-nine undivided rows, in the order the binding table happens to
|
||
// be written. Read them in the same seven sections the command palette
|
||
// uses, so a shortcut is found by where it belongs rather than by
|
||
// scrolling.
|
||
let mut grouped: Vec<(
|
||
crate::ui::palette::CommandGroup,
|
||
Vec<(String, String, String)>,
|
||
)> = Vec::new();
|
||
for (action, key) in effective {
|
||
let (group, label) = crate::ui::keymap::action_entry(&action);
|
||
let slot = match grouped.iter_mut().find(|(g, _)| *g == group) {
|
||
Some(slot) => slot,
|
||
None => {
|
||
grouped.push((group, Vec::new()));
|
||
grouped.last_mut().expect("just pushed")
|
||
}
|
||
};
|
||
slot.1.push((action, key, label));
|
||
}
|
||
grouped.sort_by_key(|(g, _)| {
|
||
crate::ui::palette::CommandGroup::ORDER
|
||
.iter()
|
||
.position(|o| o == g)
|
||
.unwrap_or(usize::MAX)
|
||
});
|
||
let rows: Vec<(String, String, String)> = grouped
|
||
.iter()
|
||
.flat_map(|(_, rows)| rows.iter().cloned())
|
||
.collect();
|
||
let heading_at: std::collections::HashMap<usize, &'static str> = {
|
||
let mut map = std::collections::HashMap::new();
|
||
let mut at = 0usize;
|
||
for (group, rows) in &grouped {
|
||
map.insert(at, group.title());
|
||
at += rows.len();
|
||
}
|
||
map
|
||
};
|
||
let count = rows.len();
|
||
let mut list = v_flex().mt_2();
|
||
for (i, (action, key, label)) in rows.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 {
|
||
// The binding commits on a pause, and the pause was
|
||
// invisible: the hint asked people to time something they
|
||
// could not see. The bar runs the same clock the commit
|
||
// does, and restarts with every extra chord.
|
||
row.child(keycaps(&chords.join(" "))).child(
|
||
v_flex()
|
||
.gap(px(3.))
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(muted)
|
||
.child(t(L10nKey::SettingsPauseToSaveEsc)),
|
||
)
|
||
.child(
|
||
div()
|
||
.h(px(2.))
|
||
.w_full()
|
||
.overflow_hidden()
|
||
.rounded_full()
|
||
.bg(border)
|
||
.child(
|
||
div().h_full().rounded_full().bg(accent).with_animation(
|
||
("kb-record-countdown", record_gen as usize),
|
||
Animation::new(std::time::Duration::from_millis(
|
||
crate::ui::app::RECORD_COMMIT_DELAY_MS,
|
||
)),
|
||
|bar, delta| bar.w(relative(delta)),
|
||
),
|
||
),
|
||
),
|
||
)
|
||
};
|
||
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)
|
||
})),
|
||
)
|
||
});
|
||
|
||
if let Some(title) = heading_at.get(&i) {
|
||
list = list.child(
|
||
div()
|
||
.pt_5()
|
||
.pb_1p5()
|
||
.text_xs()
|
||
.font_weight(FontWeight::MEDIUM)
|
||
.text_color(muted)
|
||
.child(*title),
|
||
);
|
||
}
|
||
let last_in_group = heading_at.contains_key(&(i + 1)) || i + 1 == count;
|
||
list = list.child(
|
||
h_flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.py_1p5()
|
||
.when(!last_in_group, |s| s.border_b_1().border_color(border))
|
||
.child(div().text_sm().text_color(foreground).child(label))
|
||
.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, _, window, cx| {
|
||
this.restore_default_keybindings(window, cx)
|
||
})),
|
||
),
|
||
)
|
||
.child(list)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_settings_about(&self, cx: &mut Context<Self>) -> AnyElement {
|
||
// Copied out rather than held: `self.segmented` below needs `cx`
|
||
// mutably, and a live `cx.theme()` borrow would keep it locked.
|
||
let (foreground, muted_fg, danger) = {
|
||
let theme = cx.theme();
|
||
(theme.foreground, theme.muted_foreground, theme.danger)
|
||
};
|
||
|
||
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::Verifying
|
||
| crate::core::update::UpdatePhase::Installing
|
||
);
|
||
let transferring = matches!(
|
||
update_status.phase,
|
||
crate::core::update::UpdatePhase::Downloading { .. }
|
||
| crate::core::update::UpdatePhase::Verifying
|
||
);
|
||
// A staged package whose directory has since been swept away is not an
|
||
// offer worth making.
|
||
let ready = update_status
|
||
.ready
|
||
.clone()
|
||
.filter(crate::core::update::PendingUpdate::is_usable);
|
||
// "You're running the latest version" directly above "27.0.0 is ready
|
||
// to install" is a contradiction, and a reachable one: a release that
|
||
// gets pulled after someone downloaded it leaves exactly this pair.
|
||
// The staged package is the more useful of the two claims.
|
||
let phase_text = localized_update_phase(&update_status.phase).filter(|_| {
|
||
ready.is_none()
|
||
|| !matches!(
|
||
update_status.phase,
|
||
crate::core::update::UpdatePhase::UpToDate
|
||
)
|
||
});
|
||
let failure = update_status.failure.clone();
|
||
let stale_daemon = crate::daemon::spawn::local_daemon_stale_build();
|
||
let check_for_updates = cx.global::<Config>().check_for_updates;
|
||
let auto_download = cx.global::<Config>().auto_download_updates;
|
||
let channel_idx = match cx.global::<Config>().update_channel {
|
||
UpdateChannel::Stable => 0,
|
||
UpdateChannel::Nightly => 1,
|
||
};
|
||
let channel_picker = self.segmented(
|
||
"wt-update-channel",
|
||
&[
|
||
t(L10nKey::SettingsUpdateChannelStable),
|
||
t(L10nKey::SettingsUpdateChannelNightly),
|
||
],
|
||
channel_idx,
|
||
cx,
|
||
|this, ix, _w, cx| {
|
||
let channel = match ix {
|
||
0 => UpdateChannel::Stable,
|
||
_ => UpdateChannel::Nightly,
|
||
};
|
||
this.set_update_channel(channel, cx);
|
||
},
|
||
);
|
||
let http_proxy_input = match self.active_settings() {
|
||
Some(s) => s.http_proxy_input.clone(),
|
||
None => return div().into_any_element(),
|
||
};
|
||
// Only flags a committed value: the input commits on Enter/blur, so a
|
||
// half-typed address is never marked wrong mid-keystroke.
|
||
let http_proxy_value = http_proxy_input.read(cx).value().trim().to_string();
|
||
let http_proxy_invalid = !http_proxy_value.is_empty()
|
||
&& !tty7_core::daemon::install::proxy::is_valid_manual(&http_proxy_value);
|
||
let http_proxy_control = v_flex()
|
||
.gap_1()
|
||
.w(px(260.))
|
||
.child(Input::new(&http_proxy_input).small())
|
||
.when(http_proxy_invalid, |this| {
|
||
this.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(danger)
|
||
.child(t(L10nKey::SettingsAppHttpProxyInvalid)),
|
||
)
|
||
})
|
||
.into_any_element();
|
||
|
||
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)),
|
||
)
|
||
// The search index has promised a "How shells work" entry on this
|
||
// page since it was written, and it pointed at nothing — the one
|
||
// thing that makes tty7 different from any other terminal was
|
||
// never stated in the app.
|
||
.child(self.section_rule(cx))
|
||
.child(self.section_header(t(L10nKey::SettingsSearchHowShellsWorkTitle), cx))
|
||
.child(
|
||
div()
|
||
.text_sm()
|
||
.text_color(muted_fg)
|
||
.child(t(L10nKey::SettingsHowShellsWorkBody)),
|
||
)
|
||
.child(self.section_rule(cx))
|
||
.child(self.section_header(t(L10nKey::SettingsUpdates), cx))
|
||
.child(
|
||
v_flex()
|
||
// The section can carry several stacked states at once —
|
||
// a failure, a staged package, a skipped version. At gap_2
|
||
// they read as one paragraph.
|
||
.gap_3()
|
||
// A failure the user can act on. Persisted, so it is still
|
||
// here tomorrow — the old in-memory phase died with the
|
||
// process and took the only evidence with it.
|
||
.when_some(failure, |this, failure| {
|
||
this.child(
|
||
v_flex()
|
||
.gap_1()
|
||
.child(div().text_sm().text_color(danger).child(t_fmt(
|
||
L10nKey::SettingsUpdateFailedTitle,
|
||
&[("version", &failure.version)],
|
||
)))
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(muted_fg)
|
||
.child(failure.detail.clone()),
|
||
)
|
||
.child(
|
||
h_flex()
|
||
.gap_2()
|
||
.child(
|
||
Button::new("update-retry")
|
||
.label(t(L10nKey::SettingsUpdateRetry))
|
||
.small()
|
||
.disabled(update_busy)
|
||
.on_click(cx.listener(|_, _, _window, cx| {
|
||
crate::core::update::dismiss_failure(cx);
|
||
crate::core::update::install_available(cx);
|
||
})),
|
||
)
|
||
.child(
|
||
Button::new("update-manual")
|
||
.label(t(L10nKey::SettingsUpdateDownloadManually))
|
||
.small()
|
||
.on_click(cx.listener(|_, _, _window, _cx| {
|
||
crate::core::update::open_releases_page()
|
||
})),
|
||
)
|
||
.child(
|
||
Button::new("update-dismiss")
|
||
.label(t(L10nKey::SettingsUpdateDismiss))
|
||
.small()
|
||
.on_click(cx.listener(|_, _, _window, cx| {
|
||
crate::core::update::dismiss_failure(cx)
|
||
})),
|
||
),
|
||
),
|
||
)
|
||
})
|
||
// Downloaded and verified: the decision left is "may I
|
||
// restart", not "will you spend five minutes on this".
|
||
.when_some(ready.clone(), |this, pending| {
|
||
this.child(
|
||
v_flex()
|
||
.gap_1()
|
||
.child(div().text_sm().text_color(foreground).child(t_fmt(
|
||
L10nKey::SettingsUpdateReady,
|
||
&[("version", &pending.version)],
|
||
)))
|
||
.when(pending.apply_on_launch, |this| {
|
||
this.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(muted_fg)
|
||
.child(t(L10nKey::SettingsUpdateReadyNextLaunch)),
|
||
)
|
||
})
|
||
.child(
|
||
h_flex()
|
||
.gap_2()
|
||
.child(
|
||
Button::new("install-ready")
|
||
.label(t(L10nKey::SettingsUpdateInstallNow))
|
||
.small()
|
||
.disabled(update_busy)
|
||
.on_click(cx.listener(|_, _, _window, cx| {
|
||
crate::core::update::install_available(cx)
|
||
})),
|
||
)
|
||
.child(
|
||
Button::new("discard-ready")
|
||
.label(t(L10nKey::SettingsUpdateDiscard))
|
||
.small()
|
||
.disabled(update_busy)
|
||
.on_click(cx.listener(|_, _, _window, cx| {
|
||
crate::core::update::discard_pending(cx)
|
||
})),
|
||
),
|
||
),
|
||
)
|
||
})
|
||
// One action, not the three that used to crowd this row.
|
||
// The update dialog covers the rest, but it is a moment
|
||
// rather than a place: after "Later" it does not come back
|
||
// for days, and where the package cannot be installed for
|
||
// the user — Linux, an unsupported install — the release
|
||
// page is the whole update path. That cannot live only in a
|
||
// dialog that has already been dismissed.
|
||
.when_some(update.filter(|_| ready.is_none()), |this, upd| {
|
||
let availability = t_fmt(
|
||
L10nKey::SettingsVersionAvailable,
|
||
&[("version", &upd.version)],
|
||
);
|
||
// `install_available` opens the release page by itself
|
||
// when there is nothing to install, so both labels lead
|
||
// to the one call.
|
||
let action = if upd.installable {
|
||
t(L10nKey::SettingsUpdateAndRelaunch)
|
||
} else {
|
||
t(L10nKey::SettingsUpdateViewRelease)
|
||
};
|
||
this.child(
|
||
v_flex()
|
||
.gap_2()
|
||
.child(
|
||
h_flex()
|
||
.gap_3()
|
||
.items_center()
|
||
.child(
|
||
div()
|
||
.text_sm()
|
||
.text_color(foreground)
|
||
.child(availability),
|
||
)
|
||
.child(
|
||
Button::new("install-update")
|
||
.label(action)
|
||
.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()
|
||
.gap_2()
|
||
.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)
|
||
})),
|
||
)
|
||
// Thirty megabytes on a slow link is exactly the
|
||
// download someone wants to call off; without this
|
||
// the only way out was to kill the app.
|
||
.when(transferring, |this| {
|
||
this.child(
|
||
Button::new("cancel-update-download")
|
||
.label(t(L10nKey::SettingsUpdateCancel))
|
||
.small()
|
||
.on_click(cx.listener(|_, _, _window, cx| {
|
||
crate::core::update::cancel_download(cx)
|
||
})),
|
||
)
|
||
}),
|
||
)
|
||
.child(self.settings_row(
|
||
t(L10nKey::SettingsUpdateChannel),
|
||
t(L10nKey::SettingsUpdateChannelDesc),
|
||
channel_picker,
|
||
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(
|
||
self.settings_row(
|
||
t(L10nKey::SettingsAutoDownload),
|
||
t(L10nKey::SettingsAutoDownloadDesc),
|
||
crate::ui::theme::switch("auto-download-updates", cx)
|
||
.checked(auto_download)
|
||
.on_click(cx.listener(|this, on: &bool, _w, cx| {
|
||
this.set_auto_download_updates(*on, cx)
|
||
}))
|
||
.into_any_element(),
|
||
cx,
|
||
),
|
||
)
|
||
// The other half of an in-place update: the app is new, the
|
||
// process serving every pane is not. Shown rather than
|
||
// prompted — restarting it ends the user's running work,
|
||
// which is not a thing to ask for on tty7's initiative.
|
||
.when_some(stale_daemon, |this, build| {
|
||
this.child(
|
||
v_flex()
|
||
.gap_1()
|
||
.child(div().text_sm().text_color(foreground).child(t_fmt(
|
||
L10nKey::SettingsDaemonStale,
|
||
&[("build", &build)],
|
||
)))
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(muted_fg)
|
||
.child(t(L10nKey::SettingsDaemonStaleDesc)),
|
||
)
|
||
.child(
|
||
h_flex().child(
|
||
Button::new("restart-stale-daemon")
|
||
.label(t(L10nKey::SettingsRestartServer))
|
||
.small()
|
||
.on_click(cx.listener(|this, _, window, cx| {
|
||
this.restart_daemon(window, cx)
|
||
})),
|
||
),
|
||
),
|
||
)
|
||
}),
|
||
)
|
||
.child(self.settings_row(
|
||
t(L10nKey::SettingsAppHttpProxy),
|
||
t(L10nKey::SettingsAppHttpProxyDesc),
|
||
http_proxy_control,
|
||
cx,
|
||
))
|
||
.child(self.section_rule(cx))
|
||
.child(self.section_header(t(L10nKey::SettingsServer), cx))
|
||
.child(
|
||
v_flex()
|
||
.gap_2()
|
||
.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)
|
||
})),
|
||
),
|
||
),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_row_is_marked_by_its_label_or_by_the_keywords_behind_it() {
|
||
// Straight label hit.
|
||
assert!(row_matches_query(
|
||
SettingsSection::Appearance,
|
||
"Blur",
|
||
"blur"
|
||
));
|
||
// Keyword hit: nothing on the About page contains "persist", but the
|
||
// index says the "How shells work" block answers it.
|
||
assert!(row_matches_query(
|
||
SettingsSection::About,
|
||
t(L10nKey::SettingsSearchHowShellsWorkTitle),
|
||
"persist"
|
||
));
|
||
// A row on some other page is not a hit just because the query matches
|
||
// an entry elsewhere.
|
||
assert!(!row_matches_query(
|
||
SettingsSection::Terminal,
|
||
"Blur",
|
||
"persist"
|
||
));
|
||
// An empty query marks nothing at all, so no page ever renders greyed
|
||
// out just because the field is focused.
|
||
assert!(!row_matches_query(SettingsSection::Appearance, "Blur", ""));
|
||
}
|
||
|
||
#[test]
|
||
fn a_query_that_matches_nothing_is_distinguishable_from_one_that_does() {
|
||
assert_eq!(total_match_count("zzqqxx"), 0);
|
||
assert!(total_match_count("blur") > 0);
|
||
assert!(total_match_count("persist") > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn the_how_shells_work_entry_has_something_to_point_at() {
|
||
// The index promised this page an explanation of the one thing that
|
||
// makes tty7 different; for a long time it pointed at nothing.
|
||
assert!(
|
||
!t(L10nKey::SettingsHowShellsWorkBody).is_empty(),
|
||
"the About page has no body copy for its own search entry"
|
||
);
|
||
assert_eq!(
|
||
best_matching_section("persist").map(|s| s.profile_label()),
|
||
Some(SettingsSection::About.profile_label())
|
||
);
|
||
}
|
||
|
||
#[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::SettingsInstallCliOnPath)
|
||
.expect("the CLI setting should be searchable");
|
||
|
||
assert_eq!(entry.section.profile_label(), "settings:agents");
|
||
}
|
||
|
||
#[test]
|
||
fn index_titles_match_rendered_row_labels() {
|
||
for title in [
|
||
"Start in",
|
||
"Restore last layout",
|
||
"Terminal bell",
|
||
"Report mouse to apps",
|
||
"Open files with",
|
||
"Sidebar grouping",
|
||
"Tab completion",
|
||
"History search",
|
||
"Dim inactive panes",
|
||
"Option (⌥) acts as Meta",
|
||
"Install the `tty7` command on PATH",
|
||
] {
|
||
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",
|
||
);
|
||
}
|
||
}
|