891 lines
39 KiB
Rust
891 lines
39 KiB
Rust
//! Cached host search, grouping, virtual rows and credential-free exchange.
|
|
|
|
use super::*;
|
|
use gpui::AppContext as _;
|
|
use gpui_component::menu::PopupMenuItem;
|
|
|
|
mod exchange;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
// Each virtual item includes its bottom gutter, so scrolling and hit tests use
|
|
// the actual card extent rather than the previous contiguous table-row height.
|
|
const HOST_CARD_HEIGHT: f32 = 68.0;
|
|
const HOST_CARD_EXTENT: f32 = HOST_CARD_HEIGHT + 8.0;
|
|
|
|
/// Center the glyph's ink, not its monospace advance or the surrounding text line.
|
|
fn host_icon(glyph: char, family: SharedString, color: gpui::Hsla) -> impl IntoElement {
|
|
gpui::canvas(
|
|
move |bounds, window, _| {
|
|
let font = gpui::font(family);
|
|
let text_system = window.text_system();
|
|
let font_id = text_system.resolve_font(&font);
|
|
let base_size = px(18.0);
|
|
let ink = text_system.typographic_bounds(font_id, base_size, glyph).ok();
|
|
let scale = ink
|
|
.filter(|ink| ink.size.width > px(0.0) && ink.size.height > px(0.0))
|
|
.map(|ink| 18.0 / f32::from(ink.size.width.max(ink.size.height)))
|
|
.unwrap_or(1.0);
|
|
let font_size = base_size * scale;
|
|
let text: SharedString = glyph.to_string().into();
|
|
let line = text_system.shape_line(
|
|
text.clone(),
|
|
font_size,
|
|
&[gpui::TextRun {
|
|
len: text.len(),
|
|
font,
|
|
color,
|
|
background_color: None,
|
|
underline: None,
|
|
strikethrough: None,
|
|
}],
|
|
None,
|
|
);
|
|
let height = line.ascent + line.descent;
|
|
let origin = if let Some(ink) = ink {
|
|
// Font coordinates are relative to the baseline with positive Y upwards.
|
|
let center = ink.center() * scale;
|
|
bounds.center() - gpui::point(center.x, line.ascent - center.y)
|
|
} else {
|
|
bounds.center() - gpui::point(line.width / 2.0, height / 2.0)
|
|
};
|
|
(line, origin, height)
|
|
},
|
|
|_, (line, origin, height), window, cx| {
|
|
if let Err(error) = line.paint(origin, height, gpui::TextAlign::Left, None, window, cx)
|
|
{
|
|
log::warn!("Unable to paint SSH host icon: {error}");
|
|
}
|
|
},
|
|
)
|
|
.size(px(22.0))
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub(in crate::gpui_shell) enum HostScope {
|
|
All,
|
|
Pinned,
|
|
Managed,
|
|
Recent,
|
|
}
|
|
|
|
pub(in crate::gpui_shell) struct HostLibraryState {
|
|
pub search: Entity<InputState>,
|
|
pub group: Entity<InputState>,
|
|
pub tags: Entity<InputState>,
|
|
pub notes: Entity<InputState>,
|
|
scope: HostScope,
|
|
group_filter: Option<String>,
|
|
scroll: gpui::UniformListScrollHandle,
|
|
pub(super) busy: bool,
|
|
sequence: u64,
|
|
}
|
|
|
|
impl HostLibraryState {
|
|
pub(in crate::gpui_shell) fn new(window: &mut Window, cx: &mut Context<SettingsPane>) -> Self {
|
|
let language = crate::gpui_shell::config::ui_language(cx);
|
|
Self {
|
|
search: cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(language.text(Message::HostsSearch))
|
|
}),
|
|
group: cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(language.text(Message::HostsGroup))
|
|
}),
|
|
tags: cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(language.text(Message::HostsTagsHint))
|
|
}),
|
|
notes: cx.new(|cx| InputState::new(window, cx).multi_line(true).soft_wrap(true)),
|
|
scope: HostScope::All,
|
|
group_filter: None,
|
|
scroll: Default::default(),
|
|
busy: false,
|
|
sequence: 0,
|
|
}
|
|
}
|
|
|
|
pub(in crate::gpui_shell) fn reset_scroll(&self) {
|
|
self.scroll.scroll_to_item(0, gpui::ScrollStrategy::Top);
|
|
}
|
|
}
|
|
|
|
impl SettingsPane {
|
|
pub(in crate::gpui_shell) fn prepare_launcher_ssh_host(
|
|
&mut self,
|
|
host: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.ssh_library.scope = HostScope::All;
|
|
self.ssh_library.group_filter = None;
|
|
self.ssh_library.search.update(cx, |input, cx| input.set_value(host, window, cx));
|
|
self.ssh_library.reset_scroll();
|
|
}
|
|
|
|
fn duplicate_ssh_host(&mut self, host: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
let label = self
|
|
.ssh_hosts
|
|
.profiles
|
|
.for_destination(&host)
|
|
.label
|
|
.filter(|label| !label.trim().is_empty())
|
|
.unwrap_or_else(|| host.clone());
|
|
let copy_label = self.ssh_hosts.profiles.next_default_label(label.trim());
|
|
|
|
self.open_ssh_editor(Some(host.clone()), window, cx);
|
|
if let Some(editor) = self.ssh_editor.as_mut() {
|
|
editor.original_destination = None;
|
|
// 复制不会重命名原主机,编辑路径排除的原地址仍应可用作新主机的跳板。
|
|
editor.jump_choices.push((host, label));
|
|
}
|
|
self.ssh_destination_input.update(cx, |input, cx| input.set_value("", window, cx));
|
|
self.ssh_label_input.update(cx, |input, cx| input.set_value(copy_label, window, cx));
|
|
self.ssh_password_input.update(cx, |input, cx| {
|
|
input.set_placeholder(
|
|
crate::gpui_shell::config::ui_language(cx)
|
|
.pick("留空则连接时询问", "Leave empty to ask when connecting"),
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
}
|
|
|
|
fn filtered_library_hosts(&self, cx: &gpui::App) -> Vec<String> {
|
|
let query = self.ssh_library.search.read(cx).value();
|
|
let mut hosts = if self.ssh_library.scope == HostScope::Recent {
|
|
self.ssh_hosts
|
|
.saved
|
|
.iter()
|
|
.filter(|host| !self.ssh_hosts.hidden.contains(host))
|
|
.cloned()
|
|
.collect()
|
|
} else {
|
|
self.ssh_hosts.merged()
|
|
};
|
|
if self.ssh_library.scope == HostScope::Pinned {
|
|
hosts.retain(|host| self.ssh_hosts.is_pinned(host));
|
|
}
|
|
self.ssh_hosts.profiles.filter_hosts(
|
|
hosts,
|
|
&query,
|
|
self.ssh_library.scope == HostScope::Managed,
|
|
self.ssh_library.group_filter.as_deref(),
|
|
)
|
|
}
|
|
|
|
fn library_controls(&self, cx: &mut Context<Self>) -> gpui::AnyElement {
|
|
let language = crate::gpui_shell::config::ui_language(cx);
|
|
let weak = cx.entity().downgrade();
|
|
let group_filter = self.ssh_library.group_filter.clone();
|
|
let groups = self.ssh_hosts.profiles.groups();
|
|
let group_label = match group_filter.as_deref() {
|
|
None => language.text(Message::HostsAllGroups).to_owned(),
|
|
Some("") => language.text(Message::HostsUngrouped).to_owned(),
|
|
Some(group) => group.to_owned(),
|
|
};
|
|
h_flex()
|
|
.id("ssh-library-controls")
|
|
.gap_2()
|
|
.items_center()
|
|
.flex_wrap()
|
|
.child(
|
|
h_flex().gap_1().flex_wrap().children(
|
|
[
|
|
(HostScope::All, Message::HostsAll),
|
|
(HostScope::Pinned, Message::HostsPinned),
|
|
(HostScope::Managed, Message::HostsManaged),
|
|
(HostScope::Recent, Message::HostsRecent),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, (scope, label))| {
|
|
Button::new(("host-scope", index))
|
|
.debug_selector(move || format!("host-scope-{index}"))
|
|
.label(language.text(label))
|
|
.small()
|
|
.ghost()
|
|
.selected(self.ssh_library.scope == scope)
|
|
.on_click(cx.listener(move |this, _, _, cx| {
|
|
this.ssh_library.scope = scope;
|
|
this.ssh_library.reset_scroll();
|
|
cx.notify();
|
|
}))
|
|
}),
|
|
),
|
|
)
|
|
.child(div().flex_1())
|
|
.child(
|
|
h_flex()
|
|
.gap_2()
|
|
.items_center()
|
|
.flex_wrap()
|
|
.child(
|
|
Button::new("host-group-filter").label(group_label).small().dropdown_menu(
|
|
move |mut menu, _, _| {
|
|
let choices = std::iter::once((
|
|
None,
|
|
language.text(Message::HostsAllGroups).to_owned(),
|
|
))
|
|
.chain(std::iter::once((
|
|
Some(String::new()),
|
|
language.text(Message::HostsUngrouped).to_owned(),
|
|
)))
|
|
.chain(
|
|
groups.iter().map(|group| (Some(group.clone()), group.clone())),
|
|
);
|
|
for (choice, label) in choices {
|
|
let owner = weak.clone();
|
|
menu = menu.item(
|
|
PopupMenuItem::new(label)
|
|
.checked(choice == group_filter)
|
|
.on_click(move |_, _, cx| {
|
|
let _ = owner.update(cx, |this, cx| {
|
|
this.ssh_library.group_filter = choice.clone();
|
|
this.ssh_library.reset_scroll();
|
|
cx.notify();
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
menu
|
|
},
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.id("ssh-inline-filter")
|
|
.debug_selector(|| "ssh-inline-filter".into())
|
|
.w(px(210.0))
|
|
.child(
|
|
Input::new(&self.ssh_library.search)
|
|
.small()
|
|
.prefix(Icon::new(IconName::Search).size(px(14.0))),
|
|
),
|
|
),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn library_header(&self, cx: &mut Context<Self>) -> gpui::AnyElement {
|
|
let language = crate::gpui_shell::config::ui_language(cx);
|
|
let owner = cx.entity().downgrade();
|
|
h_flex()
|
|
.gap_3()
|
|
.items_center()
|
|
.flex_wrap()
|
|
.child(
|
|
v_flex()
|
|
.flex_1()
|
|
.min_w(px(220.0))
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.text_size(px(18.0))
|
|
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
.child(language.text(Message::HostsTitle)),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_xs()
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(language.text(Message::HostsSubtitle)),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.gap_2()
|
|
.child(
|
|
Button::new("hosts-exchange")
|
|
.label(language.text(Message::HostsExchange))
|
|
.small()
|
|
.disabled(self.ssh_library.busy)
|
|
.dropdown_menu(move |menu, _, _| {
|
|
let import_owner = owner.clone();
|
|
let export_owner = owner.clone();
|
|
menu.item(
|
|
PopupMenuItem::new(language.text(Message::HostsImport))
|
|
.on_click(move |_, window, cx| {
|
|
let _ = import_owner.update(cx, |this, cx| {
|
|
this.import_host_csv(window, cx)
|
|
});
|
|
}),
|
|
)
|
|
.item(
|
|
PopupMenuItem::new(language.text(Message::HostsExport))
|
|
.on_click(move |_, window, cx| {
|
|
let _ = export_owner.update(cx, |this, cx| {
|
|
this.export_host_csv(window, cx)
|
|
});
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
.child(
|
|
Button::new("ssh-add-host")
|
|
.icon(IconName::Plus)
|
|
.label(language.text(Message::HostsAdd))
|
|
.small()
|
|
.primary()
|
|
.disabled(self.ssh_library.busy)
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.open_ssh_editor(None, window, cx)
|
|
})),
|
|
),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn library_config_banner(&self, cx: &mut Context<Self>) -> gpui::AnyElement {
|
|
let language = crate::gpui_shell::config::ui_language(cx);
|
|
let count = self.ssh_hosts.configured.len();
|
|
let text = if count == 0 {
|
|
language.text(Message::HostsConfigEmpty).to_owned()
|
|
} else {
|
|
language.format(Message::HostsConfigShared, &[("count", &count.to_string())])
|
|
};
|
|
h_flex()
|
|
.id("ssh-config-banner")
|
|
.gap_2()
|
|
.px_3()
|
|
.py_2()
|
|
.items_center()
|
|
.rounded(px(6.0))
|
|
.border_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().secondary.opacity(0.35))
|
|
.child(Icon::new(IconName::Info).size(px(14.0)).text_color(cx.theme().muted_foreground))
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.text_xs()
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(text),
|
|
)
|
|
.child(
|
|
Button::new("ssh-import")
|
|
.label(language.text(Message::HostsReload))
|
|
.small()
|
|
.ghost()
|
|
.loading(self.ssh_library.busy)
|
|
.disabled(self.ssh_library.busy)
|
|
.on_click(cx.listener(|this, _, _, cx| this.reload_host_library(cx))),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
pub(super) fn host_organization_fields(
|
|
&self,
|
|
window: &Window,
|
|
cx: &gpui::App,
|
|
) -> gpui::AnyElement {
|
|
let language = crate::gpui_shell::config::ui_language(cx);
|
|
v_flex()
|
|
.mt_4()
|
|
.gap_2()
|
|
.child(
|
|
h_flex()
|
|
.gap_3()
|
|
.child(
|
|
v_flex()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.gap_1()
|
|
.child(div().text_xs().child(language.text(Message::HostsGroup)))
|
|
.child(editor::editor_input(
|
|
&self.ssh_library.group,
|
|
language.text(Message::HostsGroup),
|
|
window,
|
|
cx,
|
|
)),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.gap_1()
|
|
.child(div().text_xs().child(language.text(Message::HostsTags)))
|
|
.child(editor::editor_input(
|
|
&self.ssh_library.tags,
|
|
language.text(Message::HostsTags),
|
|
window,
|
|
cx,
|
|
)),
|
|
),
|
|
)
|
|
.child(div().text_xs().child(language.text(Message::HostsNotes)))
|
|
.child(Input::new(&self.ssh_library.notes).w_full().h(px(80.0)))
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_library_host(
|
|
&self,
|
|
host: String,
|
|
ix: usize,
|
|
_host_count: usize,
|
|
labels: &std::collections::HashMap<String, String>,
|
|
icons: &std::collections::HashMap<String, String>,
|
|
cx: &mut Context<Self>,
|
|
) -> gpui::AnyElement {
|
|
let language = crate::gpui_shell::config::ui_language(cx);
|
|
let theme = cx.theme();
|
|
let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false);
|
|
let muted = theme.muted_foreground;
|
|
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
|
|
let font_px = cx
|
|
.try_global::<crate::gpui_shell::config::Settings>()
|
|
.map(|s| s.ui_font_size_px)
|
|
.unwrap_or(15.0);
|
|
let title_h = font_px * 0.9;
|
|
let subtitle_h = font_px * 0.8;
|
|
let delete_confirm = self.ssh_delete_confirm.clone();
|
|
|
|
let pinned = self.ssh_hosts.is_pinned(&host);
|
|
let from_config = self.ssh_hosts.is_from_config(&host);
|
|
let confirm = delete_confirm.as_deref() == Some(host.as_str());
|
|
let label = labels.get(&host).cloned().unwrap_or_else(|| host.clone());
|
|
let group = self.ssh_hosts.profiles.organization(&host).group.clone();
|
|
let profile = self.ssh_hosts.profiles.for_destination(&host);
|
|
let auth = host_auth_label(&profile, language);
|
|
// 行首 OS 图标(旧壳裁定 2026-08-09):id 取自 ssh_profiles 存储,
|
|
// 未认出回落通用终端形状;mono 字体渲染 Nerd Font 字位。
|
|
let os_icon = crate::display::ui::os_icons::resolve(icons.get(&host).map(String::as_str));
|
|
let context_owner = cx.entity().downgrade();
|
|
let context_host = host.clone();
|
|
let connect_host = host.clone();
|
|
let edit_host = host.clone();
|
|
let pin_host = host.clone();
|
|
let delete_host = host.clone();
|
|
div()
|
|
.h(px(HOST_CARD_EXTENT))
|
|
.w_full()
|
|
.pb(px(8.0))
|
|
.child(
|
|
h_flex()
|
|
.id(SharedString::from(format!("ssh-host-row-{ix}")))
|
|
.debug_selector(move || format!("ssh-host-row-{ix}"))
|
|
.h(px(HOST_CARD_HEIGHT))
|
|
.w_full()
|
|
.px_3()
|
|
.items_center()
|
|
.gap_3()
|
|
.rounded(px(6.0))
|
|
.border_1()
|
|
.border_color(theme.border)
|
|
.bg(theme.secondary.opacity(0.25))
|
|
.hover(move |row| row.bg(hover_bg))
|
|
.context_menu(move |menu, _, _| {
|
|
let connect_owner = context_owner.clone();
|
|
let edit_owner = context_owner.clone();
|
|
let duplicate_owner = context_owner.clone();
|
|
let delete_owner = context_owner.clone();
|
|
let connect_host = context_host.clone();
|
|
let edit_host = context_host.clone();
|
|
let duplicate_host = context_host.clone();
|
|
let delete_host = context_host.clone();
|
|
menu.item(
|
|
PopupMenuItem::new(language.text(Message::LauncherConnect)).on_click(
|
|
move |_, _, cx| {
|
|
let _ = connect_owner.update(cx, |this, cx| {
|
|
cx.emit(SettingsPaneEvent::LaunchSsh(connect_host.clone()));
|
|
this.ssh_status =
|
|
Some(SshStatus::Opening(connect_host.clone()));
|
|
cx.notify();
|
|
});
|
|
},
|
|
),
|
|
)
|
|
.item(PopupMenuItem::new(language.text(Message::LauncherEdit)).on_click(
|
|
move |_, window, cx| {
|
|
let _ = edit_owner.update(cx, |this, cx| {
|
|
this.open_ssh_editor(Some(edit_host.clone()), window, cx);
|
|
});
|
|
},
|
|
))
|
|
.item(PopupMenuItem::new(language.text(Message::CommonCopy)).on_click(
|
|
move |_, window, cx| {
|
|
let _ = duplicate_owner.update(cx, |this, cx| {
|
|
this.duplicate_ssh_host(duplicate_host.clone(), window, cx);
|
|
});
|
|
},
|
|
))
|
|
.item(
|
|
PopupMenuItem::new(language.text(Message::LauncherDelete)).on_click(
|
|
move |_, _, cx| {
|
|
let _ = delete_owner.update(cx, |this, cx| {
|
|
this.ssh_delete_confirm = Some(delete_host.clone());
|
|
cx.notify();
|
|
});
|
|
},
|
|
),
|
|
)
|
|
})
|
|
.child(
|
|
crate::gpui_shell::widgets::device_icon_container(cx)
|
|
.id(SharedString::from(format!("ssh-host-icon-{ix}")))
|
|
.debug_selector(move || format!("ssh-host-icon-{ix}"))
|
|
.child(host_icon(os_icon.glyph, symbol_family, muted)),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.justify_center()
|
|
.gap(px(3.0))
|
|
.child(
|
|
h_flex()
|
|
.min_w_0()
|
|
.items_center()
|
|
.text_size(px(title_h))
|
|
.line_height(px(title_h * 1.25))
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.min_w_0()
|
|
.truncate()
|
|
.font_weight(gpui::FontWeight::MEDIUM)
|
|
.child(label),
|
|
)
|
|
.when(!group.is_empty(), |line| {
|
|
line.child(
|
|
div()
|
|
.max_w(px(120.0))
|
|
.truncate()
|
|
.px(px(5.0))
|
|
.rounded_sm()
|
|
.text_xs()
|
|
.text_color(muted)
|
|
.bg(theme.secondary)
|
|
.child(group),
|
|
)
|
|
})
|
|
.when(from_config, |line| {
|
|
line.child(
|
|
div()
|
|
.flex_shrink_0()
|
|
.px(px(5.0))
|
|
.rounded_sm()
|
|
.text_size(px(10.0))
|
|
.text_color(muted)
|
|
.border_1()
|
|
.border_color(theme.border)
|
|
.child("ssh-config"),
|
|
)
|
|
}),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.min_w_0()
|
|
.gap_2()
|
|
.items_center()
|
|
.child(
|
|
div()
|
|
.min_w_0()
|
|
.text_size(px(subtitle_h))
|
|
.line_height(px(subtitle_h * 1.25))
|
|
.text_color(muted)
|
|
.truncate()
|
|
.child(host.clone()),
|
|
)
|
|
.child(div().text_color(muted).child("·"))
|
|
.child(
|
|
div()
|
|
.min_w_0()
|
|
.truncate()
|
|
.text_size(px(subtitle_h))
|
|
.text_color(muted)
|
|
.child(auth),
|
|
),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.flex_shrink_0()
|
|
.child(
|
|
Button::new(SharedString::from(format!("ssh-connect-{ix}")))
|
|
.debug_selector(move || format!("ssh-connect-{ix}"))
|
|
.label(language.text(Message::HostsConnect))
|
|
.small()
|
|
.on_click(cx.listener(move |this, _, _, cx| {
|
|
cx.emit(SettingsPaneEvent::LaunchSsh(connect_host.clone()));
|
|
this.ssh_status =
|
|
Some(SshStatus::Opening(connect_host.clone()));
|
|
cx.notify();
|
|
})),
|
|
)
|
|
.child(
|
|
Button::new(SharedString::from(format!("ssh-edit-{ix}")))
|
|
.debug_selector(move || format!("ssh-edit-{ix}"))
|
|
.icon(IconName::Settings2)
|
|
.ghost()
|
|
.small()
|
|
.size(px(32.0))
|
|
.tooltip(language.pick("编辑主机", "Edit host"))
|
|
.on_click(cx.listener(move |this, _, window, cx| {
|
|
this.open_ssh_editor(Some(edit_host.clone()), window, cx);
|
|
})),
|
|
)
|
|
.child(
|
|
Button::new(SharedString::from(format!("ssh-pin-{ix}")))
|
|
.debug_selector(move || format!("ssh-pin-{ix}"))
|
|
.icon(Icon::default().path(crate::gpui_shell::assets::nav::PIN))
|
|
.ghost()
|
|
.small()
|
|
.size(px(32.0))
|
|
.selected(pinned)
|
|
.toggled(pinned)
|
|
.tooltip(if pinned {
|
|
language.pick("取消置顶", "Unpin")
|
|
} else {
|
|
language.pick("置顶", "Pin")
|
|
})
|
|
.on_click(cx.listener(move |this, _, _, cx| {
|
|
this.ssh_apply(
|
|
|lists| lists.toggle_pin(&pin_host),
|
|
SshStatus::Pinned,
|
|
cx,
|
|
);
|
|
})),
|
|
)
|
|
.child(
|
|
Button::new(SharedString::from(format!("ssh-delete-{ix}")))
|
|
.debug_selector(move || format!("ssh-delete-{ix}"))
|
|
.map(|button| {
|
|
if confirm {
|
|
button
|
|
.label(language.pick("确认删除", "Confirm delete"))
|
|
.danger()
|
|
.small()
|
|
} else {
|
|
button
|
|
.icon(
|
|
Icon::default().path(
|
|
crate::gpui_shell::assets::nav::TRASH,
|
|
),
|
|
)
|
|
.ghost()
|
|
.small()
|
|
.size(px(32.0))
|
|
.tooltip(if from_config {
|
|
language.tr("settings.ssh.hide_config_host")
|
|
} else {
|
|
language.pick("删除", "Delete")
|
|
})
|
|
}
|
|
})
|
|
.on_click(cx.listener(move |this, _, _, cx| {
|
|
if this.ssh_delete_confirm.as_deref()
|
|
== Some(delete_host.as_str())
|
|
{
|
|
this.delete_ssh_host(&delete_host, cx);
|
|
} else {
|
|
this.ssh_delete_confirm = Some(delete_host.clone());
|
|
cx.notify();
|
|
}
|
|
})),
|
|
),
|
|
),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
pub(in crate::gpui_shell) fn section_ssh(&mut self, cx: &mut Context<Self>) -> gpui::Div {
|
|
let language = crate::gpui_shell::config::ui_language(cx);
|
|
let controls = self.library_controls(cx);
|
|
let header = self.library_header(cx);
|
|
let config_banner = self.library_config_banner(cx);
|
|
let theme = cx.theme();
|
|
let muted = theme.muted_foreground;
|
|
let hosts = self.filtered_library_hosts(cx);
|
|
let host_count = hosts.len();
|
|
let labels = self.ssh_hosts.profiles.labels();
|
|
let icons = self.ssh_hosts.profiles.icons();
|
|
let hidden: Vec<String> = self.ssh_hosts.hidden_hosts().to_vec();
|
|
|
|
let row_hosts = hosts.clone();
|
|
let host_rows = gpui::uniform_list(
|
|
"ssh-library-hosts",
|
|
host_count,
|
|
cx.processor(move |this, range: std::ops::Range<usize>, _, cx| {
|
|
range
|
|
.map(|index| {
|
|
this.render_library_host(
|
|
row_hosts[index].clone(),
|
|
index,
|
|
host_count,
|
|
&labels,
|
|
&icons,
|
|
cx,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
}),
|
|
)
|
|
.track_scroll(&self.ssh_library.scroll)
|
|
.w_full()
|
|
.h(px(HOST_CARD_EXTENT * host_count.clamp(1, 8) as f32));
|
|
|
|
let hidden_rows = self.ssh_show_hidden.then(|| {
|
|
hidden
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(ix, host)| {
|
|
let restore_host = host.clone();
|
|
h_flex()
|
|
.h(px(32.0))
|
|
.w_full()
|
|
.px_3()
|
|
.items_center()
|
|
.gap_2()
|
|
.when(ix + 1 < hidden.len(), |row| {
|
|
row.border_b_1().border_color(theme.border.opacity(0.5))
|
|
})
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.text_sm()
|
|
.text_color(muted)
|
|
.truncate()
|
|
.child(host.clone()),
|
|
)
|
|
.child(
|
|
NebulaButton::new(SharedString::from(format!("ssh-restore-{ix}")))
|
|
.label(language.pick("恢复", "Restore"))
|
|
.on_click(cx.listener(move |this, _, _, cx| {
|
|
this.ssh_apply(
|
|
|lists| lists.restore_hidden(&restore_host),
|
|
SshStatus::Restored(restore_host.clone()),
|
|
cx,
|
|
);
|
|
})),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
});
|
|
|
|
let hidden_count = self.ssh_hosts.hidden_hosts().len();
|
|
let undo_bar = self.ssh_delete_undo.as_ref().map(|undo| (undo.host.clone(), undo.seq));
|
|
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_3()
|
|
.child(header)
|
|
.child(config_banner)
|
|
.child(controls)
|
|
.children(
|
|
self.ssh_hosts
|
|
.load_error
|
|
.as_ref()
|
|
.map(|error| div().text_sm().text_color(theme.danger).child(error.clone())),
|
|
)
|
|
.child(v_flex().w_full().when(host_count > 0, |card| card.child(host_rows)).when(
|
|
host_count == 0,
|
|
|card| {
|
|
card.child(
|
|
v_flex()
|
|
.py_6()
|
|
.gap_1()
|
|
.items_center()
|
|
.child(
|
|
div()
|
|
.text_color(muted)
|
|
.child(language.text(Message::HostsNoMatches)),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_xs()
|
|
.text_color(muted)
|
|
.child(language.tr("settings.ssh.empty_hint")),
|
|
),
|
|
)
|
|
},
|
|
))
|
|
.when(hidden_count > 0, |group| {
|
|
let show = self.ssh_show_hidden;
|
|
group.child(
|
|
NebulaButton::new("ssh-toggle-hidden")
|
|
.label(if show {
|
|
SharedString::from(language.pick("收起已隐藏", "Collapse hidden"))
|
|
} else {
|
|
SharedString::from(format!(
|
|
"{} {hidden_count}",
|
|
language.pick("已隐藏", "Hidden")
|
|
))
|
|
})
|
|
.on_click(cx.listener(|this, _, _, cx| {
|
|
this.ssh_show_hidden = !this.ssh_show_hidden;
|
|
cx.notify();
|
|
})),
|
|
)
|
|
})
|
|
.when_some(hidden_rows, |group, rows| {
|
|
group.child(div().h(px(8.0))).child(
|
|
v_flex()
|
|
.w_full()
|
|
.rounded(px(8.0))
|
|
.border_1()
|
|
.border_color(theme.border)
|
|
.overflow_hidden()
|
|
.children(rows),
|
|
)
|
|
})
|
|
.when_some(undo_bar, |group, (host, _)| {
|
|
group.child(div().h(px(8.0))).child(
|
|
h_flex()
|
|
.h(px(36.0))
|
|
.px_3()
|
|
.items_center()
|
|
.gap_2()
|
|
.rounded(px(6.0))
|
|
.bg(theme.muted)
|
|
.child(Icon::new(IconName::Undo2).xsmall().text_color(muted))
|
|
.child(div().flex_1().text_sm().child(SharedString::from(format!(
|
|
"{} {host}; {} {SSH_DELETE_UNDO_SECS} {}",
|
|
language.pick("已删除", "Deleted"),
|
|
language.pick("可在", "undo within"),
|
|
language.pick("秒", "seconds")
|
|
))))
|
|
.child(
|
|
NebulaButton::new("ssh-undo-delete")
|
|
.label(language.pick("撤销", "Undo"))
|
|
.outline()
|
|
.on_click(cx.listener(|this, _, _, cx| this.undo_ssh_delete(cx))),
|
|
),
|
|
)
|
|
})
|
|
.when_some(self.ssh_status.clone(), |group, status| {
|
|
let error = status.is_error();
|
|
let message = status.text(language);
|
|
group.child(
|
|
div()
|
|
.pt(px(6.0))
|
|
.text_sm()
|
|
.text_color(if error { theme.danger } else { theme.success })
|
|
.child(message),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
fn host_auth_label(
|
|
profile: &crate::ssh_profiles::SshProfileAuth,
|
|
language: crate::display::UiLanguage,
|
|
) -> String {
|
|
use crate::ssh_profiles::SshAuthMode;
|
|
if profile.auth == SshAuthMode::PublicKey {
|
|
if let Some(name) = profile.private_keys.first().and_then(|path| path.file_name()) {
|
|
return name.to_string_lossy().into_owned();
|
|
}
|
|
}
|
|
language
|
|
.text(match profile.auth {
|
|
SshAuthMode::Auto => Message::HostsAuthAuto,
|
|
SshAuthMode::Password => Message::HostsAuthPassword,
|
|
SshAuthMode::PublicKey => Message::HostsAuthKey,
|
|
SshAuthMode::KeyboardInteractive => Message::HostsAuthInteractive,
|
|
})
|
|
.to_owned()
|
|
}
|