mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 08:01:00 +00:00
+112
-23
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user