拆分应用状态与布局基础 (#106)

* feat: 拆分终端与会话基础能力

* feat: 拆分应用状态与布局基础
This commit is contained in:
Realm
2026-08-22 21:54:56 +08:00
committed by GitHub
parent 350dcbc5b4
commit f6816da18b
13 changed files with 2075 additions and 290 deletions
+112 -23
View File
@@ -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"
+113 -26
View File
@@ -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: "代理用户名"
+839 -3
View File
@@ -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<AuthMethod> {
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<ImportedCsvConnection> {
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::<u16>()
.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<Vec<ImportedCsvConnection>> {
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::<Result<Vec<_>>>()?;
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::<ConnectionCsvRow>(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<Vec<u8>> {
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<ImportedCsvConnection>,
) -> Vec<ImportedCsvConnection> {
let mut positions = HashMap::<ConnectionIdentity, usize>::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<Session>,
imported_connections: Vec<ImportedCsvConnection>,
) -> 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<InputState>, cx: &Context<Self>) -> 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::<crate::session::config::ConfigFile>(&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<Self>) {
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::<Vec<_>>();
let skipped = candidates
.iter()
.filter(|session| !is_ssh_session(session))
.count();
let sessions = candidates
.into_iter()
.filter(is_ssh_session)
.collect::<Vec<_>>();
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<Session>,
window: &mut gpui::Window,
cx: &mut Context<Self>,
) {
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<Self>) {
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");
}
}
+95
View File
@@ -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<ElementId>) -> Button {
Button::new(id).cursor_pointer()
}
pub(crate) fn pointer_checkbox(id: impl Into<ElementId>) -> Checkbox {
Checkbox::new(id).cursor_pointer()
}
pub(crate) fn pointer_switch(id: impl Into<ElementId>) -> Switch {
Switch::new(id).cursor_pointer()
}
#[derive(IntoElement)]
pub(crate) struct PointerClipboard {
id: ElementId,
value: SharedString,
tooltip: Option<SharedString>,
}
impl PointerClipboard {
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
value: SharedString::default(),
tooltip: None,
}
}
pub(crate) fn value(mut self, value: impl Into<SharedString>) -> Self {
self.value = value.into();
self
}
pub(crate) fn tooltip(mut self, tooltip: impl Into<SharedString>) -> 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,
}
+52 -5
View File
@@ -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<String> {
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();
+680 -162
View File
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -131,6 +131,7 @@ impl ResizableState {
&mut self,
axis: Axis,
panels_count: usize,
fit_to_container: bool,
cx: &mut Context<Self>,
) {
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<Self>,
) {
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<Self>) {
if self.container_size().is_zero() {
return;
+57 -24
View File
@@ -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<dyn Fn(&Entity<ResizableState>, &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<Pixels>,
children: Vec<ResizablePanel>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &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<ResizableState>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &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() {
+3 -1
View File
@@ -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<E> = Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>;
/// Create a resize handle for a resizable panel.
pub(crate) fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
@@ -24,7 +26,7 @@ pub(crate) struct ResizeHandle<T: 'static, E: 'static + Render> {
axis: Axis,
drag_value: Option<Rc<T>>,
placement: Option<DockPlacement>,
on_drag: Option<Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>>,
on_drag: Option<DragCallback<E>>,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
+8 -12
View File
@@ -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<Self>) {
@@ -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<Self>) -> 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)
+73 -31
View File
@@ -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<gpui::Pixels> {
let displays = cx.displays();
let Some(display) = displays.iter().max_by(|left, right| {
let intersection_area = |display: &std::rc::Rc<dyn gpui::PlatformDisplay>| {
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
});
+13
View File
@@ -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();
+5 -2
View File
@@ -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",