Compare commits

..
Author SHA1 Message Date
TomZz 3c6b9e3b46 fix: correct active window check logic to fix window controls greying out when app loses focus 2026-06-24 18:06:52 +08:00
TomZz a88379b01c fix: prevent sidebar from flashing and resizing on window resize / fullscreen 2026-06-24 18:01:40 +08:00
TomZz f322b2c717 fix: beautify window control traffic lights on Windows/Linux 2026-06-24 17:46:57 +08:00
TomZz 45d0c64c06 fix: resolve gpui window not found on exit and restore layout saving on Windows 2026-06-24 17:25:25 +08:00
TomZz 6312a482cb fix: preserve original windowed bounds when saving maximized state to fix restore size on startup 2026-06-24 17:12:33 +08:00
TomZz 8bb4cbc655 fix: register WindowControlArea for window controls on non-macos to support restore toggling 2026-06-24 16:58:22 +08:00
TomZz 847f0626f4 refactor: use titlebar_double_click to toggle maximize/restore correctly 2026-06-24 16:24:15 +08:00
TomZz 7cfe5c64d5 refactor: manually implement Default for ConfigFile to respect default settings 2026-06-23 23:57:30 +08:00
TomZz 6d23d27fea feat: implement throttled auto-scrolling when dragging selection near viewport boundaries 2026-06-23 15:39:01 +08:00
TomZz da856ac9d7 fix: clamp selection coordinates when they scroll out of viewport to prevent selection from breaking 2026-06-23 15:28:46 +08:00
TomZz 7653c66a6f feat: add setting switch for terminal keyword highlighting under copy/paste option 2026-06-23 10:44:13 +08:00
TomZz 56fe508122 refactor: optimize keyword highlighting by caching results and moving logic out of custom_blocks 2026-06-23 10:44:11 +08:00
phygerr 3b7165a4bd feature:1、新增Phyger主题;2、支持terminal搜索 (#52) 2026-06-23 10:43:26 +08:00
TomZz 5ae44c9e44 bump version to 0.4.7 2026-06-23 00:23:11 +08:00
TomZz 73938aef8a feat: support keyboard-interactive auth and interactive passphrase prompts (resolves #33) 2026-06-23 00:16:48 +08:00
23 changed files with 2395 additions and 401 deletions
Generated
+1 -1
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.4.6"
version = "0.4.7"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.4.6"
version = "0.4.7"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
+308
View File
@@ -0,0 +1,308 @@
{
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
"name": "Phyger",
"author": "phygerr",
"url": "https://github.com/phygerr",
"themes": [
{
"name": "Phyger Light",
"mode": "light",
"colors": {
"accent.background": "#D6E4F0",
"accent.foreground": "#1B3A5C",
"background": "#F8F9FC",
"border": "#D0D5DD",
"danger.background": "#D94848",
"danger.active.background": "#B53535",
"danger.foreground": "#FFFFFF",
"foreground": "#2B2D30",
"input.border": "#C0C7D0",
"link.active.foreground": "#2563EB",
"link.foreground": "#2563EB",
"link.hover.foreground": "#1D4ED8",
"list.active.background": "#2563EB18",
"list.hover.background": "#E8EDF4",
"list.active.border": "#2563EB",
"list.even.background": "#F0F2F7",
"muted.background": "#E2E6EE88",
"muted.foreground": "#7A7F88",
"panel.background": "#ECEEF3",
"popover.background": "#F8F9FC",
"popover.foreground": "#2B2D30",
"primary.active.background": "#1D4ED8",
"primary.background": "#2563EB",
"primary.foreground": "#FFFFFF",
"primary.hover.background": "#3B72F0",
"ring": "#2563EB",
"scrollbar.background": "#F8F9FC00",
"scrollbar.thumb.background": "#C0C7D0",
"secondary.background": "#E8EDF4",
"secondary.active.background": "#D0D5DD",
"secondary.foreground": "#2B2D30",
"secondary.hover.background": "#DCE2EB",
"tab.active.background": "#F8F9FC",
"tab.active.foreground": "#2B2D30",
"tab.background": "#ECEEF300",
"tab.foreground": "#7A7F88",
"tab_bar.background": "#ECEEF3",
"title_bar.background": "#ECEEF3",
"title_bar.border": "#D0D5DD",
"base.magenta.light": "#C2185B44",
"base.red": "#D32F2F",
"base.green": "#2E7D32",
"base.yellow": "#BF8C00",
"base.blue": "#1565C0",
"base.magenta": "#8E24AA",
"base.cyan": "#00838F"
},
"highlight": {
"editor.foreground": "#2B2D30",
"editor.background": "#F8F9FC",
"editor.active_line.background": "#E8EDF4",
"editor.line_number": "#A0A5AE",
"editor.active_line_number": "#2B2D30",
"editor.invisible": "#A0A5AE55",
"conflict": "#D32F2F",
"created": "#2E7D32",
"deleted": "#C62828",
"error": "#D32F2F",
"hidden": "#A0A5AE",
"hint": "#1565C0",
"ignored": "#A0A5AE",
"info": "#00838F",
"modified": "#BF8C00",
"predictive": "#A0A5AE",
"renamed": "#BF8C00",
"success": "#2E7D32",
"unreachable": "#A0A5AE",
"warning": "#E65100",
"syntax": {
"attribute": {
"color": "#7B1FA2"
},
"boolean": {
"color": "#00838F"
},
"comment": {
"color": "#6A737D",
"font_style": "italic"
},
"comment.doc": {
"color": "#6A737D",
"font_style": "italic"
},
"constant": {
"color": "#00838F"
},
"constructor": {
"color": "#1565C0"
},
"embedded": {
"color": "#2B2D30"
},
"function": {
"color": "#6A1B9A"
},
"keyword": {
"color": "#1565C0"
},
"link_text": {
"color": "#1565C0",
"font_style": "normal"
},
"link_uri": {
"color": "#00838F",
"font_style": "italic"
},
"number": {
"color": "#1B5E20"
},
"string": {
"color": "#BF360C"
},
"string.escape": {
"color": "#BF360C"
},
"string.regex": {
"color": "#BF360C"
},
"string.special": {
"color": "#00838F"
},
"string.special.symbol": {
"color": "#00838F"
},
"tag": {
"color": "#1565C0"
},
"text.literal": {
"color": "#00838F"
},
"title": {
"color": "#6A1B9A",
"font_weight": 600
},
"type": {
"color": "#2E7D32"
},
"property": {
"color": "#2B2D30"
},
"variable.special": {
"color": "#D32F2F"
}
}
}
},
{
"name": "Phyger Dark",
"mode": "dark",
"colors": {
"accent.background": "#2A3040",
"accent.foreground": "#C8D3E0",
"background": "#1A1D23",
"border": "#2E3340",
"danger.background": "#E05555",
"danger.active.background": "#CC3D3D",
"danger.foreground": "#FFFFFF",
"foreground": "#C8CED8",
"input.border": "#3A4050",
"link.active.foreground": "#6CB4EE",
"link.foreground": "#6CB4EE",
"link.hover.foreground": "#8FC8F6",
"list.active.background": "#6CB4EE15",
"list.hover.background": "#252A34",
"list.active.border": "#6CB4EE",
"list.even.background": "#1E2229",
"muted.background": "#252A3466",
"muted.foreground": "#6B7280",
"panel.background": "#1E2229",
"popover.background": "#1A1D23",
"popover.foreground": "#C8CED8",
"primary.active.background": "#4A90D9",
"primary.background": "#5BA0E0",
"primary.foreground": "#1A1D23",
"primary.hover.background": "#6CB4EE",
"ring": "#5BA0E0",
"scrollbar.background": "#1A1D2300",
"scrollbar.thumb.background": "#3A4050",
"secondary.active.background": "#333A48",
"secondary.background": "#252A34",
"secondary.foreground": "#C8CED8",
"secondary.hover.background": "#2E3542",
"tab.active.background": "#1A1D23",
"tab.active.foreground": "#C8CED8",
"tab.background": "#252A3400",
"tab.foreground": "#6B7280",
"tab_bar.background": "#1E2229DD",
"title_bar.background": "#1E2229",
"title_bar.border": "#2E3340",
"base.magenta.light": "#C792EA44",
"base.red": "#E06060",
"base.green": "#7EC699",
"base.yellow": "#E8C97A",
"base.blue": "#6CB4EE",
"base.magenta": "#C792EA",
"base.cyan": "#56D4C7"
},
"highlight": {
"editor.foreground": "#C8CED8",
"editor.background": "#1A1D23",
"editor.active_line.background": "#222730",
"editor.line_number": "#5A6170",
"editor.active_line_number": "#C8CED8",
"editor.invisible": "#5A617055",
"conflict": "#E06060",
"created": "#7EC699",
"deleted": "#CC4444",
"error": "#E06060",
"hidden": "#6B7280",
"hint": "#C792EA",
"ignored": "#5A6170",
"info": "#56D4C7",
"modified": "#E8C97A",
"predictive": "#4A5568",
"renamed": "#E8C97A",
"success": "#7EC699",
"unreachable": "#5A6170",
"warning": "#E8C97A",
"syntax": {
"attribute": {
"color": "#C792EA"
},
"boolean": {
"color": "#56D4C7"
},
"comment": {
"color": "#5A6A7A",
"font_style": "italic"
},
"comment.doc": {
"color": "#5A6A7A",
"font_style": "italic"
},
"constant": {
"color": "#56D4C7"
},
"constructor": {
"color": "#6CB4EE"
},
"embedded": {
"color": "#C8CED8"
},
"function": {
"color": "#E2C08D"
},
"keyword": {
"color": "#6CB4EE"
},
"link_text": {
"color": "#6CB4EE",
"font_style": "normal"
},
"link_uri": {
"color": "#56D4C7",
"font_style": "italic"
},
"number": {
"color": "#7EC699"
},
"string": {
"color": "#E8A87C"
},
"string.escape": {
"color": "#E8A87C"
},
"string.regex": {
"color": "#E8A87C"
},
"string.special": {
"color": "#56D4C7"
},
"string.special.symbol": {
"color": "#56D4C7"
},
"tag": {
"color": "#6CB4EE"
},
"text.literal": {
"color": "#56D4C7"
},
"title": {
"color": "#E2C08D",
"font_weight": 600
},
"type": {
"color": "#7EC699"
},
"property": {
"color": "#C8CED8"
},
"variable.special": {
"color": "#E06060"
}
}
}
}
]
}
+5
View File
@@ -111,6 +111,7 @@ zombie_reason: "This transfer was interrupted because the application exited. Th
software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
keyword_highlight: "Terminal Keyword Highlighting"
copy_paste_hint: "Regardless of this switch, you can always use %{key} + C/V to copy/paste."
edit_file: "Edit File"
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
@@ -142,6 +143,9 @@ files_uploading: "%{count} files uploading"
panel_minimize: "Minimize Panel"
panel_expand: "Expand Panel"
settings_new_ssh: "New SSH"
settings_open_search: "Search"
search: "Search"
no_results: "No results"
settings_toggle_sidebar: "Toggle Sidebar"
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
settings_focus_pane_left: "Focus Pane Left"
@@ -179,3 +183,4 @@ sync_downloading: "Downloading and decrypting configuration..."
sync_upload_complete: "Configuration uploaded"
sync_download_complete: "Configuration downloaded"
sync_failed: "Synchronization failed"
keyboard_interactive: "Keyboard Interactive"
+5
View File
@@ -112,6 +112,7 @@ zombie_reason: "此传输任务因程序退出而中断,传输进程已不存
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
keyword_highlight: "终端关键词高亮"
copy_paste_hint: "不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
@@ -144,6 +145,9 @@ files_uploading: "%{count} 个文件正在上传"
panel_minimize: "折叠面板"
panel_expand: "展开面板"
settings_new_ssh: "新建 SSH"
settings_open_search: "搜索"
search: "搜索"
no_results: "无结果"
settings_toggle_sidebar: "缩放侧边栏"
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
settings_focus_pane_left: "向左切换焦点"
@@ -181,3 +185,4 @@ sync_downloading: "正在下载并解密配置..."
sync_upload_complete: "配置上传完成"
sync_download_complete: "配置下载完成"
sync_failed: "同步失败"
keyboard_interactive: "键盘交互"
+143 -10
View File
@@ -1,5 +1,5 @@
use gpui::{
Anchor, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton,
Anchor, AppContext as _, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton,
ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
prelude::FluentBuilder as _, px, rems,
};
@@ -8,7 +8,7 @@ use gpui_component::{
button::{Button, ButtonVariants as _},
dialog::Dialog,
h_flex,
input::Input,
input::{Input, InputState},
menu::{DropdownMenu as _, PopupMenuItem},
progress::Progress,
scroll::{Scrollbar, ScrollbarShow},
@@ -62,7 +62,10 @@ impl Ashell {
let key_inline_input = key_inline_input.clone();
let passphrase_input = passphrase_input.clone();
move |content, window, cx| {
let is_password = view.read(cx).ssh_auth_method == AuthMethod::Password;
let method = view.read(cx).ssh_auth_method;
let is_password = method == AuthMethod::Password;
let is_key = method == AuthMethod::Key;
let is_kb = method == AuthMethod::KeyboardInteractive;
let is_editing = view.read(cx).editing_session_id.is_some();
content.child(
v_flex()
@@ -89,13 +92,13 @@ impl Ashell {
AuthMethod::Password,
cx,
)
},
)),
},
)),
)
.child(
Button::new("ssh-auth-key")
.label(t!("key").to_string())
.when(!is_password, |button| button.primary())
.when(is_key, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
@@ -103,8 +106,22 @@ impl Ashell {
AuthMethod::Key,
cx,
)
},
)),
},
)),
)
.child(
Button::new("ssh-auth-kb")
.label(t!("keyboard_interactive").to_string())
.when(is_kb, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_auth_method(
AuthMethod::KeyboardInteractive,
cx,
)
},
)),
),
)
.when(is_password, |this| {
@@ -112,7 +129,7 @@ impl Ashell {
Input::new(&password_input).mask_toggle().tab_index(4),
)
})
.when(!is_password, |this| {
.when(is_key, |this| {
this.child(
h_flex()
.gap_2()
@@ -1527,6 +1544,25 @@ impl Ashell {
})
).description(t!("copy_paste_hint", key = if cfg!(target_os = "macos") { "Command" } else { "Ctrl" }).to_string())
)
.item(
SettingItem::new(
t!("keyword_highlight").to_string(),
SettingField::render({
let view = view_clone_for_general.clone();
move |_, window, cx| {
Switch::new("keyword-highlight")
.small()
.checked(view.read(cx).config.keyword_highlight())
.on_click(window.listener_for(&view, |this, checked, _, cx| {
this.config.set_keyword_highlight(*checked);
let _ = this.config.save();
cx.notify();
}))
.into_any_element()
}
})
)
)
.item(
SettingItem::new(
t!("monitoring_position").to_string(),
@@ -1761,7 +1797,7 @@ impl Ashell {
)
.child(
div()
.text_size(rems(0.9))
.text_size(rems(0.9))
.text_color(cx.theme().muted_foreground)
.child(t!("about_feedback_hint")),
)
@@ -1782,4 +1818,101 @@ impl Ashell {
})
});
}
pub(crate) fn show_interactive_prompt_dialog(
&mut self,
tab_id: String,
prompt_type: crate::terminal::PromptType,
instruction: String,
prompts: Vec<crate::terminal::PromptInfo>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let view = cx.entity();
// Dynamically instantiate InputState for each prompt
let mut input_states = Vec::new();
for p in &prompts {
let is_masked = !p.echo;
let input_state = cx.new(|cx| {
let mut state = InputState::new(window, cx).placeholder(p.prompt.clone());
if is_masked {
state = state.masked(true);
}
state
});
input_states.push(input_state);
}
let tab_id_clone = tab_id.clone();
let input_states_clone = input_states.clone();
self.active_dialog = Some(crate::app::DialogKind::PromptRequest);
let tab_id_for_close = tab_id.clone();
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
let title = match prompt_type {
crate::terminal::PromptType::KeyboardInteractive => "Keyboard Interactive Authentication",
crate::terminal::PromptType::Passphrase => "Enter Private Key Passphrase",
};
let tab_id_for_ok = tab_id_clone.clone();
let input_states_for_ok = input_states_clone.clone();
let view_for_ok = view.clone();
let view_for_close = view.clone();
let tab_id_for_close = tab_id_for_close.clone();
let instruction_for_content = instruction.clone();
let input_states_for_content = input_states_clone.clone();
dialog
.title(title)
.w(px(500.))
.overlay_closable(false)
.on_close(move |_, _, cx| {
view_for_close.update(cx, |this, cx| {
this.active_dialog = None;
// Send Close command to abort connection if they close the dialog without OK
if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_close) {
tab.backend.send(crate::terminal::BackendCommand::Close);
}
cx.notify();
});
})
.on_ok(move |_, window, cx| {
view_for_ok.update(cx, |this, cx| {
this.active_dialog = None;
let mut responses = Vec::new();
for state in &input_states_for_ok {
responses.push(state.read(cx).text().to_string());
}
if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_ok) {
tab.backend.send(crate::terminal::BackendCommand::PromptResponse(responses));
}
cx.notify();
});
window.close_dialog(cx);
true
})
.content(move |content, _window, _cx| {
let mut container = v_flex().gap_3();
if !instruction_for_content.is_empty() {
container = container.child(
div()
.text_sm()
.text_color(gpui::rgba(0x808080ff))
.child(instruction_for_content.clone())
);
}
for input_state in &input_states_for_content {
container = container.child(
Input::new(input_state).w_full()
);
}
content.child(container)
})
});
}
}
+9 -1
View File
@@ -18,6 +18,7 @@ gpui::actions!(
OpenSession,
OpenTransfers,
NewSsh,
OpenSearch,
ToggleSidebar,
ToggleSftpZoom,
FocusPaneLeft,
@@ -62,6 +63,11 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
label_key: "settings_new_ssh",
default_suffix: "n",
},
WorkspaceAction {
id: "OpenSearch",
label_key: "settings_open_search",
default_suffix: "f",
},
WorkspaceAction {
id: "ToggleSidebar",
label_key: "settings_toggle_sidebar",
@@ -206,6 +212,7 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
unbind_action!("OpenSession", crate::OpenSession);
unbind_action!("OpenTransfers", crate::OpenTransfers);
unbind_action!("NewSsh", crate::NewSsh);
unbind_action!("OpenSearch", crate::OpenSearch);
unbind_action!("ToggleSidebar", crate::ToggleSidebar);
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
@@ -261,6 +268,7 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bind_action!("OpenSession", crate::OpenSession);
bind_action!("OpenTransfers", crate::OpenTransfers);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("OpenSearch", crate::OpenSearch);
bind_action!("ToggleSidebar", crate::ToggleSidebar);
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
@@ -281,7 +289,7 @@ impl KeybindingsPage {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh"],
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh", "OpenSearch"],
),
(
"settings_group_keybind_zoom",
+156 -11
View File
@@ -2,6 +2,7 @@ pub mod config_sync;
pub mod constants;
pub mod dialogs;
pub mod keybinding_recorder;
pub mod search;
pub mod startup;
pub mod theme;
pub mod ui;
@@ -196,6 +197,7 @@ pub(crate) enum DialogKind {
SessionSelector,
Transfers,
NewSsh,
PromptRequest,
}
pub(crate) struct Ashell {
@@ -287,6 +289,14 @@ pub(crate) struct Ashell {
pub(crate) last_system_sample: Instant,
pub(crate) last_theme_sync: Instant,
pub(crate) search_input: Entity<InputState>,
pub(crate) search_active: bool,
pub(crate) search_query: String,
pub(crate) search_matches: Vec<(i32, i32)>,
pub(crate) search_current: usize,
pub(crate) search_target_tab: Option<String>,
pub(crate) search_bar_bounds: Option<Bounds<Pixels>>,
pub(crate) system_tab_id: Option<String>,
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
@@ -294,6 +304,8 @@ pub(crate) struct Ashell {
pub(crate) runtime: Runtime,
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
pub(crate) last_window_size: Option<gpui::Size<Pixels>>,
pub(crate) last_sidebar_width: Option<Pixels>,
pub(crate) _subscriptions: Vec<gpui::Subscription>,
}
@@ -367,6 +379,9 @@ impl Ashell {
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
let sftp_new_folder_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
let search_input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t!("search").to_string())
});
let config = ConfigStore::load().unwrap_or_else(|err| {
tracing::warn!("failed to load config: {err:#}");
ConfigStore::in_memory()
@@ -436,6 +451,7 @@ impl Ashell {
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
cx.subscribe_in(&search_input, window, Self::on_input_event),
cx.subscribe_in(&sync_endpoint_input, window, Self::on_input_event),
cx.subscribe_in(&sync_username_input, window, Self::on_input_event),
cx.subscribe_in(&sync_webdav_password_input, window, Self::on_input_event),
@@ -492,6 +508,10 @@ impl Ashell {
gpui_component::set_locale(&active_locale);
let ui_font_family: SharedString = config.ui_font_family().into();
let terminal_font_family: SharedString = config.terminal_font_family().into();
let last_sidebar_width = Some(px(config
.workspace_panels()
.and_then(|s| s.first().copied())
.unwrap_or(constants::SIDEBAR_WIDTH)));
let mut this = Self {
focus_handle: cx.focus_handle(),
selector_focus_handle: cx.focus_handle(),
@@ -592,6 +612,14 @@ impl Ashell {
last_system_sample: Instant::now(),
last_theme_sync: Instant::now(),
search_input,
search_active: false,
search_query: String::new(),
search_matches: Vec::new(),
search_current: 0,
search_target_tab: None,
search_bar_bounds: None,
system_tab_id: None,
sftp_handles: std::collections::HashMap::new(),
@@ -599,12 +627,14 @@ impl Ashell {
runtime: Runtime::new().expect("create tokio runtime"),
events_rx,
events_tx,
last_window_size: None,
last_sidebar_width,
_subscriptions,
};
this.apply_theme_preferences(window, cx);
// this.open_local(cx);
this.start_event_pump(cx);
this.start_event_pump(window, cx);
this
}
@@ -650,21 +680,35 @@ impl Ashell {
}
_ => {}
}
} else if input == &self.search_input {
if let InputEvent::PressEnter { .. } = event {
if self.search_query.is_empty()
|| self.search_input.read(cx).text().to_string() != self.search_query
{
self.perform_search(window, cx);
} else {
self.search_goto_next(cx);
}
window.prevent_default();
cx.stop_propagation();
}
}
cx.notify();
}
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
pub(crate) fn start_event_pump(&self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, mut cx| {
let mut idle_frames = 0u32;
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
if this
.update(cx, |this, cx| {
let changed = this.drain_backend_events();
let system_sampled = this.sample_system_if_due();
let mut changed = false;
let mut system_sampled = false;
let update_res = gpui::AsyncWindowContext::update(&mut cx, |window, cx| {
let _ = this.update(cx, |this, cx| {
changed = this.drain_backend_events(window, cx);
system_sampled = this.sample_system_if_due();
this.sync_theme_if_due(cx);
if changed || system_sampled {
cx.notify();
@@ -676,9 +720,9 @@ impl Ashell {
idle_frames = 0;
}
}
})
.is_err()
{
});
});
if update_res.is_err() {
break;
}
}
@@ -686,7 +730,7 @@ impl Ashell {
.detach();
}
pub(crate) fn drain_backend_events(&mut self) -> bool {
pub(crate) fn drain_backend_events(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let mut changed = false;
let mut transfers_changed = false;
while let Ok(event) = self.events_rx.try_recv() {
@@ -725,6 +769,21 @@ impl Ashell {
self.connection_progress = None;
}
}
BackendEvent::PromptRequest {
tab_id,
prompt_type,
instruction,
prompts,
} => {
self.show_interactive_prompt_dialog(
tab_id,
prompt_type,
instruction,
prompts,
window,
cx,
);
}
BackendEvent::SftpEntries {
tab_id,
path,
@@ -1015,4 +1074,90 @@ impl Ashell {
self.config.set_transfers(self.transfers.clone());
cx.notify();
}
pub(crate) fn save_layout_state(&self, window: &mut gpui::Window, cx: &gpui::App) {
if self.is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
return;
}
let current_bounds = window.window_bounds();
let bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => b,
gpui::WindowBounds::Maximized(b) => b,
gpui::WindowBounds::Windowed(b) => b,
};
let size = bounds.size;
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
tracing::info!("[ui] saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let saved_bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => {
crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Maximized(b) => {
let mut restore_bounds = (b.origin.x.into(), b.origin.y.into(), b.size.width.into(), b.size.height.into());
if let Some(existing_bounds) = config.window_bounds() {
match existing_bounds {
crate::session::config::SavedWindowBounds::Windowed { x, y, width, height } => {
restore_bounds = (*x, *y, *width, *height);
}
crate::session::config::SavedWindowBounds::Maximized { x, y, width, height } => {
restore_bounds = (*x, *y, *width, *height);
}
_ => {}
}
}
crate::session::config::SavedWindowBounds::Maximized {
x: restore_bounds.0,
y: restore_bounds.1,
width: restore_bounds.2,
height: restore_bounds.3,
}
}
gpui::WindowBounds::Windowed(b) => {
crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
};
let workspace_sizes: Vec<f32> = self.workspace_panels
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
let mut body_sizes: Vec<f32> = self.body_panels
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if self.sftp_panel_minimized {
if let Some(prev) = self.prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(self.sidebar_collapsed);
config.set_sftp_panel_minimized(self.sftp_panel_minimized);
let _ = config.save();
} else {
tracing::warn!(
"[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.",
size
);
}
}
}
+486
View File
@@ -0,0 +1,486 @@
use std::collections::HashMap;
use gpui::{
Context, Focusable as _, Hsla, IntoElement, InteractiveElement as _, MouseButton,
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex, input::Input,
};
use rust_i18n::t;
use crate::Ashell;
impl Ashell {
pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.search_active {
self.close_search(window, cx);
} else {
self.open_search(window, cx);
}
}
pub(crate) fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = true;
// Focus the search input on the next frame so it happens after the
// current render cycle completes, avoiding focus being stolen back
// by the terminal panel's track_focus.
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
cx.notify();
}
pub(crate) fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = false;
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
self.search_bar_bounds = None;
self.focus_handle.focus(window, cx);
cx.notify();
}
/// Move keyboard focus back to the search input so the user can keep typing.
/// Deferred to the next frame so it happens after the current render cycle,
/// preventing the terminal panel's track_focus from stealing focus back.
fn refocus_search_input(&self, window: &mut Window, cx: &mut Context<Self>) {
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
}
pub(crate) fn perform_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let query = self.search_input.read(cx).text().to_string();
if query.is_empty() {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.refocus_search_input(window, cx);
cx.notify();
return;
}
// Find the active tab — try active_tab first, then fall back to the
// first tab in the active group, then any tab.
let tab = self
.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
let tab = tab.or_else(|| {
let first_id = self
.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string());
first_id
.as_deref()
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
});
let tab = tab.or_else(|| self.tabs.first());
let Some(tab) = tab else {
self.status = t!("no_results").into();
self.refocus_search_input(window, cx);
cx.notify();
return;
};
// Remember which tab was searched so highlights only appear in that pane.
self.search_target_tab = Some(tab.id.clone());
let query_lower = query.to_lowercase();
let query_byte_len = query.len();
// Search the ENTIRE terminal buffer (scrollback + visible screen).
// full_grid_rows returns grid line indices; the first row is at
// `grid_start` (typically -history_size).
let (grid_start, all_rows) = tab.full_grid_rows();
let mut matches: Vec<(i32, i32)> = Vec::new();
for (row_idx, row) in all_rows.iter().enumerate() {
if row.is_empty() {
continue;
}
// Build text string and byte→column index mapping.
let mut text = String::with_capacity(row.len());
let mut byte_to_col: Vec<i32> = Vec::new();
for &(col, c) in row {
text.push(c);
while byte_to_col.len() < text.len() {
byte_to_col.push(col);
}
}
let text_lower = text.to_lowercase();
// Grid line index for this row.
let abs_row = grid_start + row_idx as i32;
let mut search_start = 0;
while let Some(pos) = text_lower[search_start..].find(&query_lower) {
let abs = search_start + pos;
let start_col = byte_to_col[abs];
let end_byte = (abs + query_byte_len).min(byte_to_col.len());
let end_col = byte_to_col[end_byte - 1];
for c in start_col..=end_col {
matches.push((abs_row, c));
}
search_start = abs + query_byte_len;
}
}
let match_count = count_match_groups(&matches);
self.search_query = query;
self.search_matches = matches;
if match_count > 0 {
self.search_current = 0;
self.jump_to_current_match(cx);
}
self.status = format!(
"{}: {} ({})",
t!("search"),
self.search_query,
if match_count == 0 {
t!("no_results").to_string()
} else {
format!("{}/{}", self.search_current + 1, match_count)
}
)
.into();
// Keep focus on the search input so the user can continue typing.
self.refocus_search_input(window, cx);
cx.notify();
}
pub(crate) fn search_goto_next(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
pub(crate) fn search_goto_prev(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + match_count - 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
fn jump_to_current_match(&mut self, _cx: &mut Context<Self>) {
// target_grid_line is the grid line index (negative = history).
let Some((target_grid_line, _)) =
find_nth_match_start(&self.search_matches, self.search_current)
else {
return;
};
// Find the tab ID first (immutable borrow), then look up mutably.
let tab_id = self.active_tab.clone().or_else(|| {
self.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string())
});
let tab = if let Some(id) = tab_id.as_deref() {
self.tabs.iter_mut().find(|t| t.id == id)
} else {
self.tabs.first_mut()
};
if let Some(tab) = tab {
let snapshot = tab.render_snapshot();
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
// viewport row = grid_line + display_offset
let vp_row = target_grid_line + display_offset;
let visible = vp_row >= 0 && vp_row < rows;
if !visible {
// Scroll so the target grid line appears near the top.
// display_offset = -grid_line puts it at viewport row 0.
let new_offset = (-target_grid_line).max(0) as usize;
if new_offset > snapshot.display_offset {
tab.scroll_up_by(new_offset - snapshot.display_offset);
} else if new_offset < snapshot.display_offset {
tab.scroll_down_by(snapshot.display_offset - new_offset);
}
}
}
}
/// Build a highlight map for search matches, converting grid line indices
/// to the current viewport coordinates. Only returns highlights for the
/// pane that was actually searched (`search_target_tab`).
pub(crate) fn search_highlight_map(
&self,
tab_id: &str,
match_color: Hsla,
current_color: Hsla,
) -> Option<HashMap<(i32, i32), Hsla>> {
if self.search_matches.is_empty()
|| self.search_query.is_empty()
|| self.search_target_tab.as_deref() != Some(tab_id)
{
return None;
}
// Get current display_offset to convert grid line → viewport row.
let tab = self
.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
let tab = tab.or_else(|| {
let first_id = self
.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string());
first_id
.as_deref()
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
});
let tab = tab.or_else(|| self.tabs.first());
let Some(tab) = tab else {
return None;
};
let snapshot = tab.render_snapshot();
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
let mut map = HashMap::new();
let mut sorted: Vec<(i32, i32)> = self.search_matches.clone();
sorted.sort();
let mut group_idx = 0;
let mut i = 0;
while i < sorted.len() {
let is_current = group_idx == self.search_current;
let color = if is_current {
current_color
} else {
match_color
};
// grid_line → viewport row: vp_row = grid_line + display_offset
let (grid_line, _) = sorted[i];
let vp_row = grid_line + display_offset;
let mut j = i;
if vp_row >= 0 && vp_row < rows {
while j < sorted.len() && sorted[j].0 == grid_line {
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
break;
}
map.insert((vp_row, sorted[j].1), color);
j += 1;
}
} else {
// Outside current viewport — skip.
while j < sorted.len() && sorted[j].0 == grid_line {
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
break;
}
j += 1;
}
}
group_idx += 1;
i = j;
}
Some(map)
}
/// Render the search button (used in the tab bar).
pub(crate) fn render_search_button(
&self,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
// Wrap in a div so .hover() doesn't conflict with Button's internal hover.
div()
.child(
Button::new("search-btn")
.ghost()
.small()
.rounded(px(999.))
.icon(IconName::Search)
.tooltip(t!("search").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_search(window, cx);
})),
)
}
/// Render the expanded search bar overlay (when search is active).
pub(crate) fn render_search_bar(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
let match_count = count_match_groups(&self.search_matches);
let has_query = !self.search_query.is_empty();
let has_matches = match_count > 0;
let current_display = if has_matches {
format!("{}/{}", self.search_current + 1, match_count)
} else if has_query {
"0".to_string()
} else {
String::new()
};
let view = cx.entity();
div()
.absolute()
.top(px(8.))
.right(px(24.))
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.search_bar_bounds = Some(bounds);
});
})
.on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| {
this.refocus_search_input(window, cx);
cx.stop_propagation();
}))
.child(
h_flex()
.gap_1()
.items_center()
.p_1()
.rounded(px(6.))
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.child(
div()
.w(px(200.))
.on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| {
if event.keystroke.key.as_str() == "escape" {
this.close_search(window, cx);
window.prevent_default();
cx.stop_propagation();
}
}))
.child(Input::new(&self.search_input).small()),
)
.when(!current_display.is_empty(), |this| {
this.child(
div()
.text_size(rems(0.75))
.text_color(cx.theme().muted_foreground)
.min_w(px(36.))
.text_center()
.child(current_display),
)
})
.child(
Button::new("search-prev")
.ghost()
.xsmall()
.icon(IconName::ChevronUp)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_prev(cx);
})),
)
.child(
Button::new("search-next")
.ghost()
.xsmall()
.icon(IconName::ChevronDown)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_next(cx);
})),
)
.child(
Button::new("search-close")
.ghost()
.xsmall()
.icon(IconName::Close)
.on_click(cx.listener(|this, _, window, cx| {
this.close_search(window, cx);
})),
),
)
.into_any_element()
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// Count distinct match groups in a sorted list of (row, col) positions.
/// A group is a run of consecutive columns in the same row.
fn count_match_groups(matches: &[(i32, i32)]) -> usize {
if matches.is_empty() {
return 0;
}
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
sorted.sort();
let mut count = 0;
let mut i = 0;
while i < sorted.len() {
count += 1;
let (r, _) = sorted[i];
i += 1;
// Skip consecutive columns in the same row.
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
i += 1;
}
}
count
}
/// Find the (row, col) start of the Nth distinct match group.
fn find_nth_match_start(matches: &[(i32, i32)], n: usize) -> Option<(i32, i32)> {
if matches.is_empty() {
return None;
}
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
sorted.sort();
let mut group_idx = 0;
let mut i = 0;
while i < sorted.len() {
if group_idx == n {
return Some(sorted[i]);
}
group_idx += 1;
let (r, _) = sorted[i];
i += 1;
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
i += 1;
}
}
None
}
+4 -71
View File
@@ -253,81 +253,14 @@ pub(crate) fn open_main_window(cx: &mut App) {
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
if view_clone.read(cx).is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
let handle = window.window_handle();
if !cx.windows().contains(&handle) {
tracing::warn!("[ui] window not found in app during close, skipping save layout state.");
return true;
}
let current_bounds = window.window_bounds();
let bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => b,
gpui::WindowBounds::Maximized(b) => b,
gpui::WindowBounds::Windowed(b) => b,
};
let size = bounds.size;
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
tracing::info!("[ui] main application window closed, saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let saved_bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => {
crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Maximized(b) => {
crate::session::config::SavedWindowBounds::Maximized {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Windowed(b) => {
crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
};
let workspace_sizes: Vec<f32> = workspace_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
let mut body_sizes: Vec<f32> = body_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if view_clone.read(cx).sftp_panel_minimized {
if let Some(prev) = view_clone.read(cx).prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(view_clone.read(cx).sidebar_collapsed);
config.set_sftp_panel_minimized(view_clone.read(cx).sftp_panel_minimized);
let _ = config.save();
} else {
tracing::warn!(
"[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.",
size
);
}
view_clone.read(cx).save_layout_state(window, cx);
true
});
+1
View File
@@ -9,6 +9,7 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
include_str!("../../assets/themes/tokyonight.json"),
include_str!("../../assets/themes/gruvbox.json"),
include_str!("../../assets/themes/solarized.json"),
include_str!("../../assets/themes/phygerr.json"),
];
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
+415 -283
View File
@@ -1,12 +1,13 @@
use gpui::{
Context, ElementId, Focusable as _, FontWeight, Hsla, InteractiveElement as _, IntoElement,
MouseButton, MouseDownEvent, ParentElement as _, PathBuilder, Pixels, Render,
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, hsla,
point, prelude::FluentBuilder as _, px, rems, uniform_list,
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, hsla, point,
prelude::FluentBuilder as _, px, relative, rems, uniform_list,
};
use gpui_component::{
ActiveTheme, Disableable as _, ElementExt, Icon, IconName, InteractiveElementExt as _, Root,
Size, Sizable as _, button::{Button, ButtonVariants as _},
Sizable as _, Size,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
h_flex,
input::Input,
@@ -330,25 +331,35 @@ impl Ashell {
let mut content = h_flex().items_center().gap_2();
if let Some((ref label, ref pct_display, pct)) = dl_summary {
content = content.child(
h_flex().items_center().gap_1()
.child(Icon::new(IconName::ArrowDown)
.with_size(Size::Small)
.text_color(cx.theme().primary))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()))
.child(Progress::new("sftp-status-dl")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone())),
h_flex()
.items_center()
.gap_1()
.child(
Icon::new(IconName::ArrowDown)
.with_size(Size::Small)
.text_color(cx.theme().primary),
)
.child(
div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()),
)
.child(
Progress::new("sftp-status-dl")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)),
)
.child(
div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone()),
),
);
}
if let Some((ref label, ref pct_display, pct)) = ul_summary {
@@ -356,25 +367,35 @@ impl Ashell {
content = content.child(div().w(px(6.)));
}
content = content.child(
h_flex().items_center().gap_1()
.child(Icon::new(IconName::ArrowUp)
.with_size(Size::Small)
.text_color(cx.theme().primary))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()))
.child(Progress::new("sftp-status-ul")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone())),
h_flex()
.items_center()
.gap_1()
.child(
Icon::new(IconName::ArrowUp)
.with_size(Size::Small)
.text_color(cx.theme().primary),
)
.child(
div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()),
)
.child(
Progress::new("sftp-status-ul")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)),
)
.child(
div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone()),
),
);
}
this.child(content)
@@ -716,8 +737,8 @@ impl Ashell {
.scrollbar_show(ScrollbarShow::Always),
),
),
)
);
),
);
outer = outer.child(
h_flex()
.flex_none()
@@ -745,25 +766,35 @@ impl Ashell {
let mut content = h_flex().items_center().gap_2();
if let Some((ref label, ref pct_display, pct)) = dl_summary {
content = content.child(
h_flex().items_center().gap_1()
.child(Icon::new(IconName::ArrowDown)
.with_size(Size::Small)
.text_color(cx.theme().primary))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()))
.child(Progress::new("sftp-status-dl")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone())),
h_flex()
.items_center()
.gap_1()
.child(
Icon::new(IconName::ArrowDown)
.with_size(Size::Small)
.text_color(cx.theme().primary),
)
.child(
div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()),
)
.child(
Progress::new("sftp-status-dl")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)),
)
.child(
div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone()),
),
);
}
if let Some((ref label, ref pct_display, pct)) = ul_summary {
@@ -771,25 +802,35 @@ impl Ashell {
content = content.child(div().w(px(6.)));
}
content = content.child(
h_flex().items_center().gap_1()
.child(Icon::new(IconName::ArrowUp)
.with_size(Size::Small)
.text_color(cx.theme().primary))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()))
.child(Progress::new("sftp-status-ul")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone())),
h_flex()
.items_center()
.gap_1()
.child(
Icon::new(IconName::ArrowUp)
.with_size(Size::Small)
.text_color(cx.theme().primary),
)
.child(
div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()),
)
.child(
Progress::new("sftp-status-ul")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)),
)
.child(
div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone()),
),
);
}
this.child(content)
@@ -801,9 +842,7 @@ impl Ashell {
.on_click(cx.listener(|this, _, window, cx| {
this.show_transfers_dialog(window, cx);
})),
)
.child(
Button::new("sftp-minimize-toggle")
.ghost()
@@ -821,9 +860,7 @@ impl Ashell {
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_sftp_minimized(window, cx);
})),
),
);
outer.into_any_element()
@@ -1798,81 +1835,78 @@ impl Ashell {
.overflow_y_scroll()
.gap_2()
.items_center()
.children(sessions.into_iter().enumerate().map(
|(ix, session)| {
let connect_id = session.id.clone();
let is_active = active_session_id.as_deref()
== Some(session.id.as_str());
let name = session.name.clone();
.children(sessions.into_iter().enumerate().map(|(ix, session)| {
let connect_id = session.id.clone();
let is_active =
active_session_id.as_deref() == Some(session.id.as_str());
let name = session.name.clone();
// Abbreviate: first 1 char for CJK, first 2 chars for Latin
let abbrev = {
let mut chars = name.chars();
if let Some(first) = chars.next() {
if first > '\u{2E7F}' {
// CJK character range — show 1 char
first.to_string()
} else {
// Latin / ASCII — show first 2 chars
let mut s = first.to_string();
if let Some(second) = chars.next() {
s.push(second);
}
s
}
// Abbreviate: first 1 char for CJK, first 2 chars for Latin
let abbrev = {
let mut chars = name.chars();
if let Some(first) = chars.next() {
if first > '\u{2E7F}' {
// CJK character range — show 1 char
first.to_string()
} else {
"?".to_string()
// Latin / ASCII — show first 2 chars
let mut s = first.to_string();
if let Some(second) = chars.next() {
s.push(second);
}
s
}
};
} else {
"?".to_string()
}
};
let edit_id = session.id.clone();
let delete_id = session.id.clone();
div()
.id(("collapsed-saved", ix))
.w(px(36.))
.h(px(36.))
.flex()
.items_center()
.justify_center()
.rounded_md()
.border_1()
.border_color(if is_active {
cx.theme().primary
} else {
cx.theme().border
})
.bg(if is_active {
cx.theme().tab_active
} else {
cx.theme().muted
})
.cursor_pointer()
.hover(|this| this.bg(cx.theme().secondary))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
this.connect_saved_session(
connect_id.clone(),
cx,
)
}),
)
.tooltip({
let tooltip_text = format!("{} {}", name, session.user);
move |window, cx| {
gpui_component::tooltip::Tooltip::new(tooltip_text.clone()).build(window, cx)
}
})
.context_menu({
let view = cx.entity();
move |menu, window, _| {
let edit_value = edit_id.clone();
let clone_value = edit_id.clone();
let delete_value = delete_id.clone();
menu.item(
PopupMenuItem::new(
t!("clone").to_string(),
)
let edit_id = session.id.clone();
let delete_id = session.id.clone();
div()
.id(("collapsed-saved", ix))
.w(px(36.))
.h(px(36.))
.flex()
.items_center()
.justify_center()
.rounded_md()
.border_1()
.border_color(if is_active {
cx.theme().primary
} else {
cx.theme().border
})
.bg(if is_active {
cx.theme().tab_active
} else {
cx.theme().muted
})
.cursor_pointer()
.hover(|this| this.bg(cx.theme().secondary))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
this.connect_saved_session(connect_id.clone(), cx)
}),
)
.tooltip({
let tooltip_text = format!("{} {}", name, session.user);
move |window, cx| {
gpui_component::tooltip::Tooltip::new(
tooltip_text.clone(),
)
.build(window, cx)
}
})
.context_menu({
let view = cx.entity();
move |menu, window, _| {
let edit_value = edit_id.clone();
let clone_value = edit_id.clone();
let delete_value = delete_id.clone();
menu.item(
PopupMenuItem::new(t!("clone").to_string())
.on_click(window.listener_for(
&view,
move |this, _, window, cx| {
@@ -1883,11 +1917,9 @@ impl Ashell {
)
},
)),
)
.item(
PopupMenuItem::new(
t!("edit").to_string(),
)
)
.item(
PopupMenuItem::new(t!("edit").to_string())
.on_click(window.listener_for(
&view,
move |this, _, window, cx| {
@@ -1898,11 +1930,9 @@ impl Ashell {
)
},
)),
)
.item(
PopupMenuItem::new(
t!("delete").to_string(),
)
)
.item(
PopupMenuItem::new(t!("delete").to_string())
.on_click(window.listener_for(
&view,
move |this, _, _, cx| {
@@ -1912,22 +1942,21 @@ impl Ashell {
)
},
)),
)
}
})
.child(
div()
.text_size(rems(0.833))
.font_weight(FontWeight::BOLD)
.text_color(if is_active {
cx.theme().primary
} else {
cx.theme().foreground
})
.child(abbrev),
)
},
)),
)
}
})
.child(
div()
.text_size(rems(0.833))
.font_weight(FontWeight::BOLD)
.text_color(if is_active {
cx.theme().primary
} else {
cx.theme().foreground
})
.child(abbrev),
)
})),
)
.child(
div()
@@ -1942,59 +1971,118 @@ impl Ashell {
)
.id("collapsed-saved-scrollbar")
.axis(gpui_component::scroll::ScrollbarAxis::Vertical)
.scrollbar_show(
gpui_component::scroll::ScrollbarShow::Scrolling,
),
.scrollbar_show(gpui_component::scroll::ScrollbarShow::Scrolling),
),
),
)
}
fn render_window_controls(&self, window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
fn render_window_controls(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let is_macos = cfg!(target_os = "macos");
let is_fullscreen = window.is_fullscreen();
let is_active = cx.active_window() == Some(window.window_handle());
h_flex()
.group("window-controls")
.flex_none()
.items_center()
.px_3()
.gap_2()
.when(!is_macos || is_fullscreen, |this| {
this.child(
div()
h_flex()
.id("window-close")
.size(px(12.))
.rounded_full()
.bg(hsla(0.0, 0.8, 0.4, 1.0)) // Red
.on_click(|_, window, _| window.remove_window())
.hover(|s| s.bg(hsla(0.0, 0.8, 0.5, 1.0)))
.active(|s| s.bg(hsla(0.0, 0.8, 0.3, 1.0))),
.bg(if is_active { hsla(3.0 / 360.0, 1.0, 0.67, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Red or Inactive Grey
.group_hover("window-controls", |s| s.bg(hsla(3.0 / 360.0, 1.0, 0.67, 1.0)))
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Close))
.on_click(cx.listener(|this, _, window, cx| {
this.save_layout_state(window, cx);
window.remove_window();
}))
.hover(|s| s.bg(hsla(3.0 / 360.0, 1.0, 0.55, 1.0)))
.active(|s| s.bg(hsla(3.0 / 360.0, 1.0, 0.45, 1.0)))
.items_center()
.justify_center()
.child(
h_flex()
.items_center()
.justify_center()
.text_size(px(7.))
.font_weight(FontWeight::BOLD)
.line_height(relative(1.0))
.text_color(hsla(3.0 / 360.0, 1.0, 0.15, 0.7))
.opacity(0.0)
.group_hover("window-controls", |s| s.opacity(1.0))
.child("✕")
),
)
.child(
div()
h_flex()
.id("window-minimize")
.size(px(12.))
.rounded_full()
.bg(hsla(0.12, 0.8, 0.4, 1.0)) // Yellow
.bg(if is_active { hsla(39.0 / 360.0, 1.0, 0.59, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Yellow or Inactive Grey
.group_hover("window-controls", |s| s.bg(hsla(39.0 / 360.0, 1.0, 0.59, 1.0)))
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Min))
.on_click(|_, window, _| window.minimize_window())
.hover(|s| s.bg(hsla(0.12, 0.8, 0.5, 1.0)))
.active(|s| s.bg(hsla(0.12, 0.8, 0.3, 1.0))),
.hover(|s| s.bg(hsla(39.0 / 360.0, 1.0, 0.49, 1.0)))
.active(|s| s.bg(hsla(39.0 / 360.0, 1.0, 0.39, 1.0)))
.items_center()
.justify_center()
.child(
h_flex()
.items_center()
.justify_center()
.text_size(px(7.))
.font_weight(FontWeight::BOLD)
.line_height(relative(1.0))
.text_color(hsla(39.0 / 360.0, 1.0, 0.15, 0.8))
.opacity(0.0)
.group_hover("window-controls", |s| s.opacity(1.0))
.child("−")
),
)
.child(
div()
h_flex()
.id("window-maximize")
.size(px(12.))
.rounded_full()
.bg(hsla(0.33, 0.8, 0.4, 1.0)) // Green
.bg(if is_active { hsla(127.0 / 360.0, 0.68, 0.47, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Green or Inactive Grey
.group_hover("window-controls", |s| s.bg(hsla(127.0 / 360.0, 0.68, 0.47, 1.0)))
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Max))
.on_click(|_, window, _| {
if window.is_fullscreen() {
window.toggle_fullscreen();
} else {
#[cfg(target_os = "macos")]
window.titlebar_double_click();
#[cfg(not(target_os = "macos"))]
window.zoom_window();
}
})
.hover(|s| s.bg(hsla(0.33, 0.8, 0.5, 1.0)))
.active(|s| s.bg(hsla(0.33, 0.8, 0.3, 1.0))),
.hover(|s| s.bg(hsla(127.0 / 360.0, 0.68, 0.37, 1.0)))
.active(|s| s.bg(hsla(127.0 / 360.0, 0.68, 0.27, 1.0)))
.items_center()
.justify_center()
.child(
h_flex()
.items_center()
.justify_center()
.text_size(px(7.))
.font_weight(FontWeight::BOLD)
.line_height(relative(1.0))
.text_color(hsla(127.0 / 360.0, 1.0, 0.15, 0.8))
.opacity(0.0)
.group_hover("window-controls", |s| s.opacity(1.0))
.child("+")
),
)
})
.when(is_macos, |this| {
@@ -2095,17 +2183,22 @@ impl Ashell {
.xsmall()
.icon(IconName::Close)
.mr(px(5.))
.on_mouse_down(MouseButton::Left, |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(cx.listener(move |this, _, window, cx| {
window.prevent_default();
cx.stop_propagation();
if !close_id.is_empty() {
this.close_tab(close_id.clone(), cx)
}
})),
.on_mouse_down(
MouseButton::Left,
|_, window, cx| {
window.prevent_default();
cx.stop_propagation();
},
)
.on_click(cx.listener(
move |this, _, window, cx| {
window.prevent_default();
cx.stop_propagation();
if !close_id.is_empty() {
this.close_tab(close_id.clone(), cx)
}
},
)),
)
},
))
@@ -2156,43 +2249,55 @@ impl Ashell {
cx.stop_propagation();
this.split_current_pane("right", cx);
})),
),
)
.child(self.render_search_button(cx)),
)
}
fn render_terminal_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
fn render_terminal_panel(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let has_active = self.active_tab.is_some();
let pane_tree = self.pane_root.clone();
let view = cx.entity();
v_flex().size_full().child(
div()
.size_full()
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.terminal_panel_bounds = Some(bounds);
});
})
.overflow_hidden()
.track_focus(&self.focus_handle)
.key_context(TERMINAL_KEY_CONTEXT)
.on_mouse_down(MouseButton::Left, cx.listener(Self::focus_terminal))
.on_mouse_down(
MouseButton::Right,
cx.listener(Self::on_terminal_right_click),
)
.on_mouse_move(cx.listener(Self::on_terminal_mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_terminal_mouse_up))
.on_key_down(cx.listener(Self::on_terminal_key_down))
.on_action(cx.listener(Self::on_terminal_tab_action))
.on_action(cx.listener(Self::on_terminal_backtab_action))
.on_scroll_wheel(cx.listener(Self::on_terminal_scroll))
.child(if has_active {
Self::render_pane_tree(self, &pane_tree, &[], cx).into_any_element()
} else {
self.render_home_page(cx).into_any_element()
}),
)
div()
.size_full()
.relative()
.child(
div()
.size_full()
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.terminal_panel_bounds = Some(bounds);
});
})
.overflow_hidden()
.track_focus(&self.focus_handle)
.key_context(TERMINAL_KEY_CONTEXT)
.on_mouse_down(MouseButton::Left, cx.listener(Self::focus_terminal))
.on_mouse_down(
MouseButton::Right,
cx.listener(Self::on_terminal_right_click),
)
.on_mouse_move(cx.listener(Self::on_terminal_mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_terminal_mouse_up))
.on_key_down(cx.listener(Self::on_terminal_key_down))
.on_action(cx.listener(Self::on_terminal_tab_action))
.on_action(cx.listener(Self::on_terminal_backtab_action))
.on_scroll_wheel(cx.listener(Self::on_terminal_scroll))
.child(if has_active {
Self::render_pane_tree(self, &pane_tree, &[], cx).into_any_element()
} else {
self.render_home_page(cx).into_any_element()
}),
)
// Search bar overlay — only when search is active.
.when(self.search_active, |el| {
el.child(self.render_search_bar(window, cx))
})
}
fn render_pane_tree(
@@ -2252,6 +2357,11 @@ impl Ashell {
font_size,
line_height,
cell_width,
this.search_highlight_map(
tab_id,
cx.theme().danger.opacity(0.35),
cx.theme().danger.opacity(0.70),
),
));
let scrollbar = this.terminal_scrollbars.entry(tab_id.clone()).or_default();
el = el.vertical_scrollbar(scrollbar);
@@ -2378,14 +2488,14 @@ impl Ashell {
.size_full()
.children(children.iter().enumerate().flat_map(|(i, child)| {
let mut items: Vec<gpui::AnyElement> = Vec::new();
if i > 0 {
let splitter_path = path.to_vec(); // path to the CONTAINER that has the ratio
items.push(
div()
.w(px(4.))
.h_full()
.flex_none()
.cursor_col_resize()
if i > 0 {
let splitter_path = path.to_vec(); // path to the CONTAINER that has the ratio
items.push(
div()
.w(px(4.))
.h_full()
.flex_none()
.cursor_col_resize()
.bg(cx.theme().border)
.hover(|s| s.bg(cx.theme().accent))
.on_mouse_down(
@@ -2489,17 +2599,15 @@ impl Render for Ashell {
let body_panel = v_resizable("ashell-body")
.with_state(&self.body_panels)
.child(resizable_panel().child(self.render_terminal_panel(cx)))
.child(resizable_panel().child(self.render_terminal_panel(window, cx)))
.child(
resizable_panel()
.size(sftp_size)
.size_range(
if self.sftp_panel_minimized {
px(minimized_height)..px(minimized_height)
} else {
px(min_panel_height)..px(1200.)
},
)
.size_range(if self.sftp_panel_minimized {
px(minimized_height)..px(minimized_height)
} else {
px(min_panel_height)..px(1200.)
})
.child(monitoring_contents),
)
.into_any_element();
@@ -2515,32 +2623,29 @@ impl Render for Ashell {
.child(self.render_collapsed_sidebar(cx)),
)
.child(
div()
.flex_1()
.h_full()
.min_w(px(0.))
.child(
v_flex()
.size_full()
.relative()
.overflow_hidden()
.when(
self.active_title_bar_style == crate::session::config::TitleBarStyle::Native,
|this| {
this.child(
div()
.flex_none()
.h(px(32.))
.w_full()
.bg(cx.theme().tab_bar)
.border_b_1()
.border_color(cx.theme().border)
.child(self.render_tab_bar(cx)),
)
},
)
.child(body_panel),
),
div().flex_1().h_full().min_w(px(0.)).child(
v_flex()
.size_full()
.relative()
.overflow_hidden()
.when(
self.active_title_bar_style
== crate::session::config::TitleBarStyle::Native,
|this| {
this.child(
div()
.flex_none()
.h(px(32.))
.w_full()
.bg(cx.theme().tab_bar)
.border_b_1()
.border_color(cx.theme().border)
.child(self.render_tab_bar(cx)),
)
},
)
.child(body_panel),
),
)
.into_any_element()
} else {
@@ -2560,7 +2665,8 @@ impl Render for Ashell {
.relative()
.overflow_hidden()
.when(
self.active_title_bar_style == crate::session::config::TitleBarStyle::Native,
self.active_title_bar_style
== crate::session::config::TitleBarStyle::Native,
|this| {
this.child(
div()
@@ -2594,6 +2700,7 @@ impl Render for Ashell {
.on_action(cx.listener(|this, _: &crate::OpenSession, window, cx| this.show_selector_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::OpenTransfers, window, cx| this.show_transfers_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::NewSsh, window, cx| this.show_ssh_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::OpenSearch, window, cx| this.toggle_search(window, cx)))
.on_action(cx.listener(|this, _: &crate::ToggleSidebar, _, cx| {
this.sidebar_collapsed = !this.sidebar_collapsed;
this.config.set_sidebar_collapsed(this.sidebar_collapsed);
@@ -2836,5 +2943,30 @@ impl Render for Ashell {
),
)
})
.on_prepaint({
let view = cx.entity().clone();
move |_, window, cx| {
view.update(cx, |this, cx| {
let current_win_size = window.viewport_size();
let size_changed = this.last_window_size.map_or(true, |prev| prev != current_win_size);
this.last_window_size = Some(current_win_size);
let current_sizes = this.workspace_panels.read(cx).sizes().clone();
if let Some(current_first_size) = current_sizes.first().copied() {
if size_changed {
if let Some(target_width) = this.last_sidebar_width {
if current_first_size != target_width {
this.workspace_panels.update(cx, |state, cx| {
state.resize_panel(0, target_width, window, cx);
});
}
}
} else {
this.last_sidebar_width = Some(current_first_size);
}
}
});
}
})
}
}
+1
View File
@@ -117,6 +117,7 @@ pub fn spawn_local_terminal(
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
BackendCommand::PromptResponse(_) => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
+167 -3
View File
@@ -16,8 +16,23 @@ use tokio::sync::mpsc;
use crate::{
session::config::{AuthMethod, Session},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx},
terminal::{BackendCommand, BackendEvent, BackendTx, PromptType, PromptInfo},
};
use std::sync::OnceLock;
use std::collections::HashMap;
use tokio::sync::Mutex;
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct CachedCreds {
pub password: Option<String>,
pub passphrase: Option<String>,
pub kb_responses: Option<Vec<String>>,
}
pub static CREDENTIALS_CACHE: OnceLock<std::sync::Mutex<HashMap<String, CachedCreds>>> = OnceLock::new();
pub static PROMPT_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
pub fn spawn_ssh_terminal(
runtime: &tokio::runtime::Handle,
@@ -95,7 +110,7 @@ async fn run_ssh(
});
let handle = Arc::new(tokio::sync::Mutex::new(
connect_and_authenticate(&tab_id, &session, &events).await?,
connect_and_authenticate(&tab_id, &session, &events, &mut commands).await?,
));
let mut channel = handle
@@ -156,6 +171,9 @@ async fn run_ssh(
}
});
}
Some(BackendCommand::PromptResponse(_)) => {
tracing::warn!("[ssh] received unexpected prompt response after authentication");
}
Some(BackendCommand::Close) | None => {
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
let _ = channel.eof().await;
@@ -213,10 +231,27 @@ async fn run_ssh(
Ok(())
}
async fn load_session_private_key_with_cache(session: &Session) -> Result<PrivateKey> {
let cached_passphrase = {
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).and_then(|c| c.passphrase.clone())
};
if let Some(p) = cached_passphrase {
let mut temp_session = session.clone();
temp_session.passphrase = p;
if let Ok(key) = load_session_private_key(&temp_session) {
return Ok(key);
}
}
load_session_private_key(session)
}
async fn connect_and_authenticate(
tab_id: &str,
session: &Session,
events: &std::sync::mpsc::Sender<BackendEvent>,
commands: &mut mpsc::UnboundedReceiver<BackendCommand>,
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
@@ -271,7 +306,55 @@ async fn connect_and_authenticate(
tab_id: tab_id.to_string(),
text: format!("connected to {addr}, loading private key from {source}"),
});
let keypair = load_session_private_key(session)?;
let mut keypair = load_session_private_key_with_cache(session).await;
if let Err(e) = &keypair {
let err_str = e.to_string();
if err_str.contains("encrypted") || err_str.contains("passphrase") || err_str.contains("decrypt") {
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
let _guard = prompt_lock.lock().await;
let _ = events.send(BackendEvent::PromptRequest {
tab_id: tab_id.to_string(),
prompt_type: PromptType::Passphrase,
instruction: format!("Enter passphrase for private key (source: {})", source),
prompts: vec![PromptInfo {
prompt: "Passphrase".to_string(),
echo: false,
}],
});
let mut passphrase_res = None;
while let Some(cmd) = commands.recv().await {
match cmd {
BackendCommand::PromptResponse(responses) => {
if let Some(p) = responses.first().cloned() {
passphrase_res = Some(p);
}
break;
}
BackendCommand::Close => {
return Err(anyhow!("Authentication cancelled by user"));
}
_ => {}
}
}
if let Some(p) = passphrase_res {
{
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut cache = cache_lock.lock().unwrap();
cache.entry(session.id.clone()).or_insert_with(|| CachedCreds {
password: None,
passphrase: None,
kb_responses: None,
}).passphrase = Some(p.clone());
}
let mut temp_session = session.clone();
temp_session.passphrase = p;
keypair = load_session_private_key(&temp_session);
} else {
return Err(anyhow!("Passphrase prompt cancelled"));
}
}
}
let keypair = keypair.context("failed to load private key")?;
let algorithm = format!("{:?}", keypair.algorithm());
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
@@ -307,6 +390,83 @@ async fn connect_and_authenticate(
}
success
}
AuthMethod::KeyboardInteractive => {
let cached = {
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).cloned()
};
let mut response = if cached.as_ref().and_then(|c| c.kb_responses.as_ref()).is_some() {
handle.authenticate_keyboard_interactive_start(&session.user, None).await?
} else {
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
let _guard = prompt_lock.lock().await;
handle.authenticate_keyboard_interactive_start(&session.user, None).await?
};
loop {
match response {
russh::client::KeyboardInteractiveAuthResponse::Success => {
break true;
}
russh::client::KeyboardInteractiveAuthResponse::Failure => {
break false;
}
russh::client::KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => {
let mut responses = Vec::new();
let mut cache_hit = false;
if let Some(c) = cached.as_ref().and_then(|c| c.kb_responses.as_ref()) {
if c.len() == prompts.len() {
responses = c.clone();
cache_hit = true;
}
}
if !cache_hit {
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
let _guard = prompt_lock.lock().await;
let mut prompt_infos = Vec::new();
for p in &prompts {
prompt_infos.push(PromptInfo {
prompt: p.prompt.clone(),
echo: p.echo,
});
}
let _ = events.send(BackendEvent::PromptRequest {
tab_id: tab_id.to_string(),
prompt_type: PromptType::KeyboardInteractive,
instruction: format!("{}\n{}", name, instructions),
prompts: prompt_infos,
});
let mut prompt_res = None;
while let Some(cmd) = commands.recv().await {
match cmd {
BackendCommand::PromptResponse(res) => {
prompt_res = Some(res);
break;
}
BackendCommand::Close => {
return Err(anyhow!("Authentication cancelled"));
}
_ => {}
}
}
if let Some(res) = prompt_res {
responses = res;
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut cache = cache_lock.lock().unwrap();
cache.entry(session.id.clone()).or_insert_with(|| CachedCreds {
password: None,
passphrase: None,
kb_responses: None,
}).kb_responses = Some(responses.clone());
} else {
return Err(anyhow!("Keyboard-interactive cancelled"));
}
}
response = handle.authenticate_keyboard_interactive_respond(responses).await?;
}
}
}
}
};
if !authed {
@@ -328,6 +488,10 @@ async fn connect_and_authenticate(
session.port,
key_source_label(session)
),
AuthMethod::KeyboardInteractive => format!(
"authentication failed: server rejected keyboard-interactive authentication for {}@{}:{}",
session.user, session.host, session.port
),
}
));
}
+3 -3
View File
@@ -16,9 +16,9 @@ rust_i18n::i18n!("locales", fallback = "en");
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSession,
OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp,
ToggleSftpZoom, ToggleSidebar,
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
};
pub(crate) use app::{
+73 -2
View File
@@ -6,10 +6,11 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[serde(rename_all = "kebab-case")]
pub enum AuthMethod {
Password,
Key,
KeyboardInteractive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -73,6 +74,27 @@ impl Session {
last_used: None,
}
}
pub fn keyboard_interactive(
host: String,
port: u16,
user: String,
) -> Self {
let name = format!("{user}@{host}");
Self {
id: Uuid::new_v4().to_string(),
name,
host,
port,
user,
auth: AuthMethod::KeyboardInteractive,
password: String::new(),
private_key_path: String::new(),
private_key_inline: String::new(),
passphrase: String::new(),
last_used: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -106,7 +128,7 @@ pub enum TitleBarStyle {
Integrated,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default = "default_follow_system_theme")]
pub follow_system_theme: bool,
@@ -124,6 +146,8 @@ pub struct ConfigFile {
pub ui_font_size: f32,
#[serde(default)]
pub right_click_copy_paste: bool,
#[serde(default)]
pub keyword_highlight: bool,
#[serde(default = "default_ui_font_family")]
pub ui_font_family: String,
#[serde(default = "default_terminal_font_family")]
@@ -210,6 +234,45 @@ fn default_terminal_font_family() -> String {
"Maple Mono NF CN".to_string()
}
impl Default for ConfigFile {
fn default() -> Self {
Self {
follow_system_theme: default_follow_system_theme(),
theme_mode: String::new(),
light_theme_name: String::new(),
dark_theme_name: String::new(),
locale: default_locale(),
terminal_font_size: default_terminal_font_size(),
ui_font_size: default_ui_font_size(),
right_click_copy_paste: false,
keyword_highlight: false,
ui_font_family: default_ui_font_family(),
terminal_font_family: default_terminal_font_family(),
title_bar_style: TitleBarStyle::default(),
sessions: Vec::new(),
window_bounds: None,
workspace_panels: None,
body_panels: None,
transfers: Vec::new(),
show_hidden_files: false,
monitoring_position: default_monitoring_position(),
sidebar_collapsed: false,
sftp_panel_minimized: false,
key_bindings: std::collections::HashMap::new(),
sync_endpoint: String::new(),
sync_username: String::new(),
sync_etag: None,
sync_device_id: String::new(),
sync_backend: String::new(),
sync_etag_backend: String::new(),
sync_s3_endpoint: String::new(),
sync_s3_region: default_s3_region(),
sync_s3_bucket: String::new(),
sync_s3_object_key: default_s3_object_key(),
}
}
}
pub struct ConfigStore {
path: PathBuf,
cache: ConfigFile,
@@ -512,6 +575,14 @@ impl ConfigStore {
self.cache.right_click_copy_paste = val;
}
pub fn keyword_highlight(&self) -> bool {
self.cache.keyword_highlight
}
pub fn set_keyword_highlight(&mut self, val: bool) {
self.cache.keyword_highlight = val;
}
pub fn terminal_font_family(&self) -> &str {
if self.cache.terminal_font_family.is_empty() {
"Maple Mono NF CN"
+29 -1
View File
@@ -100,6 +100,7 @@ impl Ashell {
}
Session::key(host, port, user, key_path, key_inline, passphrase)
}
AuthMethod::KeyboardInteractive => Session::keyboard_interactive(host, port, user),
};
session.name = name;
if let Some(id) = existing_id {
@@ -804,6 +805,15 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the search bar is visible and the click is inside it, let the
// search bar handle the event instead of switching pane focus.
if self.search_active {
if let Some(bounds) = self.search_bar_bounds {
if bounds.contains(&event.position) {
return;
}
}
}
self.focus_handle.focus(window, cx);
// Check if click is in a different pane and focus it
let click_pos = event.position;
@@ -1051,7 +1061,16 @@ impl Ashell {
if let Some(new_path) = Self::find_adjacent_pane(&self.pane_root, &path, direction) {
self.focused_pane_path = new_path;
if let Some(id) = self.pane_root.focused_tab_id(&self.focused_pane_path) {
self.active_tab = Some(id.to_string());
let id_owned = id.to_string();
let changed = self.active_tab.as_deref() != Some(id_owned.as_str());
self.active_tab = Some(id_owned);
// Clear stale search state when switching to a different pane.
if changed && self.search_active {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
}
}
}
}
@@ -1334,8 +1353,17 @@ impl Ashell {
}
let mut path = Vec::new();
if find_path(&self.pane_root, &tab_id, &mut path) {
let changed = self.active_tab.as_deref() != Some(tab_id.as_str());
self.focused_pane_path = path;
self.active_tab = Some(tab_id);
// Clear stale search state when switching to a different pane.
// The user can press Enter to re-search in the new pane.
if changed && self.search_active {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
}
}
}
}
+40 -2
View File
@@ -868,6 +868,34 @@ async fn connect_and_authenticate(
}
success
}
AuthMethod::KeyboardInteractive => {
let cached_kb_responses = {
let cache_lock = crate::backend::ssh::CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).and_then(|c| c.kb_responses.clone())
};
let responses = cached_kb_responses.unwrap_or_else(|| vec![session.password.clone()]);
let mut response = handle.authenticate_keyboard_interactive_start(&session.user, None).await?;
loop {
match response {
russh::client::KeyboardInteractiveAuthResponse::Success => {
break true;
}
russh::client::KeyboardInteractiveAuthResponse::Failure => {
break false;
}
russh::client::KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => {
let mut resp = responses.clone();
if resp.len() < prompts.len() {
resp.resize(prompts.len(), String::new());
} else if resp.len() > prompts.len() {
resp.truncate(prompts.len());
}
response = handle.authenticate_keyboard_interactive_respond(resp).await?;
}
}
}
}
};
if !authed {
@@ -879,6 +907,7 @@ async fn connect_and_authenticate(
match session.auth {
AuthMethod::Password => "password",
AuthMethod::Key => "public key",
AuthMethod::KeyboardInteractive => "keyboard interactive",
},
session.user,
session.host,
@@ -892,8 +921,17 @@ async fn connect_and_authenticate(
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
let cached_passphrase = {
let cache_lock = crate::backend::ssh::CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).and_then(|c| c.passphrase.clone())
};
let passphrase = if let Some(ref p) = cached_passphrase {
Some(p.as_str())
} else {
let p = session.passphrase.trim();
(!p.is_empty()).then_some(p)
};
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
+2
View File
@@ -335,3 +335,5 @@ pub fn paint_custom_block(
painted
}
+15 -1
View File
@@ -132,6 +132,7 @@ pub struct TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub struct PrepaintState {
@@ -270,6 +271,7 @@ impl TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
) -> Self {
Self {
view,
@@ -280,6 +282,7 @@ impl TerminalElement {
font_size,
line_height,
cell_width,
search_highlights,
}
}
@@ -354,6 +357,12 @@ impl TerminalElement {
let mut custom_blocks = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
// Retrieve cached keyword highlights and merge with search highlights
let mut highlights = self.snapshot.highlights.clone();
if let Some(sm) = self.search_highlights.as_ref() {
highlights.extend(sm.iter().map(|(k, v)| (*k, *v)));
}
for render_cell in &self.snapshot.cells {
let cell = &render_cell.cell;
if cell.flags.intersects(
@@ -392,7 +401,12 @@ impl TerminalElement {
continue;
}
let style = self.cell_run_style(cell, cx);
let mut style = self.cell_run_style(cell, cx);
// Apply keyword highlight color if this cell was matched.
if let Some(&hl_color) = highlights.get(&(render_cell.row, render_cell.col)) {
style.color = hl_color;
}
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
+358
View File
@@ -0,0 +1,358 @@
use std::collections::HashMap;
use gpui::Hsla;
use crate::terminal::RenderCell;
trait HslaExt {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self;
}
impl HslaExt for Hsla {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let l = (max + min) / 2.0;
if max == min {
return Hsla { h: 0.0, s: 0.0, l, a: 1.0 };
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == rf {
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) / 6.0
} else if max == gf {
((bf - rf) / d + 2.0) / 6.0
} else {
((rf - gf) / d + 4.0) / 6.0
};
Hsla { h, s, l, a: 1.0 }
}
}
#[derive(Debug, Clone)]
struct HighlightColors {
error: Hsla,
success: Hsla,
warning: Hsla,
info: Hsla,
failure: Hsla,
network: Hsla,
url: Hsla,
port: Hsla,
debug: Hsla,
}
fn hsla(r: u8, g: u8, b: u8) -> Hsla {
Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
a: 1.0,
}
.into_rgba_like(r, g, b)
}
fn highlight_colors() -> HighlightColors {
HighlightColors {
error: hsla(224, 96, 96), // #E06060 red
success: hsla(126, 198, 153), // #7EC699 green
warning: hsla(232, 201, 122), // #E8C97A yellow
info: hsla(108, 180, 238), // #6CB4EE blue
failure: hsla(232, 168, 124), // #E8A87C orange
network: hsla(199, 146, 234), // #C792EA purple
url: hsla( 86, 212, 199), // #56D4C7 teal
port: hsla(130, 170, 200), // #82AAC8 muted teal
debug: hsla(130, 140, 155), // #828C9B gray
}
}
fn is_boundary(c: char) -> bool {
!c.is_ascii_alphanumeric() && c != '_'
}
pub fn highlight_cells(
cells: &[RenderCell],
rows: usize,
) -> HashMap<(i32, i32), Hsla> {
let colors = highlight_colors();
// Pre-allocate the outer vector to the size of rows.
let mut row_chars: Vec<Vec<(i32, char)>> = vec![Vec::with_capacity(128); rows];
for rc in cells {
if rc.row < 0 || (rc.row as usize) >= rows {
continue;
}
row_chars[rc.row as usize].push((rc.col, rc.cell.c));
}
for row in row_chars.iter_mut() {
row.sort_by_key(|&(col, _)| col);
}
let mut map = HashMap::new();
// Reusable buffers to avoid allocation inside the loop
let mut chars_buf = String::with_capacity(128);
let mut byte_to_col: Vec<i32> = Vec::with_capacity(128);
for (row_idx, row) in row_chars.iter().enumerate() {
if row.is_empty() {
continue;
}
let row_i32 = row_idx as i32;
chars_buf.clear();
byte_to_col.clear();
for &(col, c) in row {
chars_buf.push(c);
while byte_to_col.len() < chars_buf.len() {
byte_to_col.push(col);
}
}
let text = chars_buf.as_str();
// ── 1. Error keywords ──────────────────────────
for kw in &["EMERGENCY", "CRITICAL", "FATAL", "PANIC", "ERROR", "ERR"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.error);
}
}
}
// ── 2. Success keywords ───────────────────────────
for kw in &["SUCCESS", "SUCCEEDED", "PASSED", "PASS", "OK"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.success);
}
}
}
// ── 3. Failure keywords ───────────────────────────
for kw in &["FAILED", "FAILURE", "DENIED", "REJECTED", "TIMEOUT", "FAIL"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.failure);
}
}
}
// ── 4. Warning keywords ───────────────────────────
for kw in &["WARNING", "WARN"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.warning);
}
}
}
// ── 5. Info keywords ──────────────────────────────
for kw in &["NOTICE", "INFO"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.info);
}
}
}
// ── 6. Debug keywords ─────────────────────────────
for kw in &["DEBUG", "DBG", "TRACE"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.debug);
}
}
}
// ── 7. IP addresses ───────────────────────────────
for m in find_ip_addresses(text) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + find_ip_len(&text[m..])).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.network);
}
}
// ── 8. URLs ───────────────────────────────────────
for m in find_urls(text) {
let url_len = find_url_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + url_len).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.url);
}
}
// ── 9. Port numbers ─────────────────────────────────────
for m in find_ports(text) {
let port_len = find_port_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + port_len).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.port);
}
}
}
map
}
fn find_keyword(text: &str, keyword: &str) -> Vec<usize> {
let mut positions = Vec::new();
let mut start = 0;
while let Some(pos) = text[start..].find(keyword) {
let abs = start + pos;
let before_ok = abs == 0
|| text.as_bytes()[abs - 1] == b' '
|| is_boundary(text.as_bytes()[abs - 1] as char);
let after_pos = abs + keyword.len();
let after_ok = after_pos >= text.len()
|| text.as_bytes()[after_pos] == b' '
|| is_boundary(text.as_bytes()[after_pos] as char);
if before_ok && after_ok {
positions.push(abs);
}
start = abs + keyword.len();
}
positions
}
fn find_ip_len(text: &str) -> usize {
let bytes = text.as_bytes();
let mut dots = 0u8;
let mut digits = 0u8;
let mut len = 0usize;
for &b in bytes {
match b {
b'0'..=b'9' => {
digits += 1;
if digits > 3 {
return 0;
}
}
b'.' => {
if digits == 0 {
return 0;
}
dots += 1;
if dots > 3 {
return 0;
}
digits = 0;
}
_ => break,
}
len += 1;
}
if dots == 3 && digits > 0 {
len
} else {
0
}
}
fn find_ip_addresses(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let bytes = text.as_bytes();
let len = bytes.len();
for i in 0..len {
if bytes[i].is_ascii_digit()
&& (i == 0 || is_boundary(bytes[i - 1] as char))
{
let remaining = &text[i..];
let ip_len = find_ip_len(remaining);
if ip_len > 0 {
let ip_str = &remaining[..ip_len];
let valid = ip_str
.split('.')
.all(|octet| octet.parse::<u8>().is_ok());
if valid {
positions.push(i);
}
}
}
}
positions
}
fn find_urls(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let mut start = 0;
while let Some(pos) = text[start..].find("http") {
let abs = start + pos;
let remaining = &text[abs..];
if remaining.starts_with("https://") || remaining.starts_with("http://") {
if abs == 0 || is_boundary(text.as_bytes()[abs - 1] as char) {
positions.push(abs);
}
}
start = abs + 4;
}
positions
}
fn find_url_len(text: &str) -> usize {
text.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(text.len())
}
fn find_ports(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let bytes = text.as_bytes();
let len = bytes.len();
for i in 0..len {
if bytes[i] == b':'
&& i + 1 < len
&& bytes[i + 1].is_ascii_digit()
&& (i == 0 || is_boundary(bytes[i - 1] as char) || bytes[i - 1] == b' ')
{
let mut j = i + 1;
while j < len && bytes[j].is_ascii_digit() {
j += 1;
}
let port_str = &text[i + 1..j];
if let Ok(port) = port_str.parse::<u16>() {
if port > 0 {
let after_ok = j >= len || is_boundary(bytes[j] as char);
if after_ok {
positions.push(i);
}
}
}
}
}
positions
}
fn find_port_len(text: &str) -> usize {
if !text.starts_with(':') {
return 0;
}
let mut len = 1;
for b in text.as_bytes()[1..].iter() {
if b.is_ascii_digit() {
len += 1;
} else {
break;
}
}
len
}
+64 -2
View File
@@ -3,8 +3,8 @@ use std::ops::Range;
use alacritty_terminal::index::Side;
use alacritty_terminal::selection::SelectionType;
use gpui::{
ClipboardItem, Context, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
ClipboardItem, Context, Focusable as _, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
};
use crate::{
@@ -12,6 +12,10 @@ use crate::{
terminal::{BackendCommand, encode_key},
};
thread_local! {
static LAST_DRAG_SCROLL: std::cell::Cell<Option<std::time::Instant>> = std::cell::Cell::new(None);
}
impl Ashell {
pub(crate) fn on_terminal_key_down(
&mut self,
@@ -19,6 +23,17 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the search input is focused, skip terminal key processing
// so the input can handle text entry, paste, etc. normally.
if self
.search_input
.read(cx)
.focus_handle(cx)
.is_focused(window)
{
return;
}
// Pane navigation: Alt + h/j/k/l
if event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.shift
@@ -337,7 +352,54 @@ impl Ashell {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let snapshot = match self.active_snapshot() {
Some(s) => s,
None => return,
};
let max_row = snapshot.rows.saturating_sub(1);
let mut scroll_delta = 0i32;
if max_row >= 6 {
if row <= 2 || row >= max_row.saturating_sub(2) {
let now = std::time::Instant::now();
let should_scroll = LAST_DRAG_SCROLL.with(|last| {
if let Some(last_time) = last.get() {
if now.duration_since(last_time) >= std::time::Duration::from_millis(80) {
last.set(Some(now));
true
} else {
false
}
} else {
last.set(Some(now));
true
}
});
if should_scroll {
if row == 0 {
scroll_delta = 2;
} else if row == 1 {
scroll_delta = 1;
} else if row == 2 {
scroll_delta = 1;
} else if row == max_row {
scroll_delta = -2;
} else if row == max_row.saturating_sub(1) {
scroll_delta = -1;
} else if row == max_row.saturating_sub(2) {
scroll_delta = -1;
}
}
} else {
LAST_DRAG_SCROLL.with(|last| last.set(None));
}
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
if scroll_delta != 0 {
tab.scroll_history(scroll_delta);
}
tab.update_selection(row, col, side);
cx.notify();
}
+109 -9
View File
@@ -1,13 +1,14 @@
pub mod custom_blocks;
pub mod element;
pub mod input;
pub mod highlight;
use std::sync::mpsc::Sender;
use alacritty_terminal::{
event::{Event, EventListener},
grid::{Dimensions, Scroll},
index::{Column, Point, Side},
index::{Column, Line, Point, Side},
selection::{Selection, SelectionRange, SelectionType},
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
vte::ansi::{CursorShape, Processor},
@@ -24,16 +25,35 @@ pub enum TabKind {
Ssh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptType {
KeyboardInteractive,
Passphrase,
}
#[derive(Debug, Clone)]
pub struct PromptInfo {
pub prompt: String,
pub echo: bool,
}
#[derive(Debug)]
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
PromptResponse(Vec<String>),
}
#[derive(Debug, Clone)]
pub enum BackendEvent {
PromptRequest {
tab_id: String,
prompt_type: PromptType,
instruction: String,
prompts: Vec<PromptInfo>,
},
Output {
tab_id: String,
bytes: Vec<u8>,
@@ -125,6 +145,7 @@ pub struct TerminalTab {
rows: u16,
pub backend: BackendTx,
pub scroll_pixel_y: f32,
pub(crate) highlight_cache: std::cell::RefCell<Option<(Vec<RenderCell>, std::collections::HashMap<(i32, i32), gpui::Hsla>)>>,
}
#[derive(Clone, Copy)]
@@ -134,7 +155,7 @@ pub struct CursorState {
pub shape: CursorShape,
}
#[derive(Clone)]
#[derive(Clone, PartialEq)]
pub struct RenderCell {
pub row: i32,
pub col: i32,
@@ -150,6 +171,7 @@ pub struct RenderSnapshot {
pub history_size: usize,
pub rows: usize,
pub cols: usize,
pub highlights: std::collections::HashMap<(i32, i32), gpui::Hsla>,
}
#[derive(Clone, Copy)]
@@ -232,6 +254,7 @@ impl TerminalTab {
rows: 30,
backend,
scroll_pixel_y: 0.0,
highlight_cache: std::cell::RefCell::new(None),
}
}
@@ -309,17 +332,72 @@ impl TerminalTab {
});
}
// Get highlights from cache or recompute, only if keyword_highlight is enabled.
let is_enabled = crate::session::config::ConfigStore::load()
.map(|c| c.keyword_highlight())
.unwrap_or(false);
let highlights = if is_enabled {
let mut cache = self.highlight_cache.borrow_mut();
let cache_valid = cache.as_ref().map_or(false, |(cached_cells, _)| {
cached_cells == &cells
});
if cache_valid {
cache.as_ref().unwrap().1.clone()
} else {
let computed = self::highlight::highlight_cells(&cells, rows as usize);
*cache = Some((cells.clone(), computed.clone()));
computed
}
} else {
std::collections::HashMap::new()
};
RenderSnapshot {
cells,
cursor: self.cursor_state(),
selection: viewport_selection_from_range(content.display_offset, &content.selection),
selection: viewport_selection_from_range(
content.display_offset,
self.rows as usize,
self.cols as usize,
&content.selection,
),
display_offset: content.display_offset,
history_size: self.term.grid().history_size(),
rows: self.rows as usize,
cols: self.cols as usize,
highlights,
}
}
/// Return `(grid_line_base, rows_data)` for the **entire** terminal buffer
/// including scrollback history. `grid_line_base` is the grid line index of
/// the first row (typically `-history_size`). Each entry in `rows_data` is
/// a sorted `Vec<(col, char)>` for that row.
pub fn full_grid_rows(&self) -> (i32, Vec<Vec<(i32, char)>>) {
let grid = self.term.grid();
let history = grid.history_size() as i32;
let screen = grid.screen_lines() as i32;
let total = history + screen;
let cols = self.cols as i32;
let start_line = -history;
let mut rows_data: Vec<Vec<(i32, char)>> = Vec::with_capacity(total as usize);
for line_idx in start_line..(start_line + total) {
let line = Line(line_idx);
let mut cells: Vec<(i32, char)> = Vec::new();
for col_idx in 0..cols {
let point = Point::new(line, Column(col_idx as usize));
let c = grid[point].c;
if c != ' ' && c != '\0' {
cells.push((col_idx, c));
}
}
rows_data.push(cells);
}
(start_line, rows_data)
}
pub fn scroll_history(&mut self, delta: i32) {
if delta != 0 {
self.term.scroll_display(Scroll::Delta(delta));
@@ -396,6 +474,8 @@ impl TerminalTab {
fn viewport_selection_from_range(
display_offset: usize,
rows: usize,
cols: usize,
selection: &Option<SelectionRange>,
) -> Option<ViewportSelection> {
let SelectionRange {
@@ -403,14 +483,34 @@ fn viewport_selection_from_range(
end,
is_block,
} = selection.as_ref().copied()?;
let start = point_to_viewport(display_offset, start)?;
let end = point_to_viewport(display_offset, end)?;
let top_point = viewport_to_point(display_offset, Point::new(0, Column(0)));
let bottom_point = viewport_to_point(display_offset, Point::new(rows.saturating_sub(1), Column(0)));
let top_line = top_point.line;
let bottom_line = bottom_point.line;
let start_vp = if start.line < top_line {
Point::new(0, Column(0))
} else if start.line > bottom_line {
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
} else {
point_to_viewport(display_offset, start).unwrap_or(Point::new(0, Column(0)))
};
let end_vp = if end.line < top_line {
Point::new(0, Column(0))
} else if end.line > bottom_line {
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
} else {
point_to_viewport(display_offset, end).unwrap_or(Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1))))
};
Some(ViewportSelection {
start_row: start.line,
start_col: start.column.0,
end_row: end.line,
end_col: end.column.0,
start_row: start_vp.line,
start_col: start_vp.column.0,
end_row: end_vp.line,
end_col: end_vp.column.0,
is_block,
})
}