| {
- h_flex()
- .items_center()
- .gap_3()
- .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))),
- );
-
- let ui_font_size = self.ui_font_size(cx);
- let ui_font_size_control = stepper_row(
- step("ui-font-dec", "−", 0).on_click(
- cx.listener(|this, _, _w, cx| this.change_ui_font_size(-UI_FONT_SIZE_STEP, cx)),
- ),
- format!("{ui_font_size:.0}"),
- step("ui-font-inc", "+", 2).on_click(
- cx.listener(|this, _, _w, cx| this.change_ui_font_size(UI_FONT_SIZE_STEP, 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)),
- ),
- );
-
- let font_dropdown = |state: &Entity
>>| {
- Select::new(state)
- .small()
- .w(px(FIELD_W))
- .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 ui_font_family_control = font_dropdown(&ui_font_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();
- // macOS alone dilates glyph strokes, so elsewhere there is no row.
- let thicken_row = cfg!(target_os = "macos").then(|| {
- let thicken_switch = crate::ui::theme::switch("font-thicken", cx)
- .checked(font_thicken)
- .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_font_thicken(*on, cx)))
- .into_any_element();
- self.settings_row(
- t(L10nKey::SettingsFontThicken),
- t(L10nKey::SettingsFontThickenDesc),
- thicken_switch,
- cx,
- )
- });
-
- let cursor_idx = match cursor_style {
- CursorStyle::Block => 0,
- CursorStyle::Bar => 1,
- CursorStyle::Underline => 2,
- };
- let cursor_style_control = self.segmented(
- "cursor-style",
- &[
- t(L10nKey::CursorShapeBlock),
- t(L10nKey::CursorShapeBar),
- t(L10nKey::CursorShapeUnderline),
- ],
- 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::SettingsInterfaceFontGroup), cx))
- .child(self.settings_row(
- t(L10nKey::SettingsUiFontFamily),
- t(L10nKey::SettingsUiFontFamilyDesc),
- ui_font_family_control,
- cx,
- ))
- .child(self.settings_row(
- t(L10nKey::SettingsUiFontSize),
- t(L10nKey::SettingsUiFontSizeDesc),
- ui_font_size_control,
- cx,
- ))
- .child(self.section_rule(cx))
- .child(self.section_header(t(L10nKey::SettingsTerminalFontGroup), cx))
- .child(self.settings_row(
- t(L10nKey::SettingsFontFamily),
- t(L10nKey::SettingsFontFamilyDesc),
- font_family_control,
- 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::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,
- ))
- .when_some(thicken_row, |v, row| v.child(row))
- .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) -> AnyElement {
- let Some(slider) = self
- .active_settings()
- .map(|s| s.window_opacity_slider.clone())
- else {
- return div().into_any_element();
- };
- let config = cx.global::();
- let overridden = window_overrides_active(config, cfg!(target_os = "windows"));
- let dim_inactive_panes = config.dim_inactive_panes;
- let opacity = Tty7App::effective_window_opacity(cx);
-
- let opacity_control = h_flex()
- .items_center()
- .gap_3()
- .w(px(FIELD_W))
- .max_w_full()
- .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();
- // Windows exposes the native backdrop materials directly; macOS keeps
- // the simple blur toggle, which drives its vibrancy.
- #[cfg(target_os = "windows")]
- let blur_control = {
- // Both selects come from the same SettingsState resolved at the
- // top of this function (window_opacity_slider), so the None arm
- // is unreachable today; fall back to an empty control rather
- // than returning from the whole section — a missing select must
- // never silently drop the opacity slider and the rest.
- match self
- .active_settings()
- .map(|s| s.window_backdrop_select.clone())
- {
- Some(select) => Select::new(&select)
- .small()
- .w(px(FIELD_W))
- .h(px(24.))
- .menu_max_h(px(224.))
- .into_any_element(),
- None => div().into_any_element(),
- }
- };
- #[cfg(not(target_os = "windows"))]
- let blur_control =
- {
- let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
- let blur = config.window_blur.unwrap_or(theme.blur);
- 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()
- };
- // `Auto` is the one backdrop that still defers to the legacy blur
- // flag, which is shared with the other platforms' vibrancy switch and
- // travels with a synced config. Offer that switch here exactly when it
- // has an effect — otherwise a stored `window_blur: true` would blur
- // the window with no visible control to clear it, short of the reset
- // button, which also discards the user's opacity.
- #[cfg(target_os = "windows")]
- let auto_blur_row = (config.window_backdrop == WindowBackdrop::Auto).then(|| {
- let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
- let blur = config.window_blur.unwrap_or(theme.blur);
- let control =
- 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();
- self.settings_row(
- t(L10nKey::SettingsBlur),
- // Not `SettingsBlurDesc` — that one describes the switch's
- // usual job, blurring whatever sits behind the window. This
- // row explains its one remaining job on Windows: feeding the
- // `Auto` material.
- t(L10nKey::SettingsBlurAutoDesc),
- control,
- cx,
- )
- });
- #[cfg(not(target_os = "windows"))]
- let auto_blur_row: Option> = None;
- 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(if cfg!(target_os = "windows") {
- L10nKey::SettingsBackdrop
- } else {
- L10nKey::SettingsBlur
- }),
- t(if cfg!(target_os = "windows") {
- L10nKey::SettingsBackdropDesc
- } else {
- L10nKey::SettingsBlurDesc
- }),
- blur_control,
- cx,
- ))
- .children(auto_blur_row)
- .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) -> 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 label_of = |&(edit, ref state): &(ThemeEdit, Entity)| {
- (
- crate::ui::app::theme_edit_label(edit).to_string(),
- state.clone(),
- )
- };
- let seed: Vec<_> = editor.seed.iter().map(label_of).collect();
- let ansi: Vec<_> = editor.ansi.iter().map(label_of).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(FIELD_W))
- .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(FIELD_W))
- .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,
- cx: &mut Context,
- ) -> 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) -> AnyElement {
- let border = cx.theme().border;
- let Some((master_scroll, detail_scroll)) = self
- .active_settings()
- .map(|s| (s.ssh_master_scroll.clone(), s.ssh_detail_scroll.clone()))
- else {
- return div().into_any_element();
- };
- let master = v_flex()
- .id("ssh-master")
- .size_full()
- .overflow_y_scroll()
- .track_scroll(&master_scroll)
- .child(self.render_ssh_master(cx));
- let detail = v_flex()
- .id("ssh-detail")
- .size_full()
- .overflow_y_scroll()
- .track_scroll(&detail_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)),
- ),
- );
- h_flex()
- .size_full()
- .items_start()
- .child(
- v_flex()
- .flex_shrink_0()
- .w(px(self.settings_columns_now(cx).ssh_list))
- .h_full()
- .border_r_1()
- .border_color(border)
- .child(crate::ui::scrollbar::with_vertical_scrollbar(
- "ssh-master-scrollbar",
- master,
- &master_scroll,
- )),
- )
- .child(v_flex().flex_1().min_w_0().h_full().child(
- crate::ui::scrollbar::with_vertical_scrollbar(
- "ssh-detail-scrollbar",
- detail,
- &detail_scroll,
- ),
- ))
- .into_any_element()
- }
-
- fn render_ssh_master(&self, cx: &mut Context) -> AnyElement {
- let muted = cx.theme().muted_foreground;
- let sf = cx.global::().window;
- let profiles = cx.global::().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()
- .tooltip(t(L10nKey::SettingsNewHost))
- .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)> = 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, _, window, cx| this.select_ssh_defaults(window, cx)),
- None,
- cx,
- ));
-
- if profiles.is_empty() {
- list = list.child(
- div()
- .py_4()
- // px_2 is what `render_ssh_row` insets its title by: a note
- // standing in for the rows starts on their column, not on
- // the list's own edge.
- .px_2()
- .text_sm()
- .text_color(muted)
- .child(t(L10nKey::SettingsNoSavedHosts)),
- );
- } else if groups.is_empty() {
- list = list.child(
- div()
- .py_4()
- .px_2()
- .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) -> 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(window, 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,
- ) -> AnyElement {
- let muted = cx.theme().muted_foreground;
- let sf = cx.global::().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()
- // 8 + 10 + 4 puts the group name on the same column as a host
- // title, which sits 8 + 6 + 8 past the list edge — and it hands
- // the header the same 8px inset the rows hover with.
- .px_2()
- .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,
- ) -> 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 selected {
- return;
- }
- if let Some(profile) = cx
- .global::()
- .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,
- title: impl Into,
- subtitle: impl Into,
- selected: bool,
- dot: Option,
- sf: presets::Surface,
- on_select: impl Fn(&gpui::MouseDownEvent, &mut Window, &mut App) + 'static,
- menu_for: Option,
- cx: &mut Context,
- ) -> 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);
- })
- // The gutter is here on every row, dot or no dot. Only hosts carry
- // a liveness dot, and skipping the space on the rows that don't —
- // Defaults, and nothing else — started their title 14px left of
- // every host title under them.
- .child(
- div()
- .flex_shrink_0()
- .size(px(6.))
- .when_some(dot, |d, live| {
- d.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 {
use crate::daemon::protocol::SshPhase;
let mut live = std::collections::HashSet::new();
@@ -4489,20 +2008,6 @@ impl Tty7App {
live
}
- pub(crate) fn select_ssh_defaults(&mut self, window: &mut Window, cx: &mut Context) {
- if self.ssh_form_dirty(cx) {
- self.with_settings_edits_resolved(window, cx, |this, window, cx| {
- this.select_ssh_defaults(window, cx)
- });
- return;
- }
- 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) {
if let Some(s) = self.active_settings_mut() {
if !s.ssh_collapsed_groups.remove(&key) {
@@ -4513,277 +2018,6 @@ impl Tty7App {
cx.notify();
}
- fn render_ssh_detail(&self, cx: &mut Context) -> 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) -> 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::().ssh_profiles.len();
-
- let unlinked = {
- let known: std::collections::HashSet = cx
- .global::()
- .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::>()
- };
-
- 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()
- .w_full()
- .max_w(px(380.))
- .gap_2()
- .child(
- div()
- .flex_1()
- .min_w_0()
- .child(Input::new(&input).small().w_full()),
- )
- .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, _, window, cx| {
- this.import_ssh_config_profiles(window, cx)
- })),
- ),
- );
- }
-
- body.into_any_element()
- }
-
- fn ssh_quick_connect_from_settings(&mut self, window: &mut Window, cx: &mut Context) {
- 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_then(window, cx, move |this, window, cx| {
- this.quick_connect(qc, window, cx)
- });
- }
-
- fn render_ssh_defaults_detail(&self, cx: &mut Context) -> AnyElement {
- let muted = cx.theme().muted_foreground;
- let imported = cx
- .global::()
- .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, _, window, cx| {
- this.import_ssh_config_profiles(window, 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,
- ) -> 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_then(window, cx, move |this, 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| {
- let _ =
- app.update(cx, |this, cx| this.forget_profile_password(id, window, 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) -> AnyElement {
- let verify = cx.global::().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::().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())
}
@@ -4822,10 +2056,12 @@ impl Tty7App {
.iter()
.map(|r| seed_forward_row(window, cx, r))
.collect();
- let identity_files = seed_hinted_multi(
+ // One line, comma-separated: the form is a row of single-line fields,
+ // and a profile with more than one key is the exception.
+ let identity_files = seed_hinted(
window,
cx,
- &profile.identity_files.join("\n"),
+ &profile.identity_files.join(", "),
DEFAULT_KEY_HINT,
);
@@ -4898,35 +2134,7 @@ impl Tty7App {
);
let login_scripts = seed_input(window, cx, &profile.login_scripts.join("\n"), true);
- let auth_select = cx.new(|cx| {
- SelectState::new(
- SearchableVec::new(auth_mode_labels()),
- Some(IndexPath::default().row(auth_mode_index(profile.auth))),
- window,
- cx,
- )
- });
-
let mut subs = Vec::new();
- subs.push(cx.subscribe_in(
- &auth_select,
- window,
- |this, _select, ev: &SelectEvent>, _window, cx| {
- let SelectEvent::Confirm(Some(label)) = ev else {
- return;
- };
- let picked = auth_mode_labels().iter().position(|l| l == label);
- if let (Some(ix), Some(form)) = (picked, this.ssh_form_mut()) {
- form.auth = AUTH_MODES[ix];
- // The same reason the typed fields drop it: the answer on
- // screen was about a handshake this form would no longer
- // make. A green line under a changed method reads as a
- // method that was proved, and it was not.
- form.test = None;
- cx.notify();
- }
- },
- ));
// The passphrase belongs to whichever key the field above names, so
// when that answer changes the box has to change with it. Without this
// a form opened on one key and pointed at another would carry the
@@ -4970,13 +2178,7 @@ impl Tty7App {
subs.push(
cx.subscribe_in(input, window, |this, _i, ev: &InputEvent, _w, cx| {
if matches!(ev, InputEvent::Change) {
- // The test answered for the host as it was typed a
- // moment ago. Keeping the green line under a changed
- // address would be the form vouching for something it
- // never dialled.
- if let Some(form) = this.ssh_form_mut() {
- form.test = None;
- }
+ let _ = this.ssh_form_mut();
cx.notify();
}
}),
@@ -4987,15 +2189,11 @@ impl Tty7App {
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,
- auth_select,
password,
passphrase,
loaded_password,
@@ -5024,13 +2222,20 @@ impl Tty7App {
remote_clipboard_write: profile.remote_clipboard_write,
verify_host_keys: profile.verify_host_keys,
warn_on_close: profile.warn_on_close,
- test: None,
_subs: subs,
};
let editing = form.editing;
+ let known = cx
+ .global::()
+ .ssh_profiles
+ .iter()
+ .any(|p| p.id == editing);
if let Some(s) = self.active_settings_mut() {
s.ssh_form = Some(form);
- s.ssh_detail = SshDetail::Profile(editing);
+ s.ssh_confirm_remove = false;
+ // A saved host edits in its own row; a new one gets a row of its
+ // own at the top, so nothing else is shown opened.
+ s.ssh_open = known.then_some(editing);
}
cx.notify();
}
@@ -5057,7 +2262,7 @@ impl Tty7App {
socks: val(&form.socks),
http: val(&form.http),
auth: form.auth,
- identity_files: raw(&form.identity_files),
+ identity_files: raw(&form.identity_files).replace(',', "\n"),
agent_forward: form.agent_forward,
credential_ref: form.carry_credential_ref.clone(),
forwards: form.forwards.iter().filter_map(|r| r.collect(cx)).collect(),
@@ -5093,7 +2298,7 @@ impl Tty7App {
let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else {
return;
};
- let files = split_lines(&form.identity_files.read(cx).value());
+ let files = split_lines(&form.identity_files.read(cx).value().replace(',', "\n"));
let host = form.host.read(cx).value().trim().to_string();
let user = form.user.read(cx).value().trim().to_string();
let key = first_readable_key_in(&files, &host, &user);
@@ -5115,39 +2320,6 @@ impl Tty7App {
cx.notify();
}
- /// Add key files to the profile from the system file picker, one per line
- /// alongside whatever is already named. Typing the path still works — this
- /// is for the far more common case of knowing the key by sight and not by
- /// path.
- pub(crate) fn pick_ssh_identity_file(&mut self, window: &mut Window, cx: &mut Context) {
- let rx = cx.prompt_for_paths(gpui::PathPromptOptions {
- files: true,
- directories: false,
- multiple: true,
- prompt: None,
- });
- cx.spawn_in(window, async move |this, cx| {
- let Ok(Ok(Some(paths))) = rx.await else {
- return;
- };
- let _ = this.update_in(cx, |this, window, cx| {
- let Some(form) = this.active_settings().and_then(|s| s.ssh_form.as_ref()) else {
- return;
- };
- let input = form.identity_files.clone();
- let mut lines = split_lines(&input.read(cx).value());
- for path in paths {
- let path = tildify(&path.to_string_lossy());
- if !lines.contains(&path) {
- lines.push(path);
- }
- }
- input.update(cx, |i, cx| i.set_value(lines.join("\n"), window, cx));
- });
- })
- .detach();
- }
-
pub(crate) fn save_editing_profile(
&mut self,
window: &mut Window,
@@ -5179,23 +2351,10 @@ impl Tty7App {
Some(id)
}
- fn cancel_ssh_form(&mut self, window: &mut Window, cx: &mut Context) {
- let id = self
- .active_settings()
- .and_then(|s| s.ssh_form.as_ref().map(|form| form.editing));
- let saved = id.and_then(|id| {
- cx.global::()
- .ssh_profiles
- .iter()
- .find(|p| p.id == id)
- .cloned()
- });
+ pub(crate) fn cancel_ssh_form(&mut self, cx: &mut Context) {
if let Some(s) = self.active_settings_mut() {
s.ssh_form = None;
- s.ssh_detail = SshDetail::Defaults;
- }
- if let Some(profile) = saved {
- self.ssh_form_load(&profile, window, cx);
+ s.ssh_confirm_remove = false;
}
cx.notify();
}
@@ -5339,11 +2498,6 @@ impl Tty7App {
.any(|p| p.id != except && p.user == user && p.host == host && p.port == port)
}
- pub(crate) fn save_ssh_form(&mut self, window: &mut Window, cx: &mut Context) {
- self.save_editing_profile(window, 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 — and it compares what the form
@@ -5371,136 +2525,11 @@ impl Tty7App {
});
}
- /// Dial the host the form is holding — without saving it, and without
- /// spending a tab on the answer. The daemon does the connecting, so this is
- /// the same path Connect would take.
- pub(crate) fn test_ssh_form_connection(&mut self, window: &mut Window, cx: &mut Context) {
- let Some((profile, errors)) = self.ssh_form_collect(cx) else {
- return;
- };
- if !errors.is_empty() {
- return;
- }
- let mut spec = Box::new(self.native_ssh_spec_for_profile(&profile, cx));
- // The spec is built from the keychain, so without this Test would dial
- // with the *saved* password while a new one sits typed on screen —
- // and report a failure the form could not explain.
- if let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) {
- if form.wants_password() {
- let typed = form.password.read(cx).value().to_string();
- if !typed.is_empty() {
- spec.password = Some(typed);
- }
- }
- if form.wants_key() {
- let typed = form.passphrase.read(cx).value().to_string();
- if let (false, Some(key)) = (typed.is_empty(), first_readable_key(&profile)) {
- spec.key_passphrases
- .get_or_insert_with(Default::default)
- .insert(key, typed);
- }
- }
- }
- let editing = profile.id;
- if let Some(form) = self.ssh_form_mut() {
- form.test = Some(SshTestState::Running);
- }
- cx.notify();
-
- let probe = cx
- .background_executor()
- .spawn(async move { crate::terminal::RemoteTerminal::test_ssh(spec) });
- cx.spawn_in(window, async move |this, cx| {
- let report = probe.await;
- let _ = this.update(cx, |this, cx| {
- // The form may have been closed, or moved to another host, in
- // the seconds the handshake took. An answer about a host nobody
- // is looking at any more is not worth showing.
- if let Some(form) = this.ssh_form_mut().filter(|f| f.editing == editing) {
- form.test = Some(SshTestState::Done(report));
- cx.notify();
- }
- });
- })
- .detach();
- }
-
- pub(crate) fn save_and_connect_profile(&mut self, window: &mut Window, cx: &mut Context) {
- if let Some(id) = self.save_editing_profile(window, cx) {
- self.close_settings_then(window, cx, move |this, window, cx| {
- this.connect_ssh_profile(id, window, cx)
- });
- }
- }
-
pub(crate) fn add_new_profile(&mut self, window: &mut Window, cx: &mut Context) {
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,
- ) {
- let Some(mut profile) = cx
- .global::()
- .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) {
- let Some(profile) = cx
- .global::()
- .ssh_profiles
- .iter()
- .find(|p| p.id == id)
- .cloned()
- else {
- return;
- };
- let name = profile.name.clone();
- // The entries routing through this profile are forgotten along with
- // it (#485) — say so up front, naming the endpoint: once the profile
- // is gone, its address is the part nobody can reproduce (the route
- // origins are in-memory, the keychain entry goes too). Entries with
- // a live or in-flight link are excluded, and nothing is ever sent to
- // the machine — the remote sessions keep running.
- let cascade = crate::ui::windows::cascade_for_profile(cx, id);
- let mut body = t(L10nKey::SettingsDeleteProfileBody).to_string();
- if !cascade.is_empty() {
- let endpoint = crate::core::session::RouteSnapshot::of_profile(&profile).endpoint();
- body.push(' ');
- body.push_str(&t_plural(
- L10nKey::SettingsDeleteProfileCascade,
- cascade.len(),
- &[("endpoint", endpoint.as_str())],
- ));
- }
- let answer = window.prompt(
- gpui::PromptLevel::Warning,
- &t_fmt(L10nKey::FileTreeDeleteTitle, &[("name", &name)]),
- Some(&body),
- &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) {
// "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
@@ -5559,11 +2588,14 @@ impl Tty7App {
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;
+ if let Some(s) = self.active_settings_mut() {
+ if s.ssh_form.as_ref().is_some_and(|f| f.editing == id) {
+ s.ssh_form = None;
+ }
+ if s.ssh_open == Some(id) {
+ s.ssh_open = None;
+ }
+ s.ssh_confirm_remove = false;
}
cx.notify();
}
@@ -5670,3417 +2702,6 @@ impl Tty7App {
}
window.push_notification(notification, cx);
}
-
- pub(crate) fn copy_profile_connect_string(&mut self, id: Uuid, cx: &mut Context) {
- if let Some(profile) = cx
- .global::()
- .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,
- window: &mut Window,
- cx: &mut Context,
- ) {
- let cfg = cx.global::();
- let Some(endpoint) = cfg
- .ssh_profiles
- .iter()
- .find(|p| p.id == id)
- .map(|p| format!("{}@{}:{}", p.user, p.host, p.port))
- else {
- return;
- };
- // One click used to be the whole gesture, and the thing it removed does
- // not come back. Worse, the entry is the endpoint's rather than this
- // row's, so a menu opened on one host can sign several of them out —
- // name that count here instead of letting it turn up at the next
- // connect on a host nobody touched.
- let others = profiles_sharing_endpoint(cfg, id);
- let body = if others == 0 {
- t(L10nKey::SettingsForgetPasswordBody).to_string()
- } else {
- t_plural(
- L10nKey::SettingsForgetPasswordSharedBody,
- others,
- &[("endpoint", &endpoint)],
- )
- };
- let answer = window.prompt(
- gpui::PromptLevel::Warning,
- &t_fmt(
- L10nKey::SettingsForgetPasswordTitle,
- &[("endpoint", &endpoint)],
- ),
- Some(&body),
- &crate::ui::confirm_answers(t(L10nKey::SettingsForgetPassword), t(L10nKey::Cancel)),
- cx,
- );
- cx.spawn_in(window, async move |this, cx| {
- let Ok(0) = answer.await else { return };
- // The notification is the only sign the keychain was touched, and
- // by now the click that asked for it is long gone — so it has to be
- // raised from in here, on the window the prompt belonged to.
- let _ = this.update_in(cx, |this, window, cx| {
- if let Some(msg) = this.forget_profile_password_confirmed(id, cx) {
- window.push_notification(msg, cx);
- }
- });
- })
- .detach();
- }
-
- fn forget_profile_password_confirmed(
- &mut self,
- id: Uuid,
- cx: &mut Context,
- ) -> Option {
- use crate::core::keychain::{CredentialStore, OsCredentialStore};
- let (user, host, port) = cx
- .global::()
- .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) -> 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::()
- .ssh_profiles
- .iter()
- .find(|p| p.id == editing)
- .cloned();
- let (collected, errors) = self.ssh_form_collect(cx).unzip();
- let errors = errors.unwrap_or_default();
- // A password is not part of the profile, so a change to one is
- // invisible to the comparison above — and Save would sit greyed out
- // over a secret the user just typed.
- let dirty = collected != saved || form.secrets_changed(cx);
- 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 testing = matches!(form.test, Some(SshTestState::Running));
- let test_line = form.test.as_ref().map(|state| match state {
- SshTestState::Running => field_note(t(L10nKey::SettingsTestRunning), cx),
- SshTestState::Done(report) => match report {
- SshTestReport::Authenticated { elapsed_ms } => {
- div().text_xs().text_color(success).child(t_fmt(
- L10nKey::SettingsTestReached,
- &[("time", &human_millis(*elapsed_ms))],
- ))
- }
- SshTestReport::NeedsInput { need, .. } => {
- field_note(t(ssh_test_need_message(*need)), cx)
- }
- SshTestReport::Failed { reason } => field_error(
- t_fmt(L10nKey::SettingsTestFailed, &[("reason", reason)]),
- cx,
- ),
- },
- });
-
- let header = h_flex()
- .w_full()
- .when(self.settings_row_under(STACK_ROW_BELOW, cx), |v| {
- v.flex_col()
- })
- .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()
- .max_w_full()
- .flex_wrap()
- .gap_2()
- .child(
- Button::new("ssh-form-cancel")
- .label(t(L10nKey::Cancel))
- .ghost()
- .small()
- .disabled(!dirty)
- .on_click(
- cx.listener(|this, _, window, cx| this.cancel_ssh_form(window, cx)),
- ),
- )
- .child(
- // Dials the host exactly as Connect would — proxy, jump
- // and all — but keeps the answer here instead of
- // spending a tab on finding out.
- Button::new("ssh-form-test")
- .label(t(L10nKey::SettingsTestConnection))
- .small()
- .disabled(!errors.is_empty() || testing)
- .on_click(cx.listener(|this, _, window, cx| {
- this.test_ssh_form_connection(window, cx)
- })),
- )
- .child(
- Button::new("ssh-form-save")
- .label(t(L10nKey::Save))
- .small()
- .disabled(!dirty || !errors.is_empty())
- .on_click(
- cx.listener(|this, _, window, cx| this.save_ssh_form(window, cx)),
- ),
- )
- .child(
- // Connect saves first, so it answers to the same
- // rules. Before this it answered to none at all, and
- // an empty host reached the socket layer as a DNS
- // error about a name nobody typed.
- Button::new("ssh-form-connect")
- .label(t(L10nKey::Connect))
- .primary()
- .small()
- .disabled(!errors.is_empty())
- .on_click(cx.listener(|this, _, window, cx| {
- this.save_and_connect_profile(window, cx)
- })),
- ),
- );
-
- // Every field notifies on change, so this form re-renders on each
- // keystroke: telling a brand-new host that it needs a host is
- // something it would say before the user had typed a character. Hold
- // that one line back until the group it belongs to has something in
- // it. A malformed value has nothing to wait for and says so at once.
- let core_blank = form.core_is_blank(cx);
- let host_error = errors
- .host
- .as_ref()
- .filter(|_| !core_blank)
- .map(|e| field_error(e.message(), cx));
- let port_error = errors.port.as_ref().map(|e| field_error(e.message(), cx));
-
- // Three fields whose labels say everything a sentence under them
- // would: what goes in them is shown in the box itself, as a hint that
- // gets out of the way the moment anything is typed.
- let core = v_flex()
- .gap_1()
- .child(self.ssh_field_row(
- t(L10nKey::SettingsName),
- Input::new(&form.name).small().w_full().into_any_element(),
- vec![],
- cx,
- ))
- .child(
- self.ssh_field_row(
- t(L10nKey::SettingsHost),
- h_flex()
- .w_full()
- .gap_2()
- .child(Input::new(&form.host).small().flex_1().min_w_0())
- .child(Input::new(&form.port).small().w(px(64. * ui_scale(cx))))
- .into_any_element(),
- host_error
- .into_iter()
- .chain(port_error)
- .map(IntoElement::into_any_element)
- .collect(),
- cx,
- ),
- )
- .child(self.ssh_field_row(
- t(L10nKey::SettingsUser),
- Input::new(&form.user).small().w_full().into_any_element(),
- vec![],
- cx,
- ));
-
- v_flex()
- .gap_4()
- .child(header)
- // Under the buttons that produced it, on the right, where the eye
- // already is after pressing Test.
- .when_some(test_line, |col, line| {
- col.child(h_flex().w_full().justify_end().child(line))
- })
- .child(core)
- .child(self.render_ssh_profile_auth_section(form, cx))
- .child(self.render_ssh_profile_jump_section(form, &errors, cx))
- .child(self.render_ssh_profile_forwards_section(form, cx))
- .child(self.render_ssh_profile_advanced_section(form, &errors, cx))
- .into_any_element()
- }
-
- /// One field of the host editor: its label in a narrow column on the left,
- /// the field immediately beside it, and whatever the field has to say —
- /// its description, or a complaint about what is in it — underneath the
- /// field rather than underneath the label.
- ///
- /// The settings rows on the rest of this page push their control to the
- /// far right edge of the page, which is right for a list of independent
- /// switches and wrong for a form: it left a hand's width of nothing
- /// between the word "Host" and the box a hostname goes in, and the eye had
- /// to cross it once per field. Every SSH client worth borrowing from keeps
- /// the two together.
- fn ssh_field_row(
- &self,
- label: &str,
- control: AnyElement,
- under: Vec