diff --git a/locales/en.yml b/locales/en.yml index 3e11d8e..2eaf848 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -2,6 +2,21 @@ remote_files: "REMOTE FILES" open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files." sftp_connecting: "sftp connecting..." refresh: "Refresh" +sftp_more_actions: "More remote file actions" +sftp_more_actions_short: "More" +reveal_current_directory: "Reveal current directory" +collapse_all_directories: "Collapse all directories" +parent_directory: "Parent directory" +create_or_upload: "Create or upload" +add: "Add" +directories: "DIRECTORIES" +expand_directory: "Expand directory" +collapse_directory: "Collapse directory" +retry_directory: "Retry loading directory" +loading_directory: "Loading directory..." +directory_load_failed: "Failed to load directory: %{reason}" +empty_directory: "This directory is empty" +selected_items: "%{count} selected" upload_file: "Upload File" upload_folder: "Upload Folder" hidden: "Hidden" @@ -11,14 +26,22 @@ download_count: "Download (%{count})" name: "NAME" size: "SIZE" modified: "MODIFIED" -new_ssh_connection: "New SSH Connection" +new_connection: "New Connection" +edit_connection: "Edit Connection" create_or_edit_ssh_session: "Create or edit an SSH session" new_serial_connection: "New Serial Connection" -create_or_edit_serial_session: "Create or edit a Serial session" +edit_serial_connection: "Edit Serial Connection" serial_port: "Serial Port Path" baud_rate: "Baud Rate" session_name: "Session Name" new_folder: "New Folder" +rename: "Rename" +new_name: "New name" +name_required: "Name is required." +invalid_remote_name: "Name cannot be '.', '..', or contain '/'." +rename_success: "Renamed to %{name}" +rename_failed: "Rename failed: %{err}" +sftp_connection_unavailable: "The SFTP connection is unavailable." create_folder_success: "Created folder: %{name}" create_folder_failed: "Failed to create folder: %{err}" delete_selected: "Delete" @@ -28,6 +51,7 @@ system_path_warning: "WARNING: You are attempting to delete critical system path delete_success: "Successfully deleted %{count} items" delete_failed: "Failed to delete: %{err}" deleting_paths: "Deleting %{count} items..." +confirm: "Confirm" local_terminal: "Local Terminal" open_local_shell_tab: "Open a new local shell tab" connect: "Connect" @@ -40,9 +64,6 @@ password: "Password" key: "Key" ssh_config: "Config(~/.ssh/config)" ssh_config_empty: "No entries found in ~/.ssh/config" -private_key_path: "Private Key Path" -private_key_data: "Private Key Data" -save_and_connect: "Save & Connect" save: "Save" cancel: "Cancel" host_and_user_required: "Host and user are required" @@ -55,17 +76,69 @@ use_dark_mode: "Use Dark Mode" english: "English" chinese: "简体中文" idle_no_session: "idle / No session" -system: "SYSTEM" cpu: "CPU" mem: "MEM" swap: "SWAP" net: "NET" disk: "DISK" -global_load: "global load" live: "Live" -idle: "idle" -saved: "SAVED" -add_ssh: "+ ssh" +memory: "Memory" +cpu_percent: "CPU" +memory_usage_short: "Memory" +process: "Process" +system_processes: "System Processes" +filter_processes: "Filter processes" +refresh_processes: "Refresh process list" +no_processes: "No process data" +no_matching_processes: "No matching processes" +process_details: "Process details" +copy_process: "Copy process information" +process_pid: "PID" +process_command: "Command" +process_summary: "%{name} · PID %{pid}" +network_ports: "Network Ports" +refresh_ports: "Refresh port list" +no_ports: "No network ports" +filter_ports: "Filter ports" +no_matching_ports: "No matching ports" +loading_ports: "Loading port list..." +protocol: "Protocol" +address: "Address" +state: "State" +loading_processes: "Loading process list..." +terminate_process: "Terminate Process" +confirm_terminate_process: "Terminate Process?" +confirm_terminate_process_desc: "Send a termination signal to PID %{pid}? Unsaved data may be lost." +terminating_process: "Sending termination signal to process %{pid}..." +process_terminated: "Termination signal sent to process %{pid}" +process_terminate_failed: "Failed to terminate process %{pid}: %{reason}" +connection_management: "CONNECTIONS" +import_connections: "Import" +export_connections: "Export" +new_connection_short: "New" +filter_connections: "Filter connections" +no_connections: "No connections" +no_matching_connections: "No matching connections" +connections_exported: "Exported %{count} connections" +connections_import_summary: "Import complete: %{added} added, %{updated} updated, %{skipped} skipped" +connections_export_failed: "Export failed: %{reason}" +connections_import_failed: "Import failed: %{reason}" +connections_export_no_ssh: "No SSH connections are available to export" +connections_export_plaintext_title: "Export plaintext passwords?" +connections_export_plaintext_warning: "The CSV will contain passwords for %{count} SSH connections in plaintext. Private keys and passphrases are not included. Store it securely and remove it after migration." +connections_export_plaintext_warning_with_skipped: "The CSV will contain passwords for %{count} SSH connections in plaintext. Private keys and passphrases are not included, and %{skipped} non-SSH connections will not be exported. Store it securely and remove it after migration." +delete_selected_connections: "Delete" +connections_deleted: "Deleted %{count} connections" +delete_selected_commands: "Delete selected commands" +commands_deleted: "Deleted %{count} commands" +command_history: "Command History" +command_history_short: "History" +command_history_empty: "No command history" +filter_command_history: "Filter command history" +no_matching_commands: "No matching commands" +copy_command: "Copy command" +execute_command: "Execute command" +close_command_history: "Close command history" open_local_or_ssh: "Open a local terminal or pick an SSH session." open_session: "Open Session" settings: "Settings" @@ -85,17 +158,19 @@ cursor_style_default: "Default" cursor_style_blink: "Blink" cursor_style_beam: "Line" cursor_style_beam_blink: "Line Blink" -monitoring_position: "Monitoring Position" +monitoring_position: "Local Monitoring Position" position_bottom: "Bottom" position_sidebar: "Sidebar" position_hidden: "Hidden" -local_host: "local host" -local_terminal_opened: "local terminal opened" connecting: "Connecting" starting_connection: "starting connection..." connection_failed: "Connection Failed" session_disconnected: "Session disconnected: %{reason}" press_enter_to_reconnect: "Press Enter to reconnect" +ssh_reconnect_pending: "Waiting for reconnect confirmation" +reconnect_ssh_title: "Reconnect" +reconnect_ssh_desc: "%{name} is not connected. Reconnect now?" +reconnect_ssh: "Reconnect" preview_failed: "Preview failed: %{err}" downloading_file: "Downloading %{base}..." @@ -122,7 +197,6 @@ downloading: "Downloading" paused: "Paused" completed: "Completed" failed: "Failed" -cancelled: "Cancelled" interrupted: "Interrupted" session: "Session" zombie: "Zombie" @@ -132,18 +206,33 @@ software_builtin: "Built-in" retry: "Retry" right_click_copy_paste: "Right-click Copy/Paste" keyword_highlight: "Terminal Keyword Highlighting" +remember_tabs: "Remember Tabs" lock_layout: "Lock Window Layout" lock_layout_hint: "Lock the position of the sidebar and the SFTP panel." copy_paste_hint: "Selected text can also be copied by left-clicking." settings_copy: "Copy Selection" settings_paste: "Paste Clipboard" edit_file: "Edit File" -edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save." +edit_file_tooltip: "Edit directly in ashell and save changes to the remote server." +terminal_encoding: "Terminal Encoding" +file_encoding: "File Encoding" +terminal_encoding_changed: "Terminal encoding changed to %{encoding}; new input and output will use it." +file_encoding_changed: "File encoding changed to %{encoding}." +encoding_decode_warning: "Some bytes are invalid in %{encoding}. Try another encoding." +encoding_encode_failed: "Some characters cannot be represented in %{encoding}. Choose another encoding before saving." +loading_file_content: "Loading file content..." +saving_file_content: "Saving file..." +editor_content_too_large: "Content exceeds the %{max} in-app editing limit." +unsaved_changes: "Unsaved changes" +file_content_loaded: "File loaded" +open_remote_file_failed: "Failed to open remote file: %{err}" +remote_file_saved: "Remote file saved" +save_remote_file_failed: "Failed to save remote file: %{err}" sync_cwd_tooltip: "Sync path from active terminal" sync_cwd: "Sync Path" -auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}" scroll: "Scroll ↓" clone: "Clone" +about_feedback_hint: "If you encounter a problem or have a suggestion, please report it on our GitHub project." settings_general: "General" settings_key_bindings: "Key Bindings" settings_help: "Help" @@ -155,25 +244,26 @@ settings_group_keybind_general: "General" settings_group_keybind_zoom: "Zoom" settings_group_keybind_focus: "Focus" settings_group_keybind_panel: "Panel" -theme: "Theme" theme_mode: "Theme Mode" clear_transfers: "Clear History" transfers_limit: " (Max 100 records)" press_new_key: "Press new key..." +none: "None" settings_open_settings: "Open Settings" settings_open_session: "Open Session" settings_open_transfers: "Open Transfer History" -files_downloading: "%{count} files downloading" -files_uploading: "%{count} files uploading" panel_minimize: "Minimize Panel" panel_expand: "Expand Panel" -settings_new_ssh: "New SSH" +panel_minimize_short: "Minimize" +panel_expand_short: "Expand" +settings_new_connection: "New Connection" settings_open_search: "Search" +settings_quit_application: "Quit Application" search: "Search" no_results: "No results" settings_toggle_sidebar: "Toggle Sidebar" -settings_toggle_sftp_zoom: "Toggle SFTP Zoom" +settings_toggle_sftp_zoom: "Toggle SFTP Panel Size" settings_focus_pane_left: "Focus Pane Left" settings_focus_pane_right: "Focus Pane Right" settings_focus_pane_up: "Focus Pane Up" @@ -183,7 +273,7 @@ settings_split_pane_right: "Split Pane Right" settings_split_pane_up: "Split Pane Up" settings_split_pane_down: "Split Pane Down" settings_close_pane: "Close Pane" -keybind_conflict: '"%{key}" is already used by "%{action}", please choose another key' +keybind_conflict: "\"%{key}\" is already used by \"%{action}\", please choose another key" title_bar_style: "Title Bar Style" title_bar_native: "Native" title_bar_integrated: "Integrated" @@ -214,7 +304,6 @@ enable_proxy: "Enable Proxy" read_env_proxy: "Read environment variables on startup" read_env_proxy_desc: "If enabled, the global proxy settings below will be overridden by environment variables on startup." global_proxy_settings: "Global Proxy Configuration" -global_proxy_type: "Proxy Type" global_proxy_host: "Proxy Host" global_proxy_port: "Proxy Port" global_proxy_user: "Proxy Username" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 5aa6d65..cf7fbea 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1,7 +1,22 @@ remote_files: "远程文件" -open_ssh_tab_sftp: "打开一个 SSH 会话面板以浏览 SFTP 文件。" +open_ssh_tab_sftp: "打开 SSH 标签页以浏览 SFTP 文件。" sftp_connecting: "正在连接 SFTP..." refresh: "刷新" +sftp_more_actions: "更多远程文件操作" +sftp_more_actions_short: "更多" +reveal_current_directory: "定位当前目录" +collapse_all_directories: "折叠全部目录" +parent_directory: "返回上级目录" +create_or_upload: "新建或上传" +add: "添加" +directories: "目录" +expand_directory: "展开目录" +collapse_directory: "折叠目录" +retry_directory: "重新加载目录" +loading_directory: "正在加载目录..." +directory_load_failed: "目录加载失败:%{reason}" +empty_directory: "当前目录为空" +selected_items: "已选择 %{count} 项" upload_file: "上传文件" upload_folder: "上传文件夹" hidden: "显示隐藏文件" @@ -11,14 +26,22 @@ download_count: "下载 (%{count})" name: "名称" size: "大小" modified: "修改时间" -new_ssh_connection: "新建 SSH 连接" +new_connection: "新建连接" +edit_connection: "编辑连接" create_or_edit_ssh_session: "创建或编辑一个 SSH 会话" new_serial_connection: "新建串口连接" -create_or_edit_serial_session: "创建或编辑串口会话" +edit_serial_connection: "编辑串口连接" serial_port: "串口设备路径" baud_rate: "波特率" session_name: "会话名称" new_folder: "新建文件夹" +rename: "重命名" +new_name: "新名称" +name_required: "名称不能为空。" +invalid_remote_name: "名称不能是“.”、“..”或包含“/”。" +rename_success: "已重命名为 %{name}" +rename_failed: "重命名失败:%{err}" +sftp_connection_unavailable: "SFTP 连接不可用。" create_folder_success: "成功创建文件夹: %{name}" create_folder_failed: "创建文件夹失败: %{err}" delete_selected: "删除" @@ -30,7 +53,7 @@ delete_failed: "删除失败: %{err}" deleting_paths: "正在删除 %{count} 个项..." confirm: "确认" local_terminal: "本地终端" -open_local_shell_tab: "打开一个新的本地终端页面" +open_local_shell_tab: "打开一个新的本地终端标签页" connect: "连接" edit: "编辑" delete: "删除" @@ -41,9 +64,6 @@ password: "密码 (Password)" key: "密钥 (Key)" ssh_config: "配置(~/.ssh/config)" ssh_config_empty: "~/.ssh/config 中未找到条目" -private_key_path: "私钥路径" -private_key_data: "私钥内容" -save_and_connect: "保存并连接" save: "保存" cancel: "取消" host_and_user_required: "主机名和用户名不能为空" @@ -56,17 +76,69 @@ use_dark_mode: "使用深色模式" english: "English" chinese: "简体中文" idle_no_session: "空闲 / 未连接会话" -system: "系统" cpu: "CPU" mem: "内存" swap: "虚拟内存" net: "网络" disk: "磁盘" -global_load: "全局负载" live: "实时" -idle: "空闲" -saved: "已保存会话" -add_ssh: "+ SSH" +memory: "内存" +cpu_percent: "CPU" +memory_usage_short: "内存" +process: "进程" +system_processes: "系统进程" +filter_processes: "筛选进程" +refresh_processes: "刷新进程列表" +no_processes: "暂无进程数据" +no_matching_processes: "没有匹配的进程" +process_details: "进程详细信息" +copy_process: "复制进程信息" +process_pid: "PID" +process_command: "命令" +process_summary: "%{name} · PID %{pid}" +network_ports: "网络端口" +refresh_ports: "刷新端口列表" +no_ports: "暂无网络端口" +filter_ports: "筛选端口" +no_matching_ports: "没有匹配的端口" +loading_ports: "正在加载端口列表..." +protocol: "协议" +address: "地址" +state: "状态" +loading_processes: "正在加载进程列表..." +terminate_process: "终止进程" +confirm_terminate_process: "确认终止进程?" +confirm_terminate_process_desc: "确定要向 PID %{pid} 发送终止信号吗?未保存的数据可能会丢失。" +terminating_process: "正在向进程 %{pid} 发送终止信号..." +process_terminated: "已向进程 %{pid} 发送终止信号" +process_terminate_failed: "终止进程 %{pid} 失败:%{reason}" +connection_management: "连接" +import_connections: "导入" +export_connections: "导出" +new_connection_short: "新建" +filter_connections: "筛选连接" +no_connections: "暂无连接" +no_matching_connections: "没有匹配的连接" +connections_exported: "已导出 %{count} 个连接" +connections_import_summary: "导入完成:新增 %{added} 个,更新 %{updated} 个,跳过 %{skipped} 个" +connections_export_failed: "导出失败:%{reason}" +connections_import_failed: "导入失败:%{reason}" +connections_export_no_ssh: "没有可导出的 SSH 连接" +connections_export_plaintext_title: "导出明文密码?" +connections_export_plaintext_warning: "CSV 将以明文保存 %{count} 个 SSH 连接的密码,不包含私钥和私钥口令。请妥善保管,并在迁移后及时移除。" +connections_export_plaintext_warning_with_skipped: "CSV 将以明文保存 %{count} 个 SSH 连接的密码,不包含私钥和私钥口令;另有 %{skipped} 个非 SSH 连接不会导出。请妥善保管,并在迁移后及时移除。" +delete_selected_connections: "删除" +connections_deleted: "已删除 %{count} 个连接" +delete_selected_commands: "删除选中命令" +commands_deleted: "已删除 %{count} 条命令" +command_history: "历史命令" +command_history_short: "历史命令" +command_history_empty: "暂无历史命令" +filter_command_history: "筛选历史命令" +no_matching_commands: "没有匹配的命令" +copy_command: "复制命令" +execute_command: "执行命令" +close_command_history: "关闭历史命令" open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。" open_session: "打开会话" settings: "设置" @@ -86,17 +158,19 @@ cursor_style_default: "默认" cursor_style_blink: "闪烁" cursor_style_beam: "细线" cursor_style_beam_blink: "细线闪烁" -monitoring_position: "系统监控显示位置" +monitoring_position: "本机监控显示位置" position_bottom: "底部" position_sidebar: "侧边栏" position_hidden: "不显示" -local_host: "本地主机" -local_terminal_opened: "已打开本地终端" connecting: "正在连接" starting_connection: "开始连接..." connection_failed: "连接失败" session_disconnected: "会话已断开: %{reason}" press_enter_to_reconnect: "按 Enter 重新连接" +ssh_reconnect_pending: "等待确认后重新连接" +reconnect_ssh_title: "重新连接" +reconnect_ssh_desc: "%{name} 当前未连接,是否现在重新连接?" +reconnect_ssh: "重新连接" preview_failed: "预览失败: %{err}" downloading_file: "正在下载 %{base}..." @@ -123,7 +197,6 @@ downloading: "正在下载" paused: "已暂停" completed: "已完成" failed: "失败" -cancelled: "已取消" interrupted: "已中断" session: "会话" zombie: "僵尸任务" @@ -133,16 +206,30 @@ software_builtin: "软件内置" retry: "重试" right_click_copy_paste: "右键复制/粘贴" keyword_highlight: "终端关键词高亮" +remember_tabs: "记忆标签页" lock_layout: "锁定窗口布局" lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。" copy_paste_hint: "选中后左键点击也可复制。" settings_copy: "复制选区" settings_paste: "粘贴剪贴板" edit_file: "编辑文件" -edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。" +edit_file_tooltip: "直接在 ashell 内编辑,并将修改保存到远程服务器。" +terminal_encoding: "终端编码" +file_encoding: "文件编码" +terminal_encoding_changed: "终端编码已切换为 %{encoding},新的输入和输出将使用此编码。" +file_encoding_changed: "文件编码已切换为 %{encoding}。" +encoding_decode_warning: "部分字节无法使用 %{encoding} 正确解码,请尝试其它编码。" +encoding_encode_failed: "部分字符无法使用 %{encoding} 表示,请更换编码后再保存。" +loading_file_content: "正在加载文件内容..." +saving_file_content: "正在保存文件..." +editor_content_too_large: "内容超过应用内编辑器的 %{max} 限制。" +unsaved_changes: "有未保存的修改" +file_content_loaded: "文件已加载" +open_remote_file_failed: "打开远程文件失败:%{err}" +remote_file_saved: "远程文件已保存" +save_remote_file_failed: "保存远程文件失败:%{err}" sync_cwd_tooltip: "从当前终端同步路径" sync_cwd: "同步路径" -auto_saved_and_uploaded: "已自动保存并上传: %{base}" scroll: "滑动查看 ↓" clone: "克隆" about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。" @@ -157,25 +244,26 @@ settings_group_keybind_general: "常规" settings_group_keybind_zoom: "缩放" settings_group_keybind_focus: "焦点" settings_group_keybind_panel: "面板" -theme: "主题" theme_mode: "主题模式" clear_transfers: "清空记录" transfers_limit: " (最多保留 100 条)" press_new_key: "请按下新的快捷键..." +none: "无" settings_open_settings: "打开设置" settings_open_session: "打开会话" settings_open_transfers: "打开传输历史" -files_downloading: "%{count} 个文件正在下载" -files_uploading: "%{count} 个文件正在上传" panel_minimize: "折叠面板" panel_expand: "展开面板" -settings_new_ssh: "新建 SSH" +panel_minimize_short: "折叠" +panel_expand_short: "展开" +settings_new_connection: "新建连接" settings_open_search: "搜索" +settings_quit_application: "退出应用" search: "搜索" no_results: "无结果" -settings_toggle_sidebar: "缩放侧边栏" -settings_toggle_sftp_zoom: "缩放 SFTP 面板" +settings_toggle_sidebar: "切换侧边栏" +settings_toggle_sftp_zoom: "切换 SFTP 面板大小" settings_focus_pane_left: "向左切换焦点" settings_focus_pane_right: "向右切换焦点" settings_focus_pane_up: "向上切换焦点" @@ -185,7 +273,7 @@ settings_split_pane_right: "向右拆分面板" settings_split_pane_up: "向上拆分面板" settings_split_pane_down: "向下拆分面板" settings_close_pane: "关闭当前面板" -keybind_conflict: '"%{key}" 已被「%{action}」使用,请选择其他快捷键' +keybind_conflict: "\"%{key}\" 已被「%{action}」使用,请选择其他快捷键" title_bar_style: "标题栏样式" title_bar_native: "原生" title_bar_integrated: "融合" @@ -216,7 +304,6 @@ enable_proxy: "启用代理功能" read_env_proxy: "启动时读取环境变量" read_env_proxy_desc: "若开启此项,启动时将读取环境变量,下方配置的全局代理设置将失效。" global_proxy_settings: "全局代理配置" -global_proxy_type: "代理类型" global_proxy_host: "代理地址" global_proxy_port: "代理端口" global_proxy_user: "代理用户名" diff --git a/src/app/config_sync.rs b/src/app/config_sync.rs index 623db30..b217bc3 100644 --- a/src/app/config_sync.rs +++ b/src/app/config_sync.rs @@ -1,13 +1,434 @@ +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; + +use anyhow::{Context as _, Result, anyhow, bail}; +use csv::{ReaderBuilder, StringRecord, Terminator, WriterBuilder}; use gpui::{Context, Entity, SharedString}; -use gpui_component::input::InputState; +use gpui_component::{WindowExt as _, dialog::DialogButtonProps, input::InputState}; use rust_i18n::t; +use serde::{Deserialize, Serialize}; use crate::{ Ashell, + app::DialogKind, + session::{ + config::{AuthMethod, Session}, + ssh_keys::normalize_inline_private_key, + }, sync::{self, SyncBackendCredentials, SyncCredentials, SyncPayload, SyncResult}, terminal::BackendEvent, }; +const CONNECTION_CSV_HEADERS: [&str; 5] = ["name", "host", "port", "username", "password"]; + +#[derive(Debug, Serialize)] +struct ConnectionCsvExportRow<'a> { + name: &'a str, + host: &'a str, + port: u16, + username: &'a str, + password: &'a str, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +struct ConnectionCsvRow { + #[serde(default)] + name: String, + #[serde(default)] + host: String, + #[serde(default)] + port: String, + #[serde(default)] + username: String, + #[serde(default)] + auth_type: String, + #[serde(default)] + password: String, + #[serde(default)] + private_key_path: String, + #[serde(default)] + private_key_content: String, + #[serde(default)] + passphrase: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ConnectionCsvColumns { + name: bool, + auth_type: bool, + password: bool, + private_key_path: bool, + private_key_content: bool, + passphrase: bool, +} + +#[derive(Debug)] +struct ImportedCsvConnection { + session: Session, + columns: ConnectionCsvColumns, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ConnectionIdentity { + host: String, + port: u16, + username: String, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct ConnectionImportSummary { + added: usize, + updated: usize, + skipped: usize, +} + +fn normalize_csv_header(header: &str) -> String { + header + .trim_start_matches('\u{feff}') + .trim() + .to_lowercase() + .chars() + .map(|character| match character { + ' ' | '-' => '_', + _ => character, + }) + .collect() +} + +fn canonical_csv_header(header: &str) -> Option<&'static str> { + match normalize_csv_header(header).as_str() { + "name" | "title" | "session" | "session_name" | "connection_name" => Some("name"), + "host" | "hostname" | "host_name" | "ip" | "ip_address" | "address" | "server" => { + Some("host") + } + "port" | "ssh_port" => Some("port"), + "username" | "user" | "user_name" | "login" | "login_name" => Some("username"), + "auth_type" + | "auth" + | "authentication" + | "authentication_type" + | "auth_method" + | "method" => Some("auth_type"), + "password" | "pass" | "passwd" => Some("password"), + "private_key_path" | "key_path" | "identity_file" | "identityfile" | "privatekeypath" => { + Some("private_key_path") + } + "private_key_content" + | "key_content" + | "private_key" + | "privatekey" + | "private_key_data" => Some("private_key_content"), + "passphrase" | "key_password" | "key_passphrase" => Some("passphrase"), + _ => None, + } +} + +fn parse_auth_method(value: &str, inferred: AuthMethod, line: u64) -> Result { + let normalized = normalize_csv_header(value); + match normalized.as_str() { + "" => Ok(inferred), + "password" | "pass" => Ok(AuthMethod::Password), + "key" | "private_key" | "public_key" => Ok(AuthMethod::Key), + "config" | "ssh_config" => Ok(AuthMethod::Config), + _ => bail!("CSV line {line}: unsupported auth_type '{value}'"), + } +} + +impl ConnectionCsvColumns { + fn from_headers(headers: &HashSet<&'static str>) -> Self { + Self { + name: headers.contains("name"), + auth_type: headers.contains("auth_type"), + password: headers.contains("password"), + private_key_path: headers.contains("private_key_path"), + private_key_content: headers.contains("private_key_content"), + passphrase: headers.contains("passphrase"), + } + } +} + +impl ConnectionCsvRow { + fn into_connection( + self, + line: u64, + columns: ConnectionCsvColumns, + ) -> Result { + let Self { + name, + host, + port, + username, + auth_type, + password, + private_key_path, + private_key_content, + passphrase, + } = self; + let host = host.trim().to_string(); + let username = username.trim().to_string(); + if host.is_empty() { + bail!("CSV line {line}: host is required"); + } + if username.is_empty() { + bail!("CSV line {line}: username is required"); + } + + let port = if port.trim().is_empty() { + 22 + } else { + port.trim() + .parse::() + .with_context(|| format!("CSV line {line}: invalid port '{}'", port.trim()))? + }; + if port == 0 { + bail!("CSV line {line}: port must be between 1 and 65535"); + } + + let private_key_path = private_key_path.trim().to_string(); + let private_key_content = normalize_inline_private_key(&private_key_content); + let inferred_auth = if !private_key_path.is_empty() + || !private_key_content.is_empty() + || password.is_empty() + { + AuthMethod::Key + } else { + AuthMethod::Password + }; + let auth = parse_auth_method(&auth_type, inferred_auth, line)?; + + let mut session = Session::password(host, port, username, password); + session.auth = auth; + session.private_key_path = if private_key_content.is_empty() { + private_key_path + } else { + String::new() + }; + session.private_key_inline = private_key_content; + session.passphrase = passphrase; + if !name.trim().is_empty() { + session.name = name.trim().to_string(); + } + Ok(ImportedCsvConnection { session, columns }) + } +} + +fn decode_connection_csv(bytes: &[u8]) -> Result> { + std::str::from_utf8(bytes).context("connection CSV must be UTF-8")?; + let mut reader = ReaderBuilder::new() + .has_headers(true) + .flexible(true) + .trim(csv::Trim::Headers) + .from_reader(bytes); + let source_headers = reader + .headers() + .context("failed to read CSV header")? + .clone(); + if source_headers.is_empty() { + bail!("connection CSV has no header"); + } + + let mut recognized_headers = HashSet::new(); + let normalized_headers = source_headers + .iter() + .enumerate() + .map(|(index, header)| { + if let Some(canonical) = canonical_csv_header(header) { + if !recognized_headers.insert(canonical) { + bail!("connection CSV contains duplicate '{canonical}' columns"); + } + Ok(canonical.to_string()) + } else { + Ok(format!("__ignored_{index}")) + } + }) + .collect::>>()?; + for required in ["host", "username"] { + if !recognized_headers.contains(required) { + bail!("connection CSV is missing the '{required}' column"); + } + } + let columns = ConnectionCsvColumns::from_headers(&recognized_headers); + + let normalized_headers = StringRecord::from(normalized_headers); + reader.set_headers(normalized_headers.clone()); + let mut connections = Vec::new(); + for (index, record) in reader.records().enumerate() { + let record = record.map_err(|err| { + let line = err + .position() + .map(|position| position.line()) + .unwrap_or(index as u64 + 2); + anyhow!("CSV line {line}: {err}") + })?; + let line = index as u64 + 2; + let row = record + .deserialize::(Some(&normalized_headers)) + .map_err(|err| anyhow!("CSV line {line}: {err}"))?; + connections.push(row.into_connection(line, columns)?); + } + Ok(connections) +} + +fn encode_connection_csv(sessions: &[Session]) -> Result> { + let mut output = vec![0xef, 0xbb, 0xbf]; + { + let mut writer = WriterBuilder::new() + .has_headers(false) + .terminator(Terminator::CRLF) + .from_writer(&mut output); + writer + .write_record(CONNECTION_CSV_HEADERS) + .context("failed to write CSV header")?; + for session in sessions { + let row = ConnectionCsvExportRow { + name: &session.name, + host: &session.host, + port: session.port, + username: &session.user, + password: if session.auth == AuthMethod::Password { + &session.password + } else { + "" + }, + }; + writer + .serialize(row) + .context("failed to serialize connection CSV")?; + } + writer.flush().context("failed to finish connection CSV")?; + } + Ok(output) +} + +fn write_connection_csv(path: &Path, contents: &[u8]) -> Result<()> { + #[cfg(unix)] + { + use std::{ + io::Write as _, + os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}, + }; + + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path) + .with_context(|| format!("failed to open {}", path.display()))?; + file.write_all(contents) + .with_context(|| format!("failed to write {}", path.display()))?; + let mut permissions = file + .metadata() + .with_context(|| format!("failed to inspect {}", path.display()))? + .permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions) + .with_context(|| format!("failed to protect {}", path.display()))?; + } + + #[cfg(not(unix))] + std::fs::write(path, contents) + .with_context(|| format!("failed to write {}", path.display()))?; + + Ok(()) +} + +fn is_ssh_session(session: &Session) -> bool { + session.protocol.eq_ignore_ascii_case("ssh") +} + +fn connection_identity(session: &Session) -> ConnectionIdentity { + ConnectionIdentity { + host: session.host.trim().to_lowercase(), + port: session.port, + username: session.user.trim().to_string(), + } +} + +fn deduplicate_csv_connections( + connections: Vec, +) -> Vec { + let mut positions = HashMap::::new(); + let mut deduplicated = Vec::new(); + for connection in connections { + let identity = connection_identity(&connection.session); + if let Some(index) = positions.get(&identity).copied() { + deduplicated[index] = connection; + } else { + positions.insert(identity, deduplicated.len()); + deduplicated.push(connection); + } + } + deduplicated +} + +fn update_csv_session(existing: &mut Session, imported: ImportedCsvConnection) { + let ImportedCsvConnection { session, columns } = imported; + if columns.name { + existing.name = session.name; + } + existing.host = session.host; + existing.port = session.port; + existing.user = session.user; + + if columns.password + || columns.auth_type + || columns.private_key_path + || columns.private_key_content + { + existing.auth = session.auth; + } + if columns.password { + existing.password = session.password; + } + if columns.private_key_content { + existing.private_key_inline = session.private_key_inline.clone(); + if !session.private_key_inline.is_empty() { + existing.private_key_path.clear(); + } + } + if columns.private_key_path && session.private_key_inline.is_empty() { + existing.private_key_path = session.private_key_path; + if !existing.private_key_path.is_empty() { + existing.private_key_inline.clear(); + } + } + if columns.passphrase { + existing.passphrase = session.passphrase; + } +} + +fn merge_csv_sessions( + local_sessions: &mut Vec, + imported_connections: Vec, +) -> ConnectionImportSummary { + let mut summary = ConnectionImportSummary::default(); + for imported in deduplicate_csv_connections(imported_connections) { + let identity = connection_identity(&imported.session); + let mut matches = local_sessions + .iter() + .enumerate() + .filter(|(_, local)| is_ssh_session(local) && connection_identity(local) == identity) + .map(|(index, _)| index); + let first_match = matches.next(); + if matches.next().is_some() { + summary.skipped += 1; + continue; + } + if let Some(index) = first_match { + update_csv_session(&mut local_sessions[index], imported); + summary.updated += 1; + continue; + } + + local_sessions.push(imported.session); + summary.added += 1; + } + summary +} + impl Ashell { fn sync_input_value(input: &Entity, cx: &Context) -> String { input.read(cx).value().trim().to_string() @@ -166,7 +587,7 @@ impl Ashell { .add_filter("JSON", &["json"]) .pick_file(); - cx.spawn_in(window, async move |this, mut cx| { + cx.spawn_in(window, async move |this, cx| { if let Some(file_handle) = file_dialog.await { let path = file_handle.path().to_path_buf(); let read_result = cx @@ -178,9 +599,11 @@ impl Ashell { if let Ok(config_file) = serde_json::from_str::(&json_str) { - let _ = gpui::AsyncWindowContext::update(&mut cx, |window, cx| { + let _ = gpui::AsyncWindowContext::update(cx, |window, cx| { let _ = this.update(cx, |this, cx| { this.config.cache = config_file; + this.config.normalize_command_history(); + this.selected_connection_ids.clear(); if let Err(err) = this.config.save() { tracing::error!("failed to save imported config: {err:#}"); } else { @@ -195,4 +618,417 @@ impl Ashell { }) .detach(); } + + pub(crate) fn export_connections(&mut self, window: &mut gpui::Window, cx: &mut Context) { + if self.active_dialog.is_some() { + return; + } + + let export_selected = !self.selected_connection_ids.is_empty(); + let candidates = self + .config + .sessions() + .iter() + .filter(|session| { + !export_selected || self.selected_connection_ids.contains(&session.id) + }) + .cloned() + .collect::>(); + let skipped = candidates + .iter() + .filter(|session| !is_ssh_session(session)) + .count(); + let sessions = candidates + .into_iter() + .filter(is_ssh_session) + .collect::>(); + if sessions.is_empty() { + self.status = t!("connections_export_no_ssh").to_string().into(); + cx.notify(); + return; + } + + let count = sessions.len(); + let title = t!("connections_export_plaintext_title").to_string(); + let description = if skipped == 0 { + t!("connections_export_plaintext_warning", count = count).to_string() + } else { + t!( + "connections_export_plaintext_warning_with_skipped", + count = count, + skipped = skipped + ) + .to_string() + }; + let confirm_text = t!("export_connections").to_string(); + let cancel_text = t!("cancel").to_string(); + let view = cx.entity(); + let view_for_cancel = view.clone(); + self.active_dialog = Some(DialogKind::ConnectionExport); + cx.notify(); + + window.open_alert_dialog(cx, move |alert, _, _| { + let view_for_ok = view.clone(); + let sessions_for_export = sessions.clone(); + alert + .title(title.clone()) + .description(description.clone()) + .button_props( + DialogButtonProps::default() + .ok_text(confirm_text.clone()) + .cancel_text(cancel_text.clone()) + .show_cancel(true), + ) + .on_ok(move |_, window, cx| { + let sessions = sessions_for_export.clone(); + view_for_ok.update(cx, |this, cx| { + if this.active_dialog == Some(DialogKind::ConnectionExport) { + this.active_dialog = None; + } + this.start_connection_csv_export(sessions, window, cx); + cx.notify(); + }); + true + }) + .on_cancel({ + let view = view_for_cancel.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if this.active_dialog == Some(DialogKind::ConnectionExport) { + this.active_dialog = None; + } + cx.notify(); + }); + true + } + }) + }); + } + + fn start_connection_csv_export( + &mut self, + sessions: Vec, + window: &mut gpui::Window, + cx: &mut Context, + ) { + let count = sessions.len(); + cx.spawn_in(window, async move |this, cx| { + let encoded = cx + .background_executor() + .spawn(async move { encode_connection_csv(&sessions) }) + .await; + let contents = match encoded { + Ok(contents) => contents, + Err(err) => { + tracing::error!("failed to prepare connection export: {err:#}"); + let reason = err.to_string(); + let _ = gpui::AsyncWindowContext::update(cx, |_, cx| { + let _ = this.update(cx, |this, cx| { + this.status = t!("connections_export_failed", reason = reason) + .to_string() + .into(); + cx.notify(); + }); + }); + return Ok::<(), anyhow::Error>(()); + } + }; + + let file_dialog = rfd::AsyncFileDialog::new() + .set_file_name("ashell-connections.csv") + .add_filter("CSV", &["csv"]) + .save_file(); + if let Some(file_handle) = file_dialog.await { + let path = file_handle.path().to_path_buf(); + let result = cx + .background_executor() + .spawn(async move { write_connection_csv(&path, &contents) }) + .await; + let _ = gpui::AsyncWindowContext::update(cx, |_, cx| { + let _ = this.update(cx, |this, cx| { + this.status = match result { + Ok(()) => t!("connections_exported", count = count).to_string().into(), + Err(err) => { + tracing::error!("failed to export connections: {err:#}"); + t!("connections_export_failed", reason = err.to_string()) + .to_string() + .into() + } + }; + cx.notify(); + }); + }); + } + Ok::<(), anyhow::Error>(()) + }) + .detach(); + } + + pub(crate) fn import_connections(&mut self, window: &mut gpui::Window, cx: &mut Context) { + let file_dialog = rfd::AsyncFileDialog::new() + .add_filter("CSV", &["csv"]) + .pick_file(); + + cx.spawn_in(window, async move |this, cx| { + if let Some(file_handle) = file_dialog.await { + let path = file_handle.path().to_path_buf(); + let result = cx + .background_executor() + .spawn(async move { + let contents = std::fs::read(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + decode_connection_csv(&contents) + }) + .await; + + let _ = gpui::AsyncWindowContext::update(cx, |_, cx| { + let _ = this.update(cx, |this, cx| { + match result { + Ok(sessions) => { + let previous_config = this.config.cache.clone(); + let summary = + merge_csv_sessions(&mut this.config.cache.sessions, sessions); + + match this.config.save() { + Ok(()) => { + this.selected_connection_ids.clear(); + this.status = t!( + "connections_import_summary", + added = summary.added, + updated = summary.updated, + skipped = summary.skipped + ) + .to_string() + .into(); + } + Err(err) => { + this.config.cache = previous_config; + tracing::error!( + "failed to save imported connections: {err:#}" + ); + this.status = t!( + "connections_import_failed", + reason = err.to_string() + ) + .to_string() + .into(); + } + } + } + Err(err) => { + tracing::error!("failed to import connections: {err:#}"); + this.status = + t!("connections_import_failed", reason = err.to_string()) + .to_string() + .into(); + } + } + cx.notify(); + }); + }); + } + Ok::<(), anyhow::Error>(()) + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn password_session(id: &str, name: &str, host: &str, user: &str) -> Session { + let mut session = + Session::password(host.to_string(), 22, user.to_string(), "secret".to_string()); + session.id = id.to_string(); + session.name = name.to_string(); + session + } + + #[test] + fn csv_export_uses_minimal_columns_and_preserves_quoted_passwords() { + let mut password = password_session("password-id", "Primary, server", "one.test", "root"); + password.password = "line one,\nline two".to_string(); + + let mut key = Session::key( + "two.test".to_string(), + 2202, + "deploy".to_string(), + "/keys/id_ed25519".to_string(), + "-----BEGIN PRIVATE KEY-----\nkey data\n-----END PRIVATE KEY-----\n".to_string(), + "key phrase".to_string(), + ); + key.id = "key-id".to_string(); + key.name = "Key server".to_string(); + + let bytes = encode_connection_csv(&[password, key]).expect("encode CSV"); + assert!(bytes.starts_with(&[0xef, 0xbb, 0xbf])); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("\r\n")); + assert_eq!( + text.trim_start_matches('\u{feff}').split("\r\n").next(), + Some("name,host,port,username,password") + ); + assert!(!text.contains("auth_type")); + assert!(!text.contains("private_key_path")); + assert!(!text.contains("private_key_content")); + assert!(!text.contains("passphrase")); + assert!(!text.contains("password-id")); + assert!(!text.contains("BEGIN PRIVATE KEY")); + assert!(!text.contains("key phrase")); + + let connections = decode_connection_csv(&bytes).expect("decode CSV"); + assert_eq!(connections.len(), 2); + assert_eq!(connections[0].session.password, "line one,\nline two"); + assert_eq!(connections[0].session.auth, AuthMethod::Password); + assert_eq!(connections[1].session.auth, AuthMethod::Key); + assert!(connections[1].session.password.is_empty()); + assert!(connections[1].session.private_key_path.is_empty()); + assert!(connections[1].session.private_key_inline.is_empty()); + assert!(connections[1].session.passphrase.is_empty()); + } + + #[test] + fn csv_import_accepts_common_aliases_and_defaults_the_port() { + let csv = concat!( + "Title,Hostname,SSH Port,User,Authentication,Pass,Ignored Column\r\n", + "Production,EXAMPLE.test,,root,password,secret,ignored\r\n" + ); + let connections = decode_connection_csv(csv.as_bytes()).expect("decode aliased CSV"); + assert_eq!(connections.len(), 1); + assert_eq!(connections[0].session.name, "Production"); + assert_eq!(connections[0].session.host, "EXAMPLE.test"); + assert_eq!(connections[0].session.port, 22); + assert_eq!(connections[0].session.user, "root"); + assert_eq!(connections[0].session.password, "secret"); + assert!(connections[0].columns.auth_type); + } + + #[test] + fn csv_import_reports_the_invalid_row() { + let csv = "host,username,port\r\none.test,root,22\r\ntwo.test,root,70000\r\n"; + let error = decode_connection_csv(csv.as_bytes()).expect_err("invalid port must fail"); + assert!( + error.to_string().contains("CSV line 3"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn csv_import_remains_compatible_with_optional_legacy_columns() { + let csv = concat!( + "name,host,username,auth_type,private_key_path,private_key_content,passphrase,id\r\n", + "Legacy,key.test,root,key,/keys/id_test,", + "\"-----BEGIN PRIVATE KEY-----\\nkey\\n-----END PRIVATE KEY-----\",", + "key phrase,external-id\r\n" + ); + let connections = decode_connection_csv(csv.as_bytes()).expect("decode legacy CSV"); + assert_eq!(connections.len(), 1); + let connection = &connections[0]; + assert_eq!(connection.session.auth, AuthMethod::Key); + assert!(connection.session.private_key_path.is_empty()); + assert!( + connection + .session + .private_key_inline + .contains("BEGIN PRIVATE KEY") + ); + assert_eq!(connection.session.passphrase, "key phrase"); + assert_ne!(connection.session.id, "external-id"); + assert!(connection.columns.private_key_path); + assert!(connection.columns.private_key_content); + assert!(connection.columns.passphrase); + } + + #[test] + fn csv_merge_uses_the_last_duplicate_row() { + let csv = concat!( + "name,host,port,username,password\r\n", + "First,same.test,22,root,old\r\n", + "Last,SAME.test,22,root,latest\r\n" + ); + let imported = decode_connection_csv(csv.as_bytes()).expect("decode duplicate CSV"); + let mut local = Vec::new(); + + let summary = merge_csv_sessions(&mut local, imported); + assert_eq!( + summary, + ConnectionImportSummary { + added: 1, + updated: 0, + skipped: 0, + } + ); + assert_eq!(local.len(), 1); + assert_eq!(local[0].name, "Last"); + assert_eq!(local[0].password, "latest"); + } + + #[test] + fn csv_merge_preserves_local_only_fields_and_skips_ambiguous_matches() { + let mut identity_match = Session::key( + "match.test".to_string(), + 22, + "deploy".to_string(), + "/keys/local".to_string(), + String::new(), + "local phrase".to_string(), + ); + identity_match.id = "local-id".to_string(); + identity_match.name = "Identity".to_string(); + identity_match.proxy_type = "socks5".to_string(); + identity_match.proxy_host = "proxy.test".to_string(); + identity_match.last_used = Some("2026-08-14T10:00:00Z".to_string()); + let duplicate_one = password_session("duplicate-1", "Duplicate 1", "dupe.test", "root"); + let duplicate_two = password_session("duplicate-2", "Duplicate 2", "DUPE.test", "root"); + let mut local = vec![identity_match, duplicate_one, duplicate_two]; + let csv = concat!( + "name,host,port,username,password\r\n", + "Matched,MATCH.test,22,deploy,\r\n", + "Added,added.test,22,root,new password\r\n", + "Ambiguous,dupe.test,22,root,password\r\n" + ); + let imported = decode_connection_csv(csv.as_bytes()).expect("decode merge CSV"); + + let summary = merge_csv_sessions(&mut local, imported); + assert_eq!( + summary, + ConnectionImportSummary { + added: 1, + updated: 1, + skipped: 1, + } + ); + + let identity = local + .iter() + .find(|session| session.id == "local-id") + .expect("identity match"); + assert_eq!(identity.name, "Matched"); + assert_eq!(identity.auth, AuthMethod::Key); + assert_eq!(identity.private_key_path, "/keys/local"); + assert_eq!(identity.passphrase, "local phrase"); + assert_eq!(identity.proxy_type, "socks5"); + assert_eq!(identity.proxy_host, "proxy.test"); + assert_eq!(identity.last_used.as_deref(), Some("2026-08-14T10:00:00Z")); + assert!(local.iter().any(|session| session.host == "added.test")); + } + + #[test] + fn csv_merge_ignores_legacy_ids_and_preserves_an_unlisted_name() { + let local = password_session("local-id", "Custom name", "same.test", "root"); + let csv = concat!( + "host,port,username,password,id\r\n", + "same.test,22,root,new password,other-id\r\n" + ); + let imported = decode_connection_csv(csv.as_bytes()).expect("decode CSV with legacy ID"); + let mut sessions = vec![local]; + + let summary = merge_csv_sessions(&mut sessions, imported); + assert_eq!(summary.updated, 1); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].id, "local-id"); + assert_eq!(sessions[0].name, "Custom name"); + assert_eq!(sessions[0].password, "new password"); + } } diff --git a/src/app/controls.rs b/src/app/controls.rs new file mode 100644 index 0000000..d43cc0a --- /dev/null +++ b/src/app/controls.rs @@ -0,0 +1,95 @@ +use std::time::Duration; + +use gpui::{ + App, ClipboardItem, ElementId, IntoElement, RenderOnce, SharedString, Styled as _, Window, + prelude::FluentBuilder as _, +}; +use gpui_component::{ + IconName, Sizable as _, + button::{Button, ButtonVariants as _}, + checkbox::Checkbox, + switch::Switch, +}; + +pub(crate) fn pointer_button(id: impl Into) -> Button { + Button::new(id).cursor_pointer() +} + +pub(crate) fn pointer_checkbox(id: impl Into) -> Checkbox { + Checkbox::new(id).cursor_pointer() +} + +pub(crate) fn pointer_switch(id: impl Into) -> Switch { + Switch::new(id).cursor_pointer() +} + +#[derive(IntoElement)] +pub(crate) struct PointerClipboard { + id: ElementId, + value: SharedString, + tooltip: Option, +} + +impl PointerClipboard { + pub(crate) fn new(id: impl Into) -> Self { + Self { + id: id.into(), + value: SharedString::default(), + tooltip: None, + } + } + + pub(crate) fn value(mut self, value: impl Into) -> Self { + self.value = value.into(); + self + } + + pub(crate) fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(tooltip.into()); + self + } +} + +impl RenderOnce for PointerClipboard { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| ClipboardState::default()); + let copied = state.read(cx).copied; + + pointer_button(self.id) + .icon(if copied { + IconName::Check + } else { + IconName::Copy + }) + .ghost() + .xsmall() + .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) + .when(!copied, |this| { + let state = state.clone(); + let value = self.value.clone(); + this.on_click(move |_, _, cx| { + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string(value.to_string())); + state.update(cx, |state, cx| { + state.copied = true; + cx.notify(); + }); + + let state = state.clone(); + cx.spawn(async move |cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + state.update(cx, |state, cx| { + state.copied = false; + cx.notify(); + }); + }) + .detach(); + }) + }) + } +} + +#[derive(Default)] +struct ClipboardState { + copied: bool, +} diff --git a/src/app/keybinding_recorder.rs b/src/app/keybinding_recorder.rs index f0850b5..4b3b359 100644 --- a/src/app/keybinding_recorder.rs +++ b/src/app/keybinding_recorder.rs @@ -3,13 +3,13 @@ use gpui::{ }; use gpui_component::{ Sizable, - button::{Button, ButtonVariants}, + button::ButtonVariants, kbd::Kbd, setting::{SettingField, SettingGroup, SettingItem}, }; use rust_i18n::t; -use crate::{Ashell, session::config::ConfigStore}; +use crate::{Ashell, app::controls::pointer_button, session::config::ConfigStore}; gpui::actions!( ashell_workspace, @@ -19,6 +19,7 @@ gpui::actions!( OpenTransfers, NewSsh, OpenSearch, + QuitApplication, ToggleSidebar, ToggleSftpZoom, FocusPaneLeft, @@ -62,7 +63,7 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[ }, WorkspaceAction { id: "NewSsh", - label_key: "settings_new_ssh", + label_key: "settings_new_connection", default_suffix: "n", }, WorkspaceAction { @@ -70,6 +71,11 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[ label_key: "settings_open_search", default_suffix: "f", }, + WorkspaceAction { + id: "QuitApplication", + label_key: "settings_quit_application", + default_suffix: "q", + }, WorkspaceAction { id: "ToggleSidebar", label_key: "settings_toggle_sidebar", @@ -154,6 +160,10 @@ pub(crate) fn default_modifier() -> &'static str { } pub(crate) fn default_keystroke(action_id: &str) -> Option { + if action_id == "QuitApplication" && cfg!(target_os = "windows") { + return Some("alt-f4".to_string()); + } + WORKSPACE_ACTIONS .iter() .find(|action| action.id == action_id) @@ -206,7 +216,41 @@ pub(crate) fn bind_workspace_keys_from_config(cx: &mut App, config: &ConfigStore bind_workspace_actions(cx, config); } -/// Unbind all workspace keybindings (used when entering keybinding settings to prevent interference). +pub(crate) fn suspend_quit_keybinding(cx: &mut App, config: &ConfigStore) { + let default = default_keystroke("QuitApplication").expect("quit action has a default key"); + let configured = + configured_keystroke(config, "QuitApplication").unwrap_or_else(|| default.clone()); + let action_name = crate::QuitApplication.name(); + let mut bindings = vec![KeyBinding::new(&default, Unbind(action_name.into()), None)]; + if configured != default && configured != "none" && !configured.is_empty() { + bindings.push(KeyBinding::new( + &configured, + Unbind(action_name.into()), + None, + )); + } + cx.bind_keys(bindings); +} + +pub(crate) fn restore_quit_keybinding(cx: &mut App, config: &ConfigStore) { + let default = default_keystroke("QuitApplication").expect("quit action has a default key"); + let configured = + configured_keystroke(config, "QuitApplication").unwrap_or_else(|| default.clone()); + let action_name = crate::QuitApplication.name(); + let mut bindings = Vec::new(); + if configured != default { + bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None)); + } + if configured != "none" && !configured.is_empty() { + bindings.push(KeyBinding::new(&configured, crate::QuitApplication, None)); + } + cx.bind_keys(bindings); +} + +/// Unbind workspace keybindings that could interfere with key recording. +/// +/// The quit binding remains active so the application can always be closed while +/// the settings dialog is open. pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) { let mut bindings = Vec::new(); @@ -293,6 +337,7 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) { bind_action!("OpenTransfers", crate::OpenTransfers); bind_action!("NewSsh", crate::NewSsh); bind_action!("OpenSearch", crate::OpenSearch); + bind_action!("QuitApplication", crate::QuitApplication); bind_action!("ToggleSidebar", crate::ToggleSidebar); bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom); bind_action!("FocusPaneLeft", crate::FocusPaneLeft); @@ -321,6 +366,7 @@ impl KeybindingsPage { "OpenTransfers", "NewSsh", "OpenSearch", + "QuitApplication", "Copy", "Paste", ], @@ -395,7 +441,7 @@ impl KeybindingsPage { let view = view.clone(); let action_id = action.id.to_string(); move |_, _window, _cx| { - Button::new(gpui::SharedString::from(format!("keybind-{action_id}"))) + pointer_button(gpui::SharedString::from(format!("keybind-{action_id}"))) .label(btn_label.clone()) .small() .when(recording, |this| this.primary()) @@ -407,6 +453,7 @@ impl KeybindingsPage { view.update(cx, |this, cx| { // Clear any previous error when starting new recording this.keybind_error = None; + suspend_quit_keybinding(cx, &this.config); this.recording_action = Some(action_id.clone()); this.focus_handle.focus(window, cx); cx.notify(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 8ada35a..771b51a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,6 @@ pub mod config_sync; pub mod constants; +pub mod controls; pub mod dialogs; pub mod keybinding_recorder; pub mod resizable; @@ -10,7 +11,7 @@ pub mod ui; use std::{ cell::{Cell, RefCell}, - collections::HashMap, + collections::{HashMap, HashSet}, ops::Range, rc::Rc, sync::mpsc, @@ -33,8 +34,9 @@ use tokio::runtime::Runtime; use crate::{ session::config::{AuthMethod, ConfigStore}, session::ssh_config::SshConfigEntry, - system::{SystemSampler, SystemSnapshot}, + system::{RemotePort, RemoteProcess, SystemSampler, SystemSnapshot}, terminal::{self, BackendEvent, TabKind, TerminalTab}, + text_encoding::TextEncoding, }; #[derive(Clone, Debug)] @@ -50,6 +52,7 @@ pub(crate) struct TabGroup { pub(crate) title: String, pub(crate) pane_root: PaneLayout, pub(crate) sftp: Option, + pub(crate) sftp_tab_id: Option, } impl PaneLayout { @@ -197,8 +200,21 @@ impl ScrollbarHandle for TerminalScrollbarHandle { pub(crate) enum DialogKind { Settings, SessionSelector, + ConnectionExport, Transfers, NewSsh, + SftpRename, + SftpEditor, + Processes, + Ports, + SshReconnect, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum ServerMonitorView { + #[default] + Cpu, + Memory, } pub(crate) struct Ashell { @@ -214,6 +230,7 @@ pub(crate) struct Ashell { pub(crate) passphrase_input: Entity, pub(crate) baud_rate_input: Entity, pub(crate) session_protocol: String, + pub(crate) ssh_terminal_encoding: TextEncoding, pub(crate) ssh_proxy_type: String, pub(crate) proxy_host_input: Entity, pub(crate) proxy_port_input: Entity, @@ -238,6 +255,12 @@ pub(crate) struct Ashell { pub(crate) sync_in_progress: bool, pub(crate) sync_status: SharedString, pub(crate) sftp_path_input: Entity, + pub(crate) remote_process_filter_input: Entity, + pub(crate) remote_port_filter_input: Entity, + pub(crate) connection_filter_input: Entity, + pub(crate) command_history_filter_input: Entity, + pub(crate) selected_connection_ids: HashSet, + pub(crate) selected_command_history: HashSet<(String, usize)>, pub(crate) ssh_auth_method: AuthMethod, pub(crate) ssh_config_entries: Vec, pub(crate) ssh_config_selected: Option, @@ -258,24 +281,49 @@ pub(crate) struct Ashell { pub(crate) selector_selection: usize, pub(crate) workspace_panels: Entity, pub(crate) body_panels: Entity, + pub(crate) sftp_tree_panels: Entity, + pub(crate) sftp_file_columns: Entity, pub(crate) is_layout_reset: bool, pub(crate) terminal_scrollbars: HashMap, pub(crate) remote_files_scroll_handle: UniformListScrollHandle, + pub(crate) remote_files_horizontal_scroll_handle: gpui::ScrollHandle, + pub(crate) remote_files_columns_viewport_width: Option, + pub(crate) remote_tree_scroll_handle: UniformListScrollHandle, pub(crate) disk_scroll_handle: gpui::ScrollHandle, + pub(crate) process_scroll_handle: gpui::ScrollHandle, + pub(crate) port_scroll_handle: UniformListScrollHandle, pub(crate) tabs_scroll_handle: gpui::ScrollHandle, pub(crate) selector_scroll_handle: gpui::ScrollHandle, pub(crate) saved_scroll_handle: gpui::ScrollHandle, + pub(crate) command_history_scroll_handle: gpui::ScrollHandle, pub(crate) connection_scroll_handle: gpui::ScrollHandle, + pub(crate) saved_sessions_overflowing: bool, pub(crate) connection_progress: Option, pub(crate) pending_sftp_path_sync: Option, pub(crate) sftp_context_menu: Option, + pub(crate) sftp_rename_input: Entity, + pub(crate) sftp_rename_state: Option, + pub(crate) sftp_editor_input: Entity, + pub(crate) sftp_editor_state: Option, pub(crate) sftp_creating_folder: bool, pub(crate) sftp_new_folder_input: Entity, pub(crate) sftp_delete_scroll_handle: gpui::ScrollHandle, pub(crate) show_hidden_files: bool, pub(crate) transfers: Vec, pub(crate) show_transfers_dialog: bool, + pub(crate) show_command_history: bool, + pub(crate) ssh_command_buffers: HashMap, + pub(crate) ssh_command_starts: HashMap, pub(crate) system_status: Option, + pub(crate) server_monitor_view: ServerMonitorView, + pub(crate) remote_processes: Vec, + pub(crate) remote_process_status: Option, + pub(crate) remote_processes_in_flight: bool, + pub(crate) expanded_process_pid: Option, + pub(crate) remote_ports: Vec, + pub(crate) remote_ports_status: Option, + pub(crate) remote_ports_in_flight: bool, + pub(crate) terminating_processes: HashSet, pub(crate) pane_root: PaneLayout, pub(crate) focused_pane_path: Vec, pub(crate) terminal_panel_bounds: Option>, @@ -327,7 +375,9 @@ pub(crate) struct Ashell { pub(crate) hovered_url: Option, pub(crate) cmd_ctrl_pressed: bool, pub(crate) _subscriptions: Vec, - pub(crate) save_lock: std::sync::Arc>, + pub(crate) last_window_bounds: Option, + pub(crate) window_bounds_save_task: Option>, + pub(crate) save_lock: std::sync::Arc>, pub(crate) save_latest_seq: std::sync::Arc, } @@ -360,6 +410,44 @@ pub(crate) struct SftpContextMenuState { pub(crate) position: Point, } +#[derive(Clone, Debug)] +pub(crate) struct SftpRenameState { + pub(crate) group_id: String, + pub(crate) old_path: String, + pub(crate) in_flight: bool, + pub(crate) error: Option, +} + +#[derive(Clone, Debug)] +pub(crate) enum SftpEditorInteraction { + Move { + pointer_origin: Point, + initial_bounds: Bounds, + }, + Resize { + pointer_origin: Point, + initial_bounds: Bounds, + }, +} + +#[derive(Clone, Debug)] +pub(crate) struct SftpEditorState { + pub(crate) group_id: String, + pub(crate) remote_path: String, + pub(crate) raw_content: Vec, + pub(crate) original_content: String, + pub(crate) encoding: TextEncoding, + pub(crate) has_bom: bool, + pub(crate) decode_had_errors: bool, + pub(crate) loaded: bool, + pub(crate) loading: bool, + pub(crate) saving: bool, + pub(crate) message: Option, + pub(crate) error: Option, + pub(crate) bounds: Bounds, + pub(crate) interaction: Option, +} + impl Ashell { fn transfer_source_title(&self, tab_id: &str) -> String { self.tabs @@ -420,8 +508,26 @@ 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 sftp_rename_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_name").to_string())); + let sftp_editor_input = cx.new(|cx| { + InputState::new(window, cx) + .code_editor("text") + .line_number(true) + .soft_wrap(false) + }); let search_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("search").to_string())); + let remote_process_filter_input = cx + .new(|cx| InputState::new(window, cx).placeholder(t!("filter_processes").to_string())); + let remote_port_filter_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t!("filter_ports").to_string())); + let connection_filter_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(t!("filter_connections").to_string()) + }); + let command_history_filter_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(t!("filter_command_history").to_string()) + }); let config = ConfigStore::load().unwrap_or_else(|err| { tracing::warn!("failed to load config: {err:#}"); ConfigStore::in_memory() @@ -522,7 +628,13 @@ impl Ashell { cx.subscribe_in(&proxy_password_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(&sftp_rename_input, window, Self::on_input_event), + cx.subscribe_in(&sftp_editor_input, window, Self::on_input_event), cx.subscribe_in(&search_input, window, Self::on_input_event), + cx.subscribe_in(&remote_process_filter_input, window, Self::on_input_event), + cx.subscribe_in(&remote_port_filter_input, window, Self::on_input_event), + cx.subscribe_in(&connection_filter_input, window, Self::on_input_event), + cx.subscribe_in(&command_history_filter_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), @@ -538,11 +650,15 @@ impl Ashell { window, Self::on_input_event, ), + cx.observe_window_bounds(window, Self::on_window_bounds_changed), + cx.on_app_quit(Self::save_layout_on_app_quit), ]; let (events_tx, events_rx) = mpsc::channel(); let workspace_panels = cx.new(|_| ResizableState::default()); let body_panels = cx.new(|_| ResizableState::default()); + let sftp_tree_panels = cx.new(|_| ResizableState::default()); + let sftp_file_columns = cx.new(|_| ResizableState::default()); let mut system_sampler = SystemSampler::new(); let system = system_sampler.sample(); let default_light_theme_name = ThemeRegistry::global(cx).default_light_theme().name.clone(); @@ -577,6 +693,18 @@ impl Ashell { } rust_i18n::set_locale(&active_locale); gpui_component::set_locale(&active_locale); + connection_filter_input.update(cx, |input, cx| { + input.set_placeholder(t!("filter_connections").to_string(), window, cx); + }); + command_history_filter_input.update(cx, |input, cx| { + input.set_placeholder(t!("filter_command_history").to_string(), window, cx); + }); + remote_process_filter_input.update(cx, |input, cx| { + input.set_placeholder(t!("filter_processes").to_string(), window, cx); + }); + remote_port_filter_input.update(cx, |input, cx| { + input.set_placeholder(t!("filter_ports").to_string(), window, cx); + }); 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 @@ -596,6 +724,7 @@ impl Ashell { passphrase_input, baud_rate_input, session_protocol: "ssh".to_string(), + ssh_terminal_encoding: TextEncoding::Utf8, ssh_proxy_type: "none".to_string(), proxy_host_input, proxy_port_input, @@ -620,6 +749,12 @@ impl Ashell { sync_in_progress: false, sync_status: t!("sync_not_run").into(), sftp_path_input, + remote_process_filter_input, + remote_port_filter_input, + connection_filter_input, + command_history_filter_input, + selected_connection_ids: HashSet::new(), + selected_command_history: HashSet::new(), ssh_auth_method: AuthMethod::Password, ssh_config_entries: crate::session::ssh_config::parse_ssh_config().unwrap_or_default(), ssh_config_selected: None, @@ -644,17 +779,30 @@ impl Ashell { selector_selection: 0, workspace_panels, body_panels, + sftp_tree_panels, + sftp_file_columns, is_layout_reset: false, terminal_scrollbars: HashMap::new(), remote_files_scroll_handle: UniformListScrollHandle::new(), + remote_files_horizontal_scroll_handle: gpui::ScrollHandle::new(), + remote_files_columns_viewport_width: None, + remote_tree_scroll_handle: UniformListScrollHandle::new(), disk_scroll_handle: gpui::ScrollHandle::new(), + process_scroll_handle: gpui::ScrollHandle::new(), + port_scroll_handle: UniformListScrollHandle::new(), tabs_scroll_handle: gpui::ScrollHandle::new(), selector_scroll_handle: gpui::ScrollHandle::new(), saved_scroll_handle: gpui::ScrollHandle::new(), + command_history_scroll_handle: gpui::ScrollHandle::new(), connection_scroll_handle: gpui::ScrollHandle::new(), + saved_sessions_overflowing: false, connection_progress: None, pending_sftp_path_sync: Some("/".into()), sftp_context_menu: None, + sftp_rename_input, + sftp_rename_state: None, + sftp_editor_input, + sftp_editor_state: None, sftp_creating_folder: false, sftp_new_folder_input, sftp_delete_scroll_handle: gpui::ScrollHandle::new(), @@ -674,7 +822,19 @@ impl Ashell { transfers }, show_transfers_dialog: false, + show_command_history: false, + ssh_command_buffers: HashMap::new(), + ssh_command_starts: HashMap::new(), system_status: None, + server_monitor_view: ServerMonitorView::default(), + remote_processes: Vec::new(), + remote_process_status: None, + remote_processes_in_flight: false, + expanded_process_pid: None, + remote_ports: Vec::new(), + remote_ports_status: None, + remote_ports_in_flight: false, + terminating_processes: HashSet::new(), terminal_bounds: HashMap::new(), terminal_selecting: false, terminal_marked_text: None, @@ -720,12 +880,14 @@ impl Ashell { hovered_url: None, cmd_ctrl_pressed: false, _subscriptions, - save_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())), + last_window_bounds: Some(window.window_bounds()), + window_bounds_save_task: None, + save_lock: std::sync::Arc::new(std::sync::Mutex::new(())), save_latest_seq: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), }; this.apply_theme_preferences(window, cx); - // this.open_local(cx); + this.restore_saved_tabs(window, cx); this.start_event_pump(cx); this } @@ -843,7 +1005,7 @@ 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.search_input.read(cx).text() != self.search_query { self.perform_search(window, cx); } else { @@ -864,11 +1026,14 @@ impl Ashell { let save_lock = self.save_lock.clone(); self.runtime.spawn(async move { - let _guard = save_lock.lock().await; - if current_seq < latest_seq.load(std::sync::atomic::Ordering::SeqCst) { - return; - } let _ = tokio::task::spawn_blocking(move || { + let Ok(_guard) = save_lock.lock() else { + tracing::error!("failed to lock preferences save state"); + return; + }; + if current_seq < latest_seq.load(std::sync::atomic::Ordering::SeqCst) { + return; + } if let Err(err) = config_store.save_merged_preferences(local_config) { tracing::error!("failed to save merged preferences in background: {err:#}"); } @@ -886,7 +1051,7 @@ impl Ashell { .await; if this .update(cx, |this, cx| { - let changed = this.drain_backend_events(); + let changed = this.drain_backend_events(cx); let system_sampled = this.sample_system_if_due(); this.sync_theme_if_due(cx); let is_blinking = matches!( @@ -914,21 +1079,23 @@ impl Ashell { .detach(); } - pub(crate) fn drain_backend_events(&mut self) -> bool { + pub(crate) fn drain_backend_events(&mut self, cx: &mut Context) -> bool { let mut changed = false; let mut transfers_changed = false; while let Ok(event) = self.events_rx.try_recv() { + let Some(event) = event.into_current() else { + continue; + }; changed = true; match event { + BackendEvent::Guarded { .. } => unreachable!("guarded events are unwrapped above"), BackendEvent::Output { tab_id, bytes } => { if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { - tab.backend_initialized = true; tab.feed(&bytes); } } BackendEvent::Status { tab_id, text } => { if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { - tab.backend_initialized = true; tab.status = text.clone(); } if let Some(progress) = self.connection_progress.as_mut() { @@ -943,12 +1110,23 @@ impl Ashell { } BackendEvent::Connected { tab_id } => { if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { - tab.backend_initialized = true; tab.connected = true; tab.disconnected_reason = None; } + self.sync_sftp_path_from_terminal_title(&tab_id, cx); self.sync_system_tab_to_active_group(); + if self.system_tab_id.as_deref() == Some(tab_id.as_str()) { + self.system_status = None; + self.remote_process_status = None; + self.remote_ports_status = None; + } self.request_active_system_snapshot(); + if self.active_dialog == Some(DialogKind::Processes) { + self.request_active_process_snapshot(); + } + if self.active_dialog == Some(DialogKind::Ports) { + self.request_active_port_snapshot(); + } if self .connection_progress .as_ref() @@ -962,16 +1140,50 @@ impl Ashell { path, entries, } => { - if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) { + let mut active_path = None; + if let Some(group) = self + .tab_groups + .iter_mut() + .find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str())) + { if let Some(sftp) = group.sftp.as_mut() { - sftp.current_path = path; - sftp.entries = entries; - self.pending_sftp_path_sync = Some(sftp.current_path.clone()); + let normalized_path = + crate::sftp::normalize_remote_path(&path, "/", &sftp.home_dir); + let is_current = sftp.current_path == normalized_path; + sftp.apply_directory_entries(normalized_path, entries); + if is_current && self.active_group.as_deref() == Some(group.id.as_str()) + { + active_path = Some(sftp.current_path.clone()); + } + } + } + if let Some(path) = active_path { + self.pending_sftp_path_sync = Some(path); + } + } + BackendEvent::SftpDirectoryFailed { + tab_id, + path, + reason, + } => { + if let Some(group) = self + .tab_groups + .iter_mut() + .find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str())) + { + if let Some(sftp) = group.sftp.as_mut() { + let normalized_path = + crate::sftp::normalize_remote_path(&path, "/", &sftp.home_dir); + sftp.apply_directory_error(normalized_path, reason); } } } BackendEvent::SftpPreview { tab_id, preview } => { - if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) { + if let Some(group) = self + .tab_groups + .iter_mut() + .find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str())) + { if let Some(sftp) = group.sftp.as_mut() { sftp.selected_path = Some(preview.path.clone()); sftp.preview = Some(preview); @@ -979,18 +1191,26 @@ impl Ashell { } } BackendEvent::SftpStatus { tab_id, text } => { - if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) { - if let Some(sftp) = group.sftp.as_mut() { - sftp.status = text.clone(); - } - } - if self.active_group.as_ref() == Some(&tab_id) { + let group_id = self + .tab_groups + .iter_mut() + .find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str())) + .map(|group| { + if let Some(sftp) = group.sftp.as_mut() { + sftp.status = text.clone(); + } + group.id.clone() + }); + if group_id + .as_ref() + .is_some_and(|group_id| self.active_group.as_ref() == Some(group_id)) + { self.status = text.into(); } } BackendEvent::RemoteSystem { tab_id, snapshot } => { - self.remote_sample_in_flight = false; - if self.system_tab_id.as_deref() == Some(tab_id.as_str()) { + if self.is_connected_system_tab(&tab_id) { + self.remote_sample_in_flight = false; self.system_status = None; self.system = snapshot.clone(); self.cpu_history.push(snapshot.cpu_percent); @@ -1008,29 +1228,71 @@ impl Ashell { } } BackendEvent::RemoteSystemUnavailable { tab_id, reason } => { - self.remote_sample_in_flight = false; - if self.system_tab_id.as_deref() == Some(tab_id.as_str()) { + if self.is_connected_system_tab(&tab_id) { + self.remote_sample_in_flight = false; self.system_status = Some(reason.clone().into()); self.status = reason.into(); } } - BackendEvent::Closed { tab_id, reason } => { - self.remote_sample_in_flight = false; - let is_stale = self - .tabs - .iter() - .find(|t| t.id == tab_id) - .is_some_and(|tab| { - // After retry_disconnected_tab, the old backend's threads - // may still send Closed events. Skip those — they arrive - // before the new backend sends its first Output/Connected. - // Once backend_initialized is set, any Closed is from the - // current backend and should be processed. - tab.backend_generation > 0 && !tab.backend_initialized - }); - if is_stale { - continue; + BackendEvent::RemoteProcesses { tab_id, processes } => { + if self.is_connected_system_tab(&tab_id) { + self.remote_processes_in_flight = false; + self.remote_process_status = None; + self.remote_processes = processes; + self.sort_remote_processes(); } + } + BackendEvent::RemoteProcessesUnavailable { tab_id, reason } => { + if self.is_connected_system_tab(&tab_id) { + self.remote_processes_in_flight = false; + self.remote_processes.clear(); + self.remote_process_status = Some(reason.clone().into()); + self.status = reason.into(); + } + } + BackendEvent::RemotePorts { tab_id, ports } => { + if self.is_connected_system_tab(&tab_id) { + self.remote_ports_in_flight = false; + self.remote_ports_status = None; + self.remote_ports = ports; + self.sort_remote_ports(); + } + } + BackendEvent::RemotePortsUnavailable { tab_id, reason } => { + if self.is_connected_system_tab(&tab_id) { + self.remote_ports_in_flight = false; + self.remote_ports.clear(); + self.remote_ports_status = Some(reason.clone().into()); + self.status = reason.into(); + } + } + BackendEvent::RemoteProcessTerminated { tab_id, pid } => { + if self.is_connected_system_tab(&tab_id) { + self.terminating_processes.remove(&pid); + self.remote_processes.retain(|process| process.pid != pid); + if self.expanded_process_pid == Some(pid) { + self.expanded_process_pid = None; + } + self.remote_process_status = + Some(t!("process_terminated", pid = pid).to_string().into()); + self.status = t!("process_terminated", pid = pid).to_string().into(); + self.request_active_process_snapshot(); + } + } + BackendEvent::RemoteProcessTerminateFailed { + tab_id, + pid, + reason, + } => { + if self.is_connected_system_tab(&tab_id) { + self.terminating_processes.remove(&pid); + let message = + t!("process_terminate_failed", pid = pid, reason = reason).to_string(); + self.remote_process_status = Some(message.clone().into()); + self.status = message.into(); + } + } + BackendEvent::Closed { tab_id, reason } => { let is_graceful_exit = reason == "local shell closed" || reason == "ssh session closed"; if is_graceful_exit { @@ -1044,7 +1306,19 @@ impl Ashell { tab.disconnected_reason = Some(reason.clone()); } if self.system_tab_id.as_deref() == Some(tab_id.as_str()) { + self.system = SystemSnapshot::default(); + self.cpu_history.clear(); + self.net_rx_history.clear(); + self.net_tx_history.clear(); + self.remote_sample_in_flight = false; + self.remote_processes_in_flight = false; + self.remote_processes.clear(); + self.remote_ports_in_flight = false; + self.remote_ports.clear(); + self.terminating_processes.clear(); self.system_status = Some(reason.clone().into()); + self.remote_process_status = Some(reason.clone().into()); + self.remote_ports_status = Some(reason.clone().into()); } if let Some(progress) = self.connection_progress.as_mut() { if progress.tab_id == tab_id { @@ -1093,16 +1367,60 @@ impl Ashell { transfers_changed = true; } BackendEvent::SftpHome { tab_id, home } => { - if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) { + let home = crate::sftp::normalize_remote_path(&home, "/", "/"); + let mut group_id = None; + let mut paths_to_load = Vec::new(); + let mut sync_active_path = false; + if let Some(group) = self + .tab_groups + .iter_mut() + .find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str())) + { if let Some(sftp) = group.sftp.as_mut() { - sftp.home_dir = home; + sftp.home_dir = home.clone(); + sftp.home_dir_resolved = true; + sftp.current_path = home.clone(); + sftp.expand_to(&home); + sftp.begin_directory_load(&home); + for path in crate::sftp::remote_path_ancestors(&home) { + if path != home + && !sftp.directory_cache.contains_key(&path) + && !sftp.loading_directories.contains(&path) + { + sftp.begin_directory_load(&path); + paths_to_load.push(path); + } + } + group_id = Some(group.id.clone()); + sync_active_path = + self.active_group.as_deref() == Some(group.id.as_str()); } } + if let Some(handle) = group_id.as_ref().and_then(|id| self.sftp_handles.get(id)) + { + for path in paths_to_load { + handle.list_dir(path); + } + } + if sync_active_path { + self.pending_sftp_path_sync = Some(home); + self.sync_sftp_path_from_terminal_title(&tab_id, cx); + } } BackendEvent::TerminalTitleChanged { tab_id, title } => { - if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { + let local_path = title + .strip_prefix("ASHELL_CWD_B64:") + .and_then(crate::session::decode_local_path_title); + if let Some(path) = local_path { + self.apply_local_directory_change(&tab_id, path); + } else if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { tab.dynamic_title = title; + tab.terminal_title_received = true; } + self.sync_sftp_path_from_terminal_title(&tab_id, cx); + } + BackendEvent::LocalDirectoryChanged { tab_id, path } => { + self.apply_local_directory_change(&tab_id, path); } BackendEvent::SyncFinished(result) => { self.sync_in_progress = false; @@ -1116,6 +1434,7 @@ impl Ashell { } crate::sync::SyncResult::Downloaded { payload, etag } => { self.config.replace_sessions(payload.sessions); + self.selected_connection_ids.clear(); self.config.set_sync_etag(etag); match self.config.save() { Ok(()) => self.sync_status = t!("sync_download_complete").into(), @@ -1141,15 +1460,22 @@ impl Ashell { pub(crate) fn sample_system_if_due(&mut self) -> bool { if self.last_system_sample.elapsed() >= SystemSampler::interval() { self.last_system_sample = Instant::now(); - // Use system_tab_id (not active_tab) to decide remote vs local sampling if let Some(ref tab_id) = self.system_tab_id.clone() { - if self + let ssh_connected = self .tabs .iter() - .any(|t| t.id == *tab_id && t.kind == TabKind::Ssh && t.connected) - && self.system_status.is_none() - { - self.request_active_system_snapshot(); + .find(|tab| tab.id == *tab_id && tab.kind == TabKind::Ssh) + .map(|tab| tab.connected); + if let Some(connected) = ssh_connected { + if connected { + self.request_active_system_snapshot(); + if self.active_dialog == Some(DialogKind::Processes) { + self.request_active_process_snapshot(); + } + if self.active_dialog == Some(DialogKind::Ports) { + self.request_active_port_snapshot(); + } + } return false; } } @@ -1197,12 +1523,130 @@ impl Ashell { if self.remote_sample_in_flight { return; } - self.remote_sample_in_flight = true; if let Ok(backend) = backend.lock() { + self.remote_sample_in_flight = true; backend.send(crate::terminal::BackendCommand::SampleMetrics); } } + fn is_connected_system_tab(&self, tab_id: &str) -> bool { + self.system_tab_id.as_deref() == Some(tab_id) + && self + .tabs + .iter() + .any(|tab| tab.id == tab_id && tab.kind == TabKind::Ssh && tab.connected) + } + + pub(crate) fn request_active_process_snapshot(&mut self) { + let Some(ref tab_id) = self.system_tab_id.clone() else { + return; + }; + let Some(backend) = (|| { + let tab = self + .tabs + .iter() + .find(|tab| tab.id == *tab_id && tab.kind == TabKind::Ssh && tab.connected)?; + Some(tab.backend.clone()) + })() else { + return; + }; + if self.remote_processes_in_flight { + return; + } + if let Ok(backend) = backend.lock() { + self.remote_processes_in_flight = true; + if self.remote_processes.is_empty() { + self.remote_process_status = Some(t!("loading_processes").to_string().into()); + } + backend.send(crate::terminal::BackendCommand::SampleProcesses); + } + } + + pub(crate) fn request_active_port_snapshot(&mut self) { + let Some(ref tab_id) = self.system_tab_id.clone() else { + return; + }; + let Some(backend) = (|| { + let tab = self + .tabs + .iter() + .find(|tab| tab.id == *tab_id && tab.kind == TabKind::Ssh && tab.connected)?; + Some(tab.backend.clone()) + })() else { + return; + }; + if self.remote_ports_in_flight { + return; + } + if let Ok(backend) = backend.lock() { + self.remote_ports_in_flight = true; + if self.remote_ports.is_empty() { + self.remote_ports_status = Some(t!("loading_ports").to_string().into()); + } + backend.send(crate::terminal::BackendCommand::SamplePorts); + } + } + + fn sort_remote_ports(&mut self) { + self.remote_ports.sort_by(|a, b| { + a.port + .cmp(&b.port) + .then_with(|| a.protocol.cmp(&b.protocol)) + .then_with(|| a.address.cmp(&b.address)) + .then_with(|| a.pid.cmp(&b.pid)) + }); + } + + pub(crate) fn toggle_process_expanded(&mut self, pid: u32, cx: &mut Context) { + self.expanded_process_pid = (self.expanded_process_pid != Some(pid)).then_some(pid); + cx.notify(); + } + + fn sort_remote_processes(&mut self) { + match self.server_monitor_view { + ServerMonitorView::Cpu => self.remote_processes.sort_by(|a, b| { + b.cpu_percent + .total_cmp(&a.cpu_percent) + .then_with(|| a.pid.cmp(&b.pid)) + }), + ServerMonitorView::Memory => self.remote_processes.sort_by(|a, b| { + b.memory_bytes + .cmp(&a.memory_bytes) + .then_with(|| a.pid.cmp(&b.pid)) + }), + } + } + + pub(crate) fn terminate_remote_process( + &mut self, + tab_id: String, + pid: u32, + cx: &mut Context, + ) { + if pid <= 1 || self.terminating_processes.contains(&pid) { + return; + } + if self.system_tab_id.as_deref() != Some(tab_id.as_str()) { + return; + } + let Some(backend) = (|| { + let tab = self + .tabs + .iter() + .find(|tab| tab.id == tab_id && tab.kind == TabKind::Ssh && tab.connected)?; + Some(tab.backend.clone()) + })() else { + return; + }; + if let Ok(backend) = backend.lock() { + self.terminating_processes.insert(pid); + self.remote_process_status = + Some(t!("terminating_process", pid = pid).to_string().into()); + backend.send(crate::terminal::BackendCommand::TerminateProcess { pid }); + } + cx.notify(); + } + pub(crate) fn terminal_ime_bounds_for_range( &self, range_utf16: Range, @@ -1246,7 +1690,8 @@ impl Ashell { } for (ix, tab_id, session, tab_kind) in retry_tabs { - // Close old backend + let backend_events = self.tabs[ix].advance_backend_events(); + // Invalidate old backend events before requesting shutdown. self.tabs[ix].send_backend(crate::terminal::BackendCommand::Close); // Spawn new backend @@ -1256,21 +1701,18 @@ impl Ashell { self.runtime.handle(), tab_id.clone(), session.clone(), - self.events_tx.clone(), + backend_events.clone(), ); crate::terminal::BackendTx::Serial(b) } - crate::terminal::TabKind::Ssh => { - let b = crate::backend::ssh::spawn_ssh_terminal( - self.runtime.handle(), - tab_id.clone(), - session.clone(), - self.tabs[ix].cols, - self.tabs[ix].rows, - self.events_tx.clone(), - ); - b - } + crate::terminal::TabKind::Ssh => crate::backend::ssh::spawn_ssh_terminal( + self.runtime.handle(), + tab_id.clone(), + session.clone(), + self.tabs[ix].cols, + self.tabs[ix].rows, + backend_events.clone(), + ), _ => continue, }; @@ -1279,41 +1721,12 @@ impl Ashell { self.tabs[ix].connected = false; self.tabs[ix].status = "connecting".into(); self.tabs[ix].disconnected_reason = None; - self.tabs[ix].backend_initialized = false; + self.tabs[ix].terminal_title_received = false; - // Restart SFTP for the group containing this tab - if let Some(group) = self - .tab_groups - .iter() - .find(|g| g.pane_root.contains(&tab_id)) + if tab_kind == crate::terminal::TabKind::Ssh + && self.active_tab.as_deref() == Some(tab_id.as_str()) { - let group_id = group.id.clone(); - let group_session = self - .tabs - .iter() - .find(|t| group.pane_root.contains(&t.id) && t.session.is_some()) - .and_then(|t| t.session.clone()); - - if let Some(session) = group_session { - if session.protocol != "serial" { - if let Some(old_handle) = self.sftp_handles.remove(&group_id) { - old_handle.close(); - } - let sftp_handle = crate::sftp::spawn_sftp( - self.runtime.handle(), - group_id.clone(), - session, - self.events_tx.clone(), - ); - self.sftp_handles.insert(group_id.clone(), sftp_handle); - - if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) { - if let Some(sftp) = group.sftp.as_mut() { - sftp.status = rust_i18n::t!("sftp_connecting").to_string(); - } - } - } - } + self.restart_active_sftp(); } } @@ -1339,14 +1752,14 @@ impl Ashell { pub(crate) fn sync_cwd_from_terminal( &mut self, _window: &mut gpui::Window, - _cx: &mut Context, + cx: &mut Context, ) { let active_id = self.active_tab.clone(); let Some(active_id) = active_id else { return; }; - if let Some(tab) = self.tabs.iter().find(|t| t.id == active_id) { + let parsed = if let Some(tab) = self.tabs.iter().find(|t| t.id == active_id) { let home_dir = if let Some(group) = self .tab_groups .iter() @@ -1361,25 +1774,54 @@ impl Ashell { "/" }; - let parsed = Self::parse_path_from_title(&tab.dynamic_title, home_dir); + Self::parse_path_from_title(&tab.dynamic_title, home_dir) + } else { + None + }; - if let Some(path) = parsed { - if let Some(group) = self - .tab_groups - .iter_mut() - .find(|g| g.pane_root.contains(&active_id)) - { - if let Some(sftp) = group.sftp.as_mut() { - sftp.current_path = path.clone(); - self.pending_sftp_path_sync = Some(path.clone()); - if let Some(handle) = self.sftp_handles.get(&group.id) { - let _ = handle - .commands - .send(crate::sftp::SftpCommand::ListDir(path)); - } - } - } - } + if let Some(path) = parsed { + self.navigate_sftp(path, cx); + } + } + + fn sync_sftp_path_from_terminal_title(&mut self, tab_id: &str, cx: &mut Context) { + if self.active_tab.as_deref() != Some(tab_id) { + return; + } + + let Some(tab) = self.tabs.iter().find(|tab| tab.id == tab_id) else { + return; + }; + if tab.kind != TabKind::Ssh + || !tab.connected + || !tab.terminal_title_received + || tab.is_alternate_screen_active() + { + return; + } + let title = tab.dynamic_title.clone(); + + let Some((current_path, home_dir)) = self + .active_group + .as_ref() + .and_then(|group_id| { + self.tab_groups + .iter() + .find(|group| &group.id == group_id && group.pane_root.contains(tab_id)) + }) + .and_then(|group| group.sftp.as_ref()) + .filter(|sftp| sftp.home_dir_resolved) + .map(|sftp| (sftp.current_path.clone(), sftp.home_dir.clone())) + else { + return; + }; + + let Some(path) = Self::parse_path_from_title(&title, &home_dir) else { + return; + }; + let path = crate::sftp::normalize_remote_path(&path, ¤t_path, &home_dir); + if path != current_path { + self.navigate_sftp(path, cx); } } @@ -1403,10 +1845,10 @@ impl Ashell { } } - pub(crate) fn save_layout_state(&self, window: &mut gpui::Window, cx: &gpui::App) { + fn capture_layout_state(&mut self, window: &mut gpui::Window, cx: &gpui::App) -> bool { if self.is_layout_reset { tracing::info!("[ui] layout was reset, skipping save layout state."); - return; + return false; } let current_bounds = window.window_bounds(); let bounds = match current_bounds { @@ -1416,9 +1858,6 @@ impl Ashell { }; let size = bounds.size; if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 { - self.save_latest_seq - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let mut config = self.config.clone(); let saved_bounds = match current_bounds { gpui::WindowBounds::Fullscreen(b) => { crate::session::config::SavedWindowBounds::Fullscreen { @@ -1429,38 +1868,11 @@ impl Ashell { } } 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, + x: b.origin.x.into(), + y: b.origin.y.into(), + width: b.size.width.into(), + height: b.size.height.into(), } } gpui::WindowBounds::Windowed(b) => { @@ -1487,6 +1899,42 @@ impl Ashell { .map(|s| s.into()) .collect(); + if body_sizes.len() < 2 { + body_sizes = self.config.body_panels().cloned().unwrap_or_default(); + } + + let mut sftp_tree_sizes: Vec = self + .sftp_tree_panels + .read(cx) + .sizes() + .iter() + .map(|size| size.as_f32()) + .collect(); + if sftp_tree_sizes.len() < 2 { + sftp_tree_sizes = self.config.sftp_tree_panels().cloned().unwrap_or_default(); + } + + let current_sftp_file_column_sizes: Vec = self + .sftp_file_columns + .read(cx) + .sizes() + .iter() + .map(|size| size.as_f32()) + .collect(); + let mut sftp_file_column_sizes = self + .config + .sftp_file_columns() + .filter(|sizes| sizes.len() >= 3) + .cloned() + .unwrap_or_else(|| vec![200., 64., 128.]); + for (index, size) in current_sftp_file_column_sizes + .into_iter() + .take(3) + .enumerate() + { + sftp_file_column_sizes[index] = size; + } + if self.sftp_panel_minimized { if let Some(prev) = self.prev_monitoring_size { if body_sizes.len() > 1 { @@ -1495,15 +1943,85 @@ impl Ashell { } } - 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(); + let body_sizes = (!body_sizes.is_empty()).then_some(body_sizes); + let sftp_tree_sizes = (!sftp_tree_sizes.is_empty()).then_some(sftp_tree_sizes); + let sftp_file_column_sizes = + (!sftp_file_column_sizes.is_empty()).then_some(sftp_file_column_sizes); + self.config + .set_layout_state(Some(saved_bounds), Some(workspace_sizes), body_sizes); + self.config.set_sftp_tree_panels(sftp_tree_sizes); + self.config.set_sftp_file_columns(sftp_file_column_sizes); + self.config.set_sidebar_collapsed(self.sidebar_collapsed); + self.config + .set_sftp_panel_minimized(self.sftp_panel_minimized); + true } else { tracing::warn!( "[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.", size ); + false } } + + pub(crate) fn save_layout_state(&mut self, window: &mut gpui::Window, cx: &gpui::App) { + let should_save_tabs = self.config.remember_tabs(); + if should_save_tabs { + self.capture_tabs_state(); + } + if self.capture_layout_state(window, cx) || should_save_tabs { + let current_seq = self + .save_latest_seq + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1; + let Ok(_guard) = self.save_lock.lock() else { + tracing::error!("failed to lock window layout save state"); + return; + }; + if current_seq + < self + .save_latest_seq + .load(std::sync::atomic::Ordering::SeqCst) + { + return; + } + if let Err(err) = self.config.save() { + tracing::error!("failed to save window layout state: {err:#}"); + } + } + } + + fn on_window_bounds_changed(&mut self, window: &mut Window, cx: &mut Context) { + let current_bounds = window.window_bounds(); + + // Keep a reset effective until the first actual bounds change. GPUI may + // notify observers during a redraw without changing the window geometry. + if self.is_layout_reset && self.last_window_bounds != Some(current_bounds) { + self.is_layout_reset = false; + } + + if self.last_window_bounds == Some(current_bounds) { + return; + } + self.last_window_bounds = Some(current_bounds); + + self.window_bounds_save_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + let _ = this.update_in(cx, |this, window, cx| { + if this.capture_layout_state(window, cx) { + this.save_preferences_background(); + } + }); + })); + } + + fn save_layout_on_app_quit(&mut self, cx: &mut Context) -> gpui::Task<()> { + let entity_id = cx.entity_id(); + let _ = cx.with_window(entity_id, |window, cx| { + self.save_layout_state(window, cx); + }); + gpui::Task::ready(()) + } } diff --git a/src/app/resizable/mod.rs b/src/app/resizable/mod.rs index e7d5739..09aa186 100644 --- a/src/app/resizable/mod.rs +++ b/src/app/resizable/mod.rs @@ -131,6 +131,7 @@ impl ResizableState { &mut self, axis: Axis, panels_count: usize, + fit_to_container: bool, cx: &mut Context, ) { let mut changed = self.axis != axis; @@ -150,7 +151,7 @@ impl ResizableState { changed = true; } - if changed { + if changed && fit_to_container { // We need to make sure the total size is in line with the container size. self.adjust_to_container_size(cx); } @@ -299,6 +300,29 @@ impl ResizableState { cx.notify(); } + pub(crate) fn resize_panel_independently( + &mut self, + ix: usize, + size: Pixels, + _: &mut Window, + cx: &mut Context, + ) { + if ix >= self.sizes.len() { + return; + } + + self.sync_real_panel_sizes(cx); + let size_range = self.panel_size_range(ix); + let new_size = size.clamp(size_range.start, size_range.end); + if new_size == self.sizes[ix] { + return; + } + + self.sizes[ix] = new_size; + self.panels[ix].size = Some(new_size); + cx.notify(); + } + pub(crate) fn adjust_to_container_size(&mut self, cx: &mut Context) { if self.container_size().is_zero() { return; diff --git a/src/app/resizable/panel.rs b/src/app/resizable/panel.rs index 90ba0ae..bf959f4 100644 --- a/src/app/resizable/panel.rs +++ b/src/app/resizable/panel.rs @@ -14,6 +14,8 @@ use gpui_component::{AxisExt, ElementExt, StyledExt, h_flex, v_flex}; use super::{PANEL_MIN_SIZE, ResizablePanelEvent, ResizableState, resizable_panel, resize_handle}; +type ResizeCallback = Rc, &mut Window, &mut App)>; + #[derive(Clone)] pub(crate) struct DragPanel; impl Render for DragPanel { @@ -30,8 +32,9 @@ pub struct ResizablePanelGroup { axis: Axis, size: Option, children: Vec, - on_resize: Rc, &mut Window, &mut App)>, + on_resize: ResizeCallback, locked: bool, + independent_resize: bool, } impl ResizablePanelGroup { @@ -45,6 +48,7 @@ impl ResizablePanelGroup { size: None, on_resize: Rc::new(|_, _, _| {}), locked: false, + independent_resize: false, } } @@ -68,6 +72,13 @@ impl ResizablePanelGroup { self } + /// Resize each panel without redistributing space among its siblings. + /// This lets the total group size grow and adds a trailing handle to the last panel. + pub fn independent_resize(mut self) -> Self { + self.independent_resize = true; + self + } + /// Add a panel to the group. /// /// - The `axis` will be set to the same axis as the group. @@ -141,7 +152,7 @@ impl RenderOnce for ResizablePanelGroup { // Sync panels to the state let panels_count = self.children.len(); state.update(cx, |state, cx| { - state.sync_panels_count(self.axis, panels_count, cx); + state.sync_panels_count(self.axis, panels_count, !self.independent_resize, cx); }); container @@ -156,6 +167,8 @@ impl RenderOnce for ResizablePanelGroup { panel.axis = self.axis; panel.state = Some(state.clone()); panel.locked = self.locked; + panel.trailing_resize_handle = + self.independent_resize && ix + 1 == panels_count; panel }), ) @@ -168,7 +181,7 @@ impl RenderOnce for ResizablePanelGroup { state.bounds = bounds; - if size_changed { + if size_changed && !self.independent_resize { state.adjust_to_container_size(cx); } }) @@ -178,6 +191,7 @@ impl RenderOnce for ResizablePanelGroup { state: state.clone(), axis: self.axis, on_resize: self.on_resize.clone(), + independent_resize: self.independent_resize, }) } } @@ -196,6 +210,7 @@ pub struct ResizablePanel { visible: bool, style: StyleRefinement, locked: bool, + trailing_resize_handle: bool, } impl ResizablePanel { @@ -211,6 +226,7 @@ impl ResizablePanel { visible: true, style: StyleRefinement::default(), locked: false, + trailing_resize_handle: false, } } @@ -262,6 +278,8 @@ impl RenderOnce for ResizablePanel { .get(self.panel_ix) .expect("BUG: The `index` of ResizablePanel should be one of in `state`."); let size_range = self.size_range.clone(); + let leading_handle_state = state.clone(); + let trailing_handle_state = state.clone(); div() .id(("resizable-panel", self.panel_ix)) @@ -305,8 +323,27 @@ impl RenderOnce for ResizablePanel { } else { handle.on_drag(DragPanel, move |drag_panel, _, _, cx| { cx.stop_propagation(); - state.update(cx, |state, _| { + leading_handle_state.update(cx, |state, cx| { state.resizing_panel_ix = Some(ix); + cx.notify(); + }); + cx.new(|_| drag_panel.deref().clone()) + }) + }; + this.child(handle) + }) + .when(self.trailing_resize_handle, |this| { + let ix = self.panel_ix; + let handle = resize_handle(("resizable-handle", ix), self.axis) + .placement(gpui_component::dock::DockPlacement::Left); + let handle = if self.locked { + handle + } else { + handle.on_drag(DragPanel, move |drag_panel, _, _, cx| { + cx.stop_propagation(); + trailing_handle_state.update(cx, |state, cx| { + state.resizing_panel_ix = Some(ix); + cx.notify(); }); cx.new(|_| drag_panel.deref().clone()) }) @@ -318,8 +355,9 @@ impl RenderOnce for ResizablePanel { struct ResizePanelGroupElement { state: Entity, - on_resize: Rc, &mut Window, &mut App)>, + on_resize: ResizeCallback, axis: Axis, + independent_resize: bool, } impl IntoElement for ResizePanelGroupElement { @@ -361,7 +399,6 @@ impl Element for ResizePanelGroupElement { _window: &mut Window, _cx: &mut App, ) -> Self::PrepaintState { - () } fn paint( @@ -372,34 +409,31 @@ impl Element for ResizePanelGroupElement { _: &mut Self::RequestLayoutState, _: &mut Self::PrepaintState, window: &mut Window, - cx: &mut App, + _cx: &mut App, ) { window.on_mouse_event({ let state = self.state.clone(); let axis = self.axis; - let current_ix = state.read(cx).resizing_panel_ix; + let independent_resize = self.independent_resize; move |e: &MouseMoveEvent, phase, window, cx| { if !phase.bubble() { return; } - let Some(ix) = current_ix else { return }; + let Some(ix) = state.read(cx).resizing_panel_ix else { + return; + }; state.update(cx, |state, cx| { let panel = state.panels.get(ix).expect("BUG: invalid panel index"); - match axis { - Axis::Horizontal => state.resize_panel_at_handle( - ix, - e.position.x - panel.bounds.left(), - window, - cx, - ), - Axis::Vertical => state.resize_panel_at_handle( - ix, - e.position.y - panel.bounds.top(), - window, - cx, - ), + let size = match axis { + Axis::Horizontal => e.position.x - panel.bounds.left(), + Axis::Vertical => e.position.y - panel.bounds.top(), + }; + if independent_resize { + state.resize_panel_independently(ix, size, window, cx); + } else { + state.resize_panel_at_handle(ix, size, window, cx); } cx.notify(); }) @@ -409,10 +443,9 @@ impl Element for ResizePanelGroupElement { // When any mouse up, stop dragging window.on_mouse_event({ let state = self.state.clone(); - let current_ix = state.read(cx).resizing_panel_ix; let on_resize = self.on_resize.clone(); move |_: &MouseUpEvent, phase, window, cx| { - if current_ix.is_none() { + if state.read(cx).resizing_panel_ix.is_none() { return; } if phase.bubble() { diff --git a/src/app/resizable/resize_handle.rs b/src/app/resizable/resize_handle.rs index 1289f08..4a49ef5 100644 --- a/src/app/resizable/resize_handle.rs +++ b/src/app/resizable/resize_handle.rs @@ -11,6 +11,8 @@ use gpui_component::{ActiveTheme as _, AxisExt as _, dock::DockPlacement}; pub(crate) const HANDLE_PADDING: Pixels = px(4.); pub(crate) const HANDLE_SIZE: Pixels = px(1.); +type DragCallback = Rc, &mut Window, &mut App) -> Entity>; + /// Create a resize handle for a resizable panel. pub(crate) fn resize_handle( id: impl Into, @@ -24,7 +26,7 @@ pub(crate) struct ResizeHandle { axis: Axis, drag_value: Option>, placement: Option, - on_drag: Option, &mut Window, &mut App) -> Entity>>, + on_drag: Option>, } impl ResizeHandle { diff --git a/src/app/search.rs b/src/app/search.rs index ca317db..8088124 100644 --- a/src/app/search.rs +++ b/src/app/search.rs @@ -6,13 +6,11 @@ use gpui::{ }; use gpui_component::{ ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _, - button::{Button, ButtonVariants as _}, - h_flex, - input::Input, + button::ButtonVariants as _, h_flex, input::Input, }; use rust_i18n::t; -use crate::Ashell; +use crate::{Ashell, app::controls::pointer_button}; impl Ashell { pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context) { @@ -272,9 +270,7 @@ impl Ashell { let tab = tab.or_else(|| self.tabs.first()); - let Some(tab) = tab else { - return None; - }; + let tab = tab?; let snapshot = tab.render_snapshot(false); let display_offset = snapshot.display_offset as i32; let rows = snapshot.rows as i32; @@ -327,7 +323,7 @@ impl Ashell { pub(crate) fn render_search_button(&self, cx: &mut Context) -> impl gpui::IntoElement { // Wrap in a div so .hover() doesn't conflict with Button's internal hover. div().child( - Button::new("search-btn") + pointer_button("search-btn") .ghost() .small() .rounded(px(999.)) @@ -362,7 +358,7 @@ impl Ashell { .top(px(8.)) .right(px(24.)) .on_prepaint(move |bounds, _window, cx| { - let _ = view.update(cx, |this, _| { + view.update(cx, |this, _| { this.search_bar_bounds = Some(bounds); }); }) @@ -407,7 +403,7 @@ impl Ashell { ) }) .child( - Button::new("search-prev") + pointer_button("search-prev") .ghost() .xsmall() .icon(IconName::ChevronUp) @@ -417,7 +413,7 @@ impl Ashell { })), ) .child( - Button::new("search-next") + pointer_button("search-next") .ghost() .xsmall() .icon(IconName::ChevronDown) @@ -427,7 +423,7 @@ impl Ashell { })), ) .child( - Button::new("search-close") + pointer_button("search-close") .ghost() .xsmall() .icon(IconName::Close) diff --git a/src/app/startup.rs b/src/app/startup.rs index e905898..6f029a7 100644 --- a/src/app/startup.rs +++ b/src/app/startup.rs @@ -2,7 +2,75 @@ use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size}; use gpui_component::Root; use crate::Ashell; -use crate::session::config::ConfigStore; +use crate::session::config::{ConfigStore, SavedWindowBounds}; + +fn clamp_saved_window_bounds( + x: f32, + y: f32, + width: f32, + height: f32, + cx: &App, +) -> Bounds { + let displays = cx.displays(); + let Some(display) = displays.iter().max_by(|left, right| { + let intersection_area = |display: &std::rc::Rc| { + let visible = display.visible_bounds(); + let left_edge = x.max(visible.origin.x.as_f32()); + let top_edge = y.max(visible.origin.y.as_f32()); + let right_edge = + (x + width).min(visible.origin.x.as_f32() + visible.size.width.as_f32()); + let bottom_edge = + (y + height).min(visible.origin.y.as_f32() + visible.size.height.as_f32()); + (right_edge - left_edge).max(0.0) * (bottom_edge - top_edge).max(0.0) + }; + intersection_area(left).total_cmp(&intersection_area(right)) + }) else { + return Bounds::new(point(px(x), px(y)), size(px(width), px(height))); + }; + + let visible = display.visible_bounds(); + let visible_x = visible.origin.x.as_f32(); + let visible_y = visible.origin.y.as_f32(); + let visible_width = visible.size.width.as_f32(); + let visible_height = visible.size.height.as_f32(); + let width = width.max(1.0).min(visible_width); + let height = height.max(1.0).min(visible_height); + let x = if x.is_finite() { + x.clamp(visible_x, visible_x + visible_width - width) + } else { + visible_x + (visible_width - width) / 2.0 + }; + let y = if y.is_finite() { + y.clamp(visible_y, visible_y + visible_height - height) + } else { + visible_y + (visible_height - height) / 2.0 + }; + + Bounds::new(point(px(x), px(y)), size(px(width), px(height))) +} + +fn restore_window_bounds(bounds: &SavedWindowBounds, cx: &App) -> gpui::WindowBounds { + match bounds { + SavedWindowBounds::Fullscreen { + x, + y, + width, + height, + } => gpui::WindowBounds::Fullscreen(clamp_saved_window_bounds(*x, *y, *width, *height, cx)), + SavedWindowBounds::Maximized { + x, + y, + width, + height, + } => gpui::WindowBounds::Maximized(clamp_saved_window_bounds(*x, *y, *width, *height, cx)), + SavedWindowBounds::Windowed { + x, + y, + width, + height, + } => gpui::WindowBounds::Windowed(clamp_saved_window_bounds(*x, *y, *width, *height, cx)), + } +} pub(crate) fn bind_workspace_keys(cx: &mut gpui::App) { let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory()); @@ -251,35 +319,7 @@ pub(crate) fn open_main_window(cx: &mut App) { } if let Some(bounds) = config.window_bounds() { - window_options.window_bounds = Some(match bounds { - crate::session::config::SavedWindowBounds::Fullscreen { - x, - y, - width, - height, - } => gpui::WindowBounds::Fullscreen(Bounds::new( - point(px(*x), px(*y)), - size(px(*width), px(*height)), - )), - crate::session::config::SavedWindowBounds::Maximized { - x, - y, - width, - height, - } => gpui::WindowBounds::Maximized(Bounds::new( - point(px(*x), px(*y)), - size(px(*width), px(*height)), - )), - crate::session::config::SavedWindowBounds::Windowed { - x, - y, - width, - height, - } => gpui::WindowBounds::Windowed(Bounds::new( - point(px(*x), px(*y)), - size(px(*width), px(*height)), - )), - }); + window_options.window_bounds = Some(restore_window_bounds(bounds, cx)); } else if let Some(display) = cx.displays().first().cloned() { let display_bounds = display.bounds(); let width = display_bounds.size.width * 0.8; @@ -317,7 +357,9 @@ pub(crate) fn open_main_window(cx: &mut App) { ); return true; } - view_clone.read(cx).save_layout_state(window, cx); + view_clone.update(cx, |this, cx| { + this.save_layout_state(window, cx); + }); true }); diff --git a/src/app/theme.rs b/src/app/theme.rs index 0dde1b8..f72c824 100644 --- a/src/app/theme.rs +++ b/src/app/theme.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, Result}; use gpui::{App, Context, SharedString, Window, px}; use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry}; +use rust_i18n::t; use crate::Ashell; @@ -126,6 +127,18 @@ impl Ashell { } rust_i18n::set_locale(&active_locale); gpui_component::set_locale(&active_locale); + self.connection_filter_input.update(cx, |input, cx| { + input.set_placeholder(t!("filter_connections").to_string(), window, cx); + }); + self.command_history_filter_input.update(cx, |input, cx| { + input.set_placeholder(t!("filter_command_history").to_string(), window, cx); + }); + self.remote_process_filter_input.update(cx, |input, cx| { + input.set_placeholder(t!("filter_processes").to_string(), window, cx); + }); + self.remote_port_filter_input.update(cx, |input, cx| { + input.set_placeholder(t!("filter_ports").to_string(), window, cx); + }); self.save_preferences_background(); window.refresh(); cx.notify(); diff --git a/src/main.rs b/src/main.rs index 2fceb39..b5c8fbc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod sftp; mod sync; mod system; mod terminal; +mod text_encoding; rust_i18n::i18n!("locales", fallback = "en"); @@ -17,8 +18,8 @@ gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]); pub(crate) use app::keybinding_recorder::{ ClosePane, Copy, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch, - OpenSession, OpenSettings, OpenTransfers, Paste, SplitPaneDown, SplitPaneLeft, SplitPaneRight, - SplitPaneUp, ToggleSftpZoom, ToggleSidebar, + OpenSession, OpenSettings, OpenTransfers, Paste, QuitApplication, SplitPaneDown, SplitPaneLeft, + SplitPaneRight, SplitPaneUp, ToggleSftpZoom, ToggleSidebar, }; pub(crate) use app::{Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup}; @@ -42,6 +43,8 @@ fn main() { }); app.run(move |cx| { gpui_component::init(cx); + cx.on_action(|_: &QuitApplication, cx| cx.quit()); + cx.bind_keys([ KeyBinding::new( "tab",