Compare commits

...
Author SHA1 Message Date
okooo bcc10cc55f 终端粘贴截图:写成文件并把路径打进输入行
本地终端和已保存主机的远程终端里,剪贴板只有图、没有文字时,⌘V / Ctrl+Shift+V 或右键粘贴不再丢掉图片。图存成 PNG 等文件后,把路径当作文字粘进输入行,给终端里的 Agent 读。

本地写到系统临时目录 shellrs-pastes/(目录 0700、文件 0600),超过 20 个或超过 7 天的旧文件会清掉。已保存的主机另开一条 SFTP 把文件写到远端 /tmp/shellrs-paste-….png,连上后再粘路径;登录要问答就直接失败,不弹框。超过 8 MB 拒绝。外部连接和临时连接提示不支持。剪贴板里有文字时仍粘文字。
2026-10-11 16:19:01 +08:00
okooo 552d55777d release workflow 暂时只打 Windows 包
macOS 和 Linux 的 job 关掉,另行通知后再打开。
2026-10-11 09:59:39 +08:00
okooo 6674a47662 快捷键允许 Shift 加 Insert、Delete、Home、End、Page Up、Page Down
这些键不挡打字,终端粘贴可以录成 Shift+Insert。单独按仍给终端和列表。
2026-10-11 09:57:07 +08:00
XuzandClaude Opus 5.5 9799f88ade 版本号改为 0.1.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:22:26 +08:00
XuzandClaude Opus 5.5 dc5adddd06 启动后不久检查更新时,下好的新版本在重启时找不到
更新程序启动 30 秒后清理旧的暂存,原来会删掉所有 .ShellRS.app.update-*(Linux 是
.<AppImage>.update-*),包括启动后手动检查、切换渠道刚下载暂存好的新版本,于是「重启并
安装」报找不到文件,重试才从下载目录重新暂存。现在清理只删不比当前版本新的暂存,和下载
目录的规则一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:21:47 +08:00
XuzandClaude Opus 5.5 db0a0faf05 发布流程:lipo -verify_arch 的参数顺序写反了,macOS 两个架构都在构建后失败
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:49:57 +08:00
XuzandClaude Opus 5.5 026e0d44d8 版本号改为 0.1.8-beta.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:34:46 +08:00
bigtan e91bab0d09 macOS 按 Apple Silicon 和 Intel 分别发布安装包
发布流程改为按 aarch64、x86_64 构建矩阵,分别签名、公证并上传 DMG 和升级 ZIP,
不再合成 universal 应用。两个任务使用独立缓存和产物名称,发布时收集全部架构。

更新清单保留原有平台键,分别记录对应架构包的地址、大小和 SHA-256。
已有 universal 客户端按运行架构升级;Rosetta 下的 Intel 版仍接收 Intel 包。
同步更新中英文下载说明、发布文档和示例清单,增加双架构校验与错包拒绝断言。

验证:52 项更新模块测试通过,Clippy、格式、YAML 和 shell 语法检查通过。
2026-10-10 22:33:22 +08:00
XuzandClaude Opus 5.5 5ca6e53366 设置 › 终端新增「终端交互」:选中复制、右键行为
「选中复制」打开后,鼠标选中的文字在松开时进入系统剪贴板,单击没选中东西时剪贴板不变;
默认关闭。「右键行为」可选无动作、粘贴、显示菜单,默认显示菜单;右键从不交给要求鼠标的
程序。设置存在 settings.json 的 terminal_interaction,终端经全局量 TerminalInteraction
读取,改动立即作用于已打开的终端。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:24:04 +08:00
XuzandClaude Opus 5.5 0aa1d4452d ESXi 等只在键盘交互式认证里收密码的主机,用保存的密码自动登录
ESXi 的 sshd 关了 password 方式,只开 publickey 和 keyboard-interactive。保存的密码只走
password 方式,于是测试连接报「服务器要求键盘交互式认证」,外部 CLI 和端口转发连不上,
在应用里每次连接都要手输。

现在「密码」登录遇到键盘交互式认证的第一个单独的隐藏问题,就用保存的密码(测试连接时是
对话框里填的)回答,同 WinSCP 默认的「用密码响应第一个提示」。一次登录里保存的密码只发
一次;密码之后的验证码等问题照样问人。被拒或没有密码时,不能询问的连接报缺的是密码,应用
里的询问写明保存的密码被拒。没有问题的一轮直接回空,不再弹框。外部 CLI 遇到密码以外的问题
不再让人「保存密码」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:12:19 +08:00
XuzandClaude Opus 5.5 5fdfce239a 版本号改为 0.1.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:18:22 +08:00
XuzandClaude Opus 5.5 3a9e72eab3 版本号改为 0.1.7-beta.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:47:24 +08:00
XuzandClaude Opus 5.5 27822e7315 SFTP 文件列表点列名即可排序,换目录后列宽和排序箭头不再复位
gpui-kit 只给列标题右边的箭头挂了排序,标题本身的点击用来选列,而文件列表关掉了选列,
所以只能点箭头。现在点列名和点箭头一样:降序、升序、恢复按名称。

表格每次 refresh(每读一次目录)都从委托重新取列,原来拖宽的列会弹回默认宽度,箭头也回到
「未排序」而行仍按原列排着。现在 FileListing 记下拖动后的列宽(ColumnWidthsChanged)和
当前排序,refresh 后照旧。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:46:54 +08:00
XuzandClaude Opus 5.5 e1600ce05c SFTP 本地目录列表在 Windows 上列出各个驱动器
Windows 上每个盘是一个独立的根,目录列表原来只有当前路径的各级和主目录、桌面、文稿、下载,
离不开起始的盘(只能在「打开目录/书签」里手动输入 D:\)。现在「位置」里按 WinSCP 的样子
列出所有盘符:「C: 本地磁盘」「E: 可移动磁盘」「Z: 网络驱动器」「F: CD 驱动器」。

盘符用 GetLogicalDrives 和 GetDriveTypeW 取,不读卷标。位置改在后台随每次读本地目录
一起重新取,所以新插上的 U 盘刷新一下就出现,也不在 UI 线程上碰网络驱动器。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:46:54 +08:00
XuzandClaude Opus 5.5 5be3f56b0c 版本号改为 0.1.7-beta.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:52:19 +08:00
XuzandClaude Opus 5.5 eadbe6481a SFTP 只用 WinSCP 也用的请求:修复经堡垒机上传失败、编辑器保存时连接断开
用户的堡垒机对 fsetstat 回 Failure,收到 fsync@openssh.com(即使宣称支持)就断开整个会话,
所以上传报「Failure: Failure」(.filepart 已写完整),编辑器保存时 SFTP 连接断开。现在:

- 上传关闭文件后才按路径 setstat 设修改时间和权限,不再对打开的句柄 fsetstat;
- 不再发 fsync@openssh.com;
- 覆盖已有文件也不用 posix-rename@openssh.com,一律先把旧文件改名成备份、发布后再删
  (不是原子的)。

测试服务器加了堡垒机模式(bastion_server):对 fsetstat 和所有扩展请求断开会话;新上传、
覆盖上传和编辑器保存都要在同一条连接上完成,并保留修改时间。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:51:51 +08:00
XuzandClaude Opus 5.5 ecf9d92e47 README 加上 LINUX DO 徽章
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:33:45 +08:00
XuzandClaude Opus 5.5 63d1e4281c 版本号改为 0.1.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:23:54 +08:00
XuzandClaude Opus 5.5 b834477bf4 终端加上滚动条:翻看回滚历史时出现在右侧,可拖动、点轨道跳转
用 gpui-kit 的 Scrollbar,配一个按回滚行数换算像素的 ScrollbarHandle
(terminal/scrollbar.rs)。拖动的回调没有 context,经引擎给出的
Scrollback 直接滚动终端,再由滚动条通知视图重画。滚动条盖在整个边距上,
滑块大多落在文字旁边;全屏程序没有回滚,不显示。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:23:22 +08:00
XuzandClaude Opus 5.5 65ea32acfb 终端执行的单元测试:dash 作为 sh 时允许输出里多一行 Terminated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:44:07 +08:00
XuzandClaude Opus 5.5 2df67bc639 版本号改为 0.1.6-beta.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:16:42 +08:00
XuzandClaude Opus 5.5 e09adeefb7 外部 CLI 新增 exec --terminal:在已打开的终端里执行命令
只允许登录一次的堡垒机(外部连接)上,exec 重新登录会被拒绝,另开通道又会结束会话。
--terminal 把命令打进这台主机在 ShellRS 里已打开的远程终端:先发 Ctrl-C,探测出
交互 shell(bash、dash、busybox ash)后输入命令行,经 OSC 标记取回输出、退出码和
执行上下文(--json 的 context)。不做忙碌检测,只拒绝全屏程序和同一终端已有一次运行。
外部连接上普通 exec 登录失败时,说明里提示改用 --terminal。

shells.yml 在 Docker 里对 bash 4.2–5.2、dash、busybox ash 跑真实终端测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:16:30 +08:00
XuzandClaude Opus 5.5 3c937a9be2 README 去掉界面语言的说明
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:18:41 +08:00
XuzandClaude Opus 5.5 2dd1f1995a 版本号改为 0.1.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:08:42 +08:00
XuzandClaude Opus 5.5 51bdab74f4 README 默认英文:README.md 改为英文版,中文版移到 README.zh-CN.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:07:05 +08:00
XuzandClaude Opus 5.5 4d70be98a9 分屏时右侧栏跟着焦点所在的远程终端,标题写出主机
中间区分屏后,焦点点进哪个标签它就在前,状态栏和右侧栏跟着变;点进另一半的 SFTP、
本地终端时右侧栏仍对着刚才的远程终端,屏幕上没有远程终端才隐藏。拖动合并标签组后
重新核对谁在前,修好标签被盖住后右侧栏还留着的问题。右侧栏标题加上主机名。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:59:06 +08:00
XuzandClaude Opus 5.5 77da643999 中文 README 的截图按当前界面重截
标题栏的「临时连接…」、远程终端右侧的工具按钮、Docker 的下划线标签都已不同于旧图。
用演示数据在真实窗口里截取,与英文版的六张构图一致,服务器地址打码。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:29:26 +08:00
XuzandClaude Opus 5.5 97addfbc00 英文 README 换成英文界面的截图
用改成英文名的演示数据在真实窗口里截取,与中文版的六张一一对应,
服务器地址打码;右侧栏工具拼图按中文版的版式拼。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:17:44 +08:00
XuzandClaude Opus 5.5 7b0dad9b41 Docker 的标签改用可横向滚动的下划线标签,与系统服务一致
均分宽度放不下英文的 "Containers 12"。不管什么语言,标签都按文字和数量的宽度排,
放不下时横向滚动。去掉只给 Docker 用的 count_tabs 和分段控件的计数。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:42:23 +08:00
XuzandClaude Opus 5.5 82c2c8b277 英文用户手册,README 说明界面有中英两种语言
docs/manual.en.md 按英文界面的用词翻译整本手册;README.en.md 改为链接英文手册。
两份 README 去掉「界面只有中文」的说明和路线图里的英文界面一项。
SFTP 标签菜单和地址提示里的 hostname 与其他处统一。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:30:25 +08:00
XuzandClaude Opus 5.5 d4d079579a 守卫测试扫描整个 src,用词表补充常用词
所有模块都已接入多语言,不再按目录逐步登记;扫描器也认 #[cfg(all(test, …))]。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:18:07 +08:00
XuzandClaude Opus 5.5 a0ed1ac8bf SFTP 与编辑器的文案接入多语言
explorer/、sftp/、editor/ 的文案移进 locales/explorer.yml、sftp.yml、editor.yml,中文逐字不变。
文件列表的列名、目录列表的分组跟着界面语言重设;列表占位改存枚举,拖动预览存方向,
「位置」改存 Place,显示时再翻译。动词拼句的地方(上传 / 下载、编辑器 / 预览、服务器上的 /
本机上的)拆成每种情况一个整句;废纸篓在 Windows 上写 Recycle Bin。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:12:51 +08:00
XuzandClaude Opus 5.5 eb6e43045a 英文用词统一:清屏、复制主机名
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:11:58 +08:00
XuzandClaude Opus 5.5 b7ebd893d3 英文排版:放宽详情标签列和高亮规则的通知列,转发示意图说明允许折行
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:11:30 +08:00
XuzandClaude Opus 5.5 575ca42258 实时切换测试覆盖主机树搜索框的提示
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:09:27 +08:00
XuzandClaude Opus 5.5 acd0e238d4 守卫测试覆盖右侧栏七个工具
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:07:56 +08:00
XuzandClaude Opus 5.5 5514612524 右侧栏七个工具的文案接入多语言
命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控和工作区里它们的确认、
通知改用 t! / tn!,文案在 locales/<工具>.yml 与 locales/tools.yml。容器操作的对象改成
ContainerSubject,每种操作一整句;详情里的字段存键、按键找行,切换语言时跟着变;
搜索框的占位随界面语言重设。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:07:14 +08:00
XuzandClaude Opus 5.5 70b329066f 英文措辞统一:下载页、占位示例
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:07:14 +08:00
XuzandClaude Opus 5.5 85189e4412 守卫测试覆盖终端、SSH、在线升级与外部 CLI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:06:46 +08:00
XuzandClaude Opus 5.5 da546b5351 终端与 SSH 的文案接入多语言
终端标签、右键菜单、查找条、写进终端的提示、本地 PTY 和关键字高亮的文案,
以及 SSH 登录、跳板、代理、外部 CLI exec、测试连接的报错都走 t!,
新增 locales/terminal.yml、locales/ssh.yml,中文逐字不变。
终端的默认标题和无障碍名称在用到时再生成,查找条的占位随界面语言重设;
高亮规则的校验错误改为 PatternError,显示时再翻译。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:05:47 +08:00
XuzandClaude Opus 5.5 f6286b776c 在线升级与外部 CLI 的文案接入多语言
- 在线升级:状态行、标题栏按钮、更新对话框、出错原因和「重启会关闭…」改走 t! / tn!,列表用 join_list
- 外部 CLI:命令输出(表头、字段、摘要、进度、错误)、应用回给命令的消息和校验、设置页的 Agent 名称都有中英两版
- --help 与 SKILL.md 提到的界面名称写英文并附中文
- 新增 locales/update.yml、locales/cli.yml,补英文输出的单元测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:05:37 +08:00
XuzandClaude Opus 5.5 17dc087f52 守卫测试覆盖主机、凭据、端口转发
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:05:33 +08:00
XuzandClaude Opus 5.5 1c61269c52 主机、凭据、端口转发的文案接入多语言
host/、credential/、forward/、connection.rs、secrets/ 的界面文案、校验和报错改经 t! / tn!,
放进 locales/{host,credential,forward,connection,secrets}.yml,中文逐字不变。
写库失败按改动种类各给整句(Change),钥匙串失败同理;删除说明按句拼接(join_sentences),
数量用 tn!。三个侧栏列表的搜索框占位随语言切换重设。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:03:29 +08:00
XuzandClaude Opus 5.5 352ffcef55 设置、快捷键、窗口壳的文案接入多语言,打包元数据补英文
设置页按语言实时切换(分类、搜索关键词、规则表的提示),快捷键表改存键,
冲突提示按快捷键 id 找名称;状态栏、标题栏、开始页、确认框和通知换成整句。
macOS 包声明 en / zh-Hans 两种语言并翻译本地网络说明,Linux 桌面文件补英文说明。
新增界面语言实时切换的 UI 测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:42:09 +08:00
XuzandClaude Opus 5.5 1c855f1508 多语言基础设施:rust-i18n 接入,界面语言默认跟随系统
文案放进 locales/<模块>.yml,经 crate::i18n 的 t! / tn! 取用;启动时先按设置定语言,
改设置时实时切换并发出 UiLocale。守卫测试检查两种语言齐全、占位一致、没有遗漏和死键。
UI 测试夹具固定中文并隔离本线程的语言。共用件(shared/)先转换。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:24:06 +08:00
XuzandClaude Opus 5.5 f8f7473e42 列表的悬停和选中分开:深色悬停调淡,选中行统一一种颜色
- 侧栏的主机、端口转发、凭据列表和「最近连接」的选中行同色:浅色是原来侧栏的深一档灰,深色用「最近连接」原来的选中色,不再刺眼
- 深色下列表和表格的悬停只有选中色的一半,和选中区分开
- 浅色「最近连接」的选中行原来和悬停一样,现在同侧栏

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:37:48 +08:00
Xuz 3ddc7ebe62 完善隐私描述 2026-10-07 20:13:41 +08:00
XuzandClaude Opus 5.5 baa6745c1a 默认深色改用参照微信的炭灰,不再是近黑
- ShellRS Dark 的界面和终端:底色 #1e1e1f,标题栏、标签栏、状态栏 #2f2f30,边框和悬停 #3b3b3d,文字 #e0e0e4;gpui-kit 的每级灰按对照表换掉,彩色不动
- 次要文字 #929296,比微信的亮一些,对比度保持在 4.5 以上
- 终端选区和 ANSI 黑按新底色重算;「界面跟随主题」关闭时的深色界面同样生效

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:04:38 +08:00
XuzandClaude Opus 5.5 cd3d41c496 接入 Aptabase 匿名使用统计,「设置 › 关于」新增开关
- 自己调 Aptabase 的批量接口 POST /api/v0/events(没有纯 Rust SDK)。只发版本、系统名和版本、CPU 架构、设置快照(取值固定,主机等数量只报档位)和功能使用次数;不发主机、凭据、命令、文件名、路径,也没有任何标识
- 四种事件:app_installed(首次启动,送达后记进 analytics.json)、app_started、app_active(窗口到前台且当天 UTC 还没报过)、usage(43 项功能计数,从第一次使用起满 4 小时汇总一条)
- 不用定时器:只在启动、窗口激活、用到功能、改设置时动作,满期的汇总和失败重试都等下一个信号;计数存盘,退出不等网络、不丢数据
- 内网断网不受影响:联网、系统检测、写文件都在后台线程,连接 5 秒、整体 15 秒超时,失败不提示,重试从 1 分钟起翻倍、最长 6 小时,4xx 不重试
- 「设置 › 关于」新增「隐私」组的「发送匿名使用统计」,默认开,关掉清空本机攒下的计数
- 只有发布构建发;开发构建设 SHELLRS_ANALYTICS_DEBUG=1 才发调试事件,汇总周期 1 分钟、失败 10 秒后重试
- 手册新增「隐私」一节列出全部上报内容,README 同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:04:38 +08:00
XuzandClaude Opus 5.5 56491658a6 sync 测试的辅助函数只在 Unix 上编译,Windows 的 clippy 不再报未使用
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:16:26 +08:00
XuzandClaude Opus 5.5 a957b3b5a3 版本号改为 0.1.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:12:34 +08:00
XuzandClaude Opus 5.5 7e32ac7033 外部 CLI 补齐主机与凭据管理和目录同步,命令写法对齐 termark
- 新增 shellrs hosts / credentials 的 list、show、create、update、delete,create / update 从标准输入读 JSON,show 的输出可以直接交回 update
- 校验和文案同对话框(HostDraft::validated),分组路径不存在就逐级新建;改了地址、端口或用户时保存的密码跟着搬
- 秘密只写不读:密码、口令、私钥进钥匙串或 keys/,show 只说有没有保存
- hosts delete 遇到有打开标签的主机报 host_in_use,--force 才关标签;删凭据时用它的主机改回自己登录
- 改动经服务端队列交给 UI 线程逐条执行,超时只算排队;写库和钥匙串失败报 save_failed
- 新增 shellrs sync:本地目录同步到远程,没变的文件跳过,--delete 删远程多余项(不删 .filepart 和备份,本地读不了的目录不清理)
- 去掉顶层 shellrs list,统一用 shellrs hosts list;skill、手册、README 同步更新

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:11:40 +08:00
XuzandClaude Opus 5.5 f7928fbeba 主机列表搜索也能按名称找分组
- 分组名匹配时列出这个分组和它下面的全部子分组、主机;主机仍按名称、
  地址、用户名匹配。搜索框提示改为「搜索主机或分组」。
- 回车连接的第一台匹配主机改按列表里的顺序;只因分组匹配而列出的主机
  不算。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:25:37 +08:00
XuzandClaude Opus 5.5 36293e8959 设置新增「键盘快捷键」:查看、改绑、禁用、恢复默认
- 设置左栏「关键字高亮」下面新增「键盘快捷键」,通用、标签、终端、
  编辑器四组主要功能。点按键后按下新组合即换上,Esc 放弃;每行可禁用、
  恢复默认。没有修饰键的键,以及同一处已被占用或输入框等内置功能使用
  的组合会被拒绝,并提示被谁占用。
- 新增标签切换:下一个 / 上一个标签(⌘⇧] / ⌘⇧[,其他平台 Ctrl+Tab /
  Ctrl+Shift+Tab),切换到第 1…9 个标签(⌘1…⌘9,Alt+1…9)。
- ⌘= / ⌘- / ⌘0 在终端里调终端字号并记进设置,在终端外仍缩放界面。
- 终端字号范围改为 10–28。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:16:50 +08:00
XuzandClaude Opus 5.5 3fef93ba8b 设置 › 关于加上官网链接
- 「应用更新」下面新增「ShellRS」一组,「官网」一行的按钮用浏览器打开
  shellrs.com。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:16:06 +08:00
XuzandClaude Opus 5.5 e9700d0e32 设置新增「应用 › 窗口」:记住窗口大小和位置
- 设置左栏在「关于」上面新增「应用」,「窗口」组两个开关,默认都打开:
  记住窗口大小(含最大化状态)、记住窗口位置(连同所在屏幕),下次启动
  时生效。
- 窗口移动、缩放停下后记进数据目录的 window.json,退出时再记一次。
- 恢复时那块屏幕不在了、或窗口大半在屏幕外,就放在屏幕中间;比屏幕大
  就缩到能放下。全屏不记,按退出全屏后的大小恢复。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:17:55 +08:00
XuzandClaude Opus 5.5 1bab1bff50 设置:切到别的标签再回来,停在原来的分类上
- 之前切回设置标签总是回到「外观」:gpui-kit 的 Settings 把选中的分类
  放在元素状态里,标签不显示时就丢了。现在记下正在显示的分类,回来时
  从它开始。页内的滚动位置和搜索框内容仍不保留。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:34:55 +08:00
XuzandClaude Opus 5.5 613287aedd 主题:可选让界面也按主题配色,默认保持中性
- 「外观 › 终端主题」改名「主题」,组里第一项「界面跟随主题」,默认
  关闭:界面是中性的黑白灰,只有终端按所选主题配色。
- 打开后侧栏、标签栏、对话框等也换成主题的底色和文字色,成功、警告、
  错误等状态用主题的绿、黄、红;次要文字在正文偏淡的主题里也保持可读。
  ShellRS Light / Dark 就是原来的界面。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:27:23 +08:00
XuzandClaude Opus 5.5 d791dad8ae 终端主题:浅色、深色外观各选一套,界面切深浅时终端跟着换
- 设置 › 外观新开「终端主题」组,左栏浅色、右栏深色各 10 套内置主题,
  卡片是主题名加一段 ls 示例,选中的整张加底色、右上角蓝底对勾。同一
  主题的浅、深两版在同一行,没有另一版的排最后。应用外观(含跟随系统、
  标题栏按钮)决定用哪一栏。
- 默认 ShellRS Light / Dark:背景、文字、光标、选区和原来一样,16 色
  按 Tailwind 色阶重调,浅色下黄字等不再看不清。第三方主题取自各项目
  自己的终端配色(MIT、Apache-2.0),出处写在源码里。
- 终端格子、边距、选区、链接都取自终端主题;程序查询颜色(OSC 4 / 10
  / 11 / 12)按当前主题回答,不再一律答深色背景。字体和关键字高亮的
  预览也用当前主题。
- 不做自定义主题、导入、按主机指定。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:39:22 +08:00
XuzandClaude Opus 5.5 2aa5d5be49 关键字高亮设置改成可直接编辑的规则表,总开关默认关闭
- 设置页分三组:常规(启用关键字高亮,默认关闭)、预览(当前终端字体和
  配色下的一段示例日志)、规则(N)。
- 规则表在行里直接改,不再弹对话框:拖动把手排序、启用、正则表达式、
  备注、颜色(取色器加 #rrggbb 输入框)、通知、删除。规则都是正则,区分
  大小写;颜色可任意取,不跟主题;去掉加粗。通知标题用备注。
- 预置 ERROR、WARN、IPv4 三条示例,各自启用、不通知,打开总开关就能用。
- 取色器自带一行与调色板不重复的常用色:gpui-kit 默认的常用色也在调色板
  里,同色两格是重复的无障碍节点,调试版会因此崩溃。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:46:18 +08:00
XuzandClaude Opus 5.5 7c9501e4cb 终端:程序开了同步更新却没结束时,到点照样画出来,不再冻住屏幕
- 程序用 \e[?2026h 开始一帧、却没发 \e[?2026l 结束(在一帧中间被杀掉、
  崩溃,连接断在一帧中间,或 cat 了含这段字节的文件)时,后面的输出全被
  攒着不画:屏幕冻住、敲字没回显,要等攒够 2 MiB 或下一个程序再结束一次
  同步更新才恢复。
- 解析线程等输出时带着同步更新的截止时间(150 ms,同 alacritty),到点
  就把攒下的画出来;输出一直不停时在处理前补查一次。
- 进程退出、连接出错时先结束同步更新,再追加「进程已退出」「终端错误」
  的提示,断在一帧中间也看得到那一帧和断开的原因。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:10:04 +08:00
XuzandClaude Opus 5.5 5f588a4a18 终端关键字高亮:按规则给匹配的文字上色,可选匹配时通知
- 规则是关键字(smart case,同查找)或正则表达式,加颜色(主题的红、黄、
  绿、青、蓝、紫)和加粗,在所有终端里显示,回滚历史里的也一样;重叠时
  靠前的规则优先。
- 只改显示:每帧对可见行把折行接成逻辑行再匹配,不改服务器的输出,复制
  出的文字不变;窗口缩放重排、折成两行的词都照样上色。用 regex crate
  匹配,不用 alacritty 的 RegexSearch(编不了 \b,^ 只认第一行)。
- 勾了「匹配时通知」的规则在看不到那个终端时提醒,同响铃;解析线程在
  换行前读完整的一行,同一终端 10 秒一次,反复重画的同一行 5 分钟一次。
- 全屏程序(vim、less、top、tmux)里不高亮也不通知。
- 设置左栏新增「关键字高亮」,排在「终端」后面;首次预置 ERROR 红粗、
  WARN 黄、IPv4 蓝三条示例。settings.json 里读不了的规则单条丢掉,不让
  整个设置回退成默认值。
- 光标闪烁不再每 500 ms 取一整份终端快照。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:54:04 +08:00
XuzandClaude Opus 5.5 09ccaa3036 版本号改为 0.1.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:23:06 +08:00
XuzandClaude Opus 5.5 3c38c664c2 外部 CLI 加 exec --json:JSON 进出,输出只含 ASCII
- shellrs exec --json 从标准输入读 {"host", "command"},命令结束后输出
  {"exit_code", "stdout", "stderr"},出错时输出 {"error": {"code", "message"}},
  退出码和不加 --json 时一样。非 ASCII 字符都转成 \uXXXX,不是 UTF-8 的输出
  换成 U+FFFD:命令不经过本机 shell 的引号处理,控制台代码页也弄不乱输出。
- skill 的 Windows 一节:PowerShell 优先用 --json;要边执行边看输出时用
  here-string 加 --stdin,先设 UTF-8 编码;Git Bash 用 heredoc。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:09:20 +08:00
XuzandClaude Opus 5.5 a9e8d8dbef 终端通知:程序请求的通知(OSC 9 / 777)和响铃提醒
- 程序用 OSC 9 或 OSC 777 notify 请求通知,终端响铃时也提醒;OSC 9 里
  ConEmu 的进度条、当前目录等命令不算。
- ShellRS 不在前台时发系统通知,点了回到那个终端;在前台但终端在别的标签
  时弹应用内通知,点一下切过去。响铃在终端就在眼前时不提醒。
- 标题是标签名,同一终端程序通知 2 秒、响铃 10 秒内只提醒一次;设置 ›
  终端 › 通知可分别关掉。
- 启动时设置应用标识 com.shellrs.ShellRS,Windows 安装程序的快捷方式用
  同一个 AppUserModelID。
- 不做 OSC 133 长命令通知:要每台服务器装 shell 集成,收益不抵成本。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:37:53 +08:00
XuzandClaude Opus 5.5 befdfc7b02 终端识别链接、支持鼠标上报和 OSC 52 远程复制
- 链接:输出里的 http / https 地址和程序用 OSC 8 标出的链接一直画成蓝色加
  下划线,⌘(其他平台 Ctrl)单击用浏览器打开,悬停显示真实地址;末尾标点
  和不成对的括号不算,折行的长地址算一个,其他协议不当链接。
- 鼠标上报:vim、htop、tmux 等要求鼠标时,单击、拖动和滚轮交给程序
  (1000 / 1002 / 1003,默认、UTF-8、SGR 编码);按住 Shift 照旧选文字和
  翻回滚,右键始终是 ShellRS 的菜单。
- OSC 52:远程程序复制的内容进本机剪贴板,只写不读,主选择区忽略。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:26:21 +08:00
XuzandClaude Opus 5.5 d53bb559e6 SFTP 加「显示隐藏文件」开关:本地、远程各自独立,默认不显示
工具栏第一行最右边的眼睛按钮(睁眼表示正在显示,划掉的眼睛表示不显示)、
空白处右键的勾选项和 ⌘⇧. / Ctrl+Alt+H 切换所在一侧,名称以「.」开头的
文件和目录算隐藏文件。每一侧的选择对所有 SFTP 标签生效,存进设置。

不显示时面板底部写「隐藏 N 项」,选中的隐藏文件随之取消选择,新建和重命名
查重名时仍算上它们;显示时隐藏文件整行文字浅一些。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:45:18 +08:00
XuzandClaude Opus 5.5 f1ac434ebf SFTP 右键菜单:去掉目录的「打开」,加「复制路径」
文件名上的菜单不再有「打开」:目录双击就进入。「上传… / 下载…」下面
加「复制路径」,复制选中项目的完整路径,多个时每行一个,「..」是上级目录。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:29:05 +08:00
XuzandClaude Opus 5.5 40589c9931 精简 CLAUDE.md:从 216 KB 压到 32 KB
只留产品决定、模块职责和会让改动出错的约定与坑;各工具的命令细节、选择拖动的逐步机制、
UI 测试的元素 id 清单和来回试错的过程删掉,它们在代码、测试和使用手册里都有。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:59:14 +08:00
XuzandClaude Opus 5.5 8e290562f8 SFTP 加图片和 Markdown 预览:双击图片预览,可缩放、原图和滚动
图片(PNG、JPG、GIF、WebP、SVG、BMP、ICO、TIFF)和 Markdown 在一个像快速查看的大对话框里预览,
Esc 关闭,远程文件可以「下载…」,Markdown 可以「编辑」。双击图片就预览;Markdown 和 SVG 是文本,
双击仍是编辑,右键另有「预览」。图片上限 20 MB,Markdown 同编辑器 5 MB。

图片默认适合窗口、居中显示;可以放大、缩小(⌘= / ⌘-,按住 ⌘ 滚动连续缩放)、看原图(⌘0)、
回到适合窗口(⌘9),双击图片在两者之间切换;比预览区大时可以滚动,缩放时中间的位置不动。
解不开的图片写明「无法显示这张图片」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:50:02 +08:00
XuzandClaude Opus 5.5 c679d9d8a0 SFTP 加内置编辑器:双击文件在编辑器标签里编辑,⌘S 原地写回
SFTP 里双击、回车、F4 或右键「编辑」一个文件(远程和本地都行),在中间区的「编辑器」标签里打开;
「新建 › 文件…」建好后也直接打开。同一个文件只开一个标签。先读后开:超过 5 MB、二进制或不是
UTF-8 的文件只弹说明,远程的可以改用「下载…」。

远程文件经打开它的 SFTP 标签的连接读写,不另起登录。保存是原地改写(截断后写原文件),所有者、
权限和链接都不变;保存前核对大小和修改时间,被别人改过就先问「覆盖」。BOM 和 CRLF 原样写回。
常见的约 15 种语言有语法高亮(gpui-kit 的 tree-sitter 特性;SQL 的语法库要求 cc ~1.2,所以
Cargo.lock 里 cc 退到 1.2.67)。

关闭有修改的标签问「保存 / 放弃修改 / 取消」;批量关闭、关 SFTP 标签、⌘Q(Windows / Linux 还有
窗口的关闭按钮)有未保存的文件时问一次。状态栏显示光标位置和编码、换行方式。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:37:26 +08:00
XuzandClaude Opus 5.5 09dc45e7a2 版本号改为 0.1.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:24:36 +08:00
XuzandClaude Opus 5.5 2de318a4e6 开始页加「快速连接」:搜索保存的主机,回车或单击连接
「最近连接」标题右边,「新建主机…」左边加一个小号的「快速连接」按钮(两个按钮都改成小号)。
打开的对话框列出所有保存的主机,连接过的按最近连接排在前面,其余按名称排;每行是系统徽章、
名称和「分组 · 用户@地址:端口」。名称、地址、用户名、备注和分组都能搜,几个词都要匹配。
打开时不高亮任何一台,输入后高亮第一个匹配的,回车或单击连接。

多选(Tab 选中、⌘A 全选、⇧↵ 批量连接)已经做好但先关掉,常量 MULTIPLE 打开即可。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:20:57 +08:00
XuzandClaude Opus 5.5 9474c9e789 临时连接:标题栏「临时连接…」打开对话框,连一台主机而不保存
标题栏右上角的「新建主机…」换成「临时连接…」(新建主机还在主机列表的「+」、
右键菜单和 ⌘N)。对话框沿用主机对话框,去掉分组、连接方式和备注,名称可不填;
密码只留在内存里,不进钥匙串。连上后功能齐全(不像外部连接只开一个通道),
不进主机列表和最近连接,标签都关掉就没了;可以使用凭据,凭据改动和删除也
照顾到它。外部 CLI 不区分临时连接和外部连接,表格写「(未保存)」。

测试连接选「密码」却没填时,按「无密码」那样试(免认证、SSH Agent、默认私钥),
服务器要密码时报「未填写密码」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:52:29 +08:00
XuzandClaude Opus 5.5 835894625f README:功能列表补上右侧栏工具,截图一节加右侧栏工具的拼图
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:03:05 +08:00
XuzandClaude Opus 5.5 28d4799154 README:写明可以像 Xshell、WinSCP 一样从堡垒机打开
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:54:07 +08:00
XuzandClaude Opus 5.5 f1a7c68907 版本号改为 0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:51:28 +08:00
XuzandClaude Opus 5.5 76f832caf6 Agent skill 去掉外部连接那一条
ID 失效时 CLI 自己会报 host_not_found,Errors 一节已经写了重新 list;agent 也看不到
标签开没开,按名字 list --query 就能找到外部连接。skill 回到 3c0871e 之前的内容。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:45:02 +08:00
XuzandClaude Opus 5.5 6dcd084840 版本号改为 0.1.0:第一个正式版
更新日志的 0.1.0 一节补上右侧栏和从堡垒机打开;README 不再说目前只有 Beta 版。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:09:33 +08:00
XuzandClaude Opus 5.5 ac6f8eaeba 版本号改为 0.1.0-beta.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:44:49 +08:00
XuzandClaude Opus 5.5 2feae2fe3b 外部连接:sftp:// 链接打开 SFTP 标签,兼容 WinSCP 的调用方式
堡垒机像调 WinSCP 一样拉起 ShellRS(`ShellRS sftp://用户[:密码]@地址[:端口]`,
前面可以有 `/sessionname=名称`),开一个不保存的 SFTP 标签,左侧栏收起;
其余规则和 ssh:// 的外部连接一样。WinSCP 的其他开关忽略,链接里的路径暂不认。

补了 SFTP 不带密码、只凭账号(堡垒机令牌)登录的协议测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:44:17 +08:00
XuzandClaude Opus 5.5 d5370f8bac 外部连接:改名并在打开时收起左侧栏
命名(用户定的):命令行链接打开的叫「外部连接」(不保存、只开终端);以后在
ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。代码里共同的「不
保存」统一叫 temporary(HostStore 的 temporary / is_temporary、
SecretRef::Temporary、TemporarySecretStore,和 CLI 的 JSON 字段一致),「只开
终端」改由来源决定:HostStore::is_external,login_of 只对它置 shell_only,右
侧栏也看它。shellrs list 表格写「(外部连接)」。

外部连接打开时左侧栏开着就收起(同 ⌘B),终端占满窗口;链接有误时不收。
命令片段面板本来就列出全部片段,外部连接照常都能看到;以后做适用范围时外部
和临时连接只显示「所有主机」的,规则记在 CLAUDE.md。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:17:03 +08:00
XuzandClaude Opus 5.5 fd34edfc1c 版本号改为 0.1.0-beta.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:40:05 +08:00
XuzandClaude Opus 5.5 4fc7704954 临时连接只用终端这一个通道
用户的堡垒机在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会
话:正替终端登录目标机时到来,终端报「No more authentication methods
available」;探测跑完通道一关,刚出提示符就退出。Xshell 和 OpenSSH 只开终
端一个通道。HostLogin 加 shell_only(临时主机置上):不做系统探测,工具的
命令直接拒绝、不发给服务器,右侧栏只给不执行命令的命令片段。延迟测量是全局
请求,照常。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:39:46 +08:00
XuzandClaude Opus 5.5 67d36c8210 版本号改为 0.1.0-beta.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:03:48 +08:00
XuzandClaude Opus 5.5 c0bc970de8 SSH:密钥交换按 OpenSSH 的默认,连接失败写明原因
用户的堡垒机是老 Java 上的 Apache SSHD 2.10:没有 curve25519,DH 组交换最大
2048 位。russh 默认不带 NIST 曲线、组交换要至少 3072 位,于是选了组交换、服务
器找不到素数就断开;OpenSSH 选 ecdh-sha2-nistp256 能连上。现在照 OpenSSH 的
顺序加上 ecdh-sha2-nistp*,组交换下限 2048(OpenSSH 的),不加 SHA-1、CBC。
route_tests 用只给 2048 位组、只有 NIST 曲线的服务端守着。

终端和 SFTP 的连接失败改用 describe_login_error:沿错误链给出真正的原因,
没有共同算法时写明服务器支持哪些,找不到时给整条错误链,不再只剩「无法建立
SSH 连接,请检查地址、端口和主机密钥」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:03:18 +08:00
XuzandClaude Opus 5.5 e7fcc7ca40 Docker:同一个镜像有多个标签时每个标签一行,按标签查看和删除
docker images 每个「仓库:标签」一行,同一镜像的几行镜像 ID 相同,卡片和删除
按钮的 id 因此重复:调试版构建在镜像页直接崩溃,正式版里详情和删除只认第一
行,按 ID 删多标签镜像还会被 Docker 拒绝。现在每行按 Image::key 认:有标签
用「仓库:标签」(删除只去掉这个标签),没有标签用镜像 ID。只有仓库名的镜像
显示为「仓库:<none>」、按 ID 操作,不再被当成 仓库:latest;valid_id 允许 /,
带仓库地址的镜像可以删除和查看详情。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:03:18 +08:00
XuzandClaude Opus 5.5 269fab6550 版本号改为 0.1.0-beta.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:45:10 +08:00
XuzandClaude Opus 5.5 3c0871eb4e 外部 CLI:shellrs list 也列出临时连接
堡垒机打开的临时连接排在保存的主机后面,JSON 里 "temporary": true,表格的
分组一栏写「(临时连接)」。标签开着期间 ID 不变;每条命令照常新建连接,用
内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录。旧版应用的回答缺
这个字段时读成保存的主机,不升协议版本。skill 里写明这些。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:44:33 +08:00
XuzandClaude Opus 5.5 87c5cfb120 堡垒机用 ssh:// 链接打开临时连接(兼容 Xshell 的调用方式)
`ShellRS ssh://用户[:密码]@地址[:端口]` 或 `ShellRS -url … -newtab 名称`
开一个终端标签连过去。只看第一个参数判断是不是链接,外部 CLI 的命令不受
影响;Windows 发布版也认。

- 临时连接不保存:HostStore 把它放在单独的 transient 里,按 id 找得到,
  但不在 hosts() 里,不写库、不进最近连接;终端和 SFTP 标签都关掉就忘掉。
- 链接里的密码只在 TransientSecretStore 的内存里(SecretRef::Transient),
  不进钥匙串。
- ShellRS 已在运行时,Request::Activate 带上链接交给它打开;不带链接时消息
  和以前一字不差,旧版收到也只是调出窗口,所以不升协议版本。
- 链接有问题时弹通知说明原因,文字里不带链接原文。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:24:42 +08:00
XuzandClaude Opus 5.5 ba63b3d73f 在线升级:标题栏按钮更醒目,更新对话框不再列更新说明
标题栏的更新按钮改成 success 色的浅色按钮(出错时 warning 色),不再是
普通图标加一个小绿点。更新对话框不再显示更新说明,页脚两个按钮:「查看
更新内容」打开 https://shellrs.com/changelog,「重启并安装」照旧;去掉
「稍后」,用关闭按钮或 Esc 关。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:25:41 +08:00
XuzandClaude Opus 5.5 de0fafe6ee Docker:有多个容器的 compose 项目默认折叠
只有一个容器、而且它在运行的项目默认展开;多个容器的项目展开会把下面的
都挤下去,卡片上已经写着「运行中/总数」。手动展开或折叠照旧记在面板里。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:25:36 +08:00
XuzandClaude Opus 5.5 7886cb7138 外部 CLI:Agent Skills 增加 WorkBuddy
安装到 ~/.workbuddy/skills(Windows 是 %USERPROFILE%\.workbuddy\skills)。
设置页的 skill 行数按 AgentKind::ALL 的长度算,不再写死。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:40 +08:00
XuzandClaude Opus 5.5 e27bb0cb63 版本号改为 0.1.0-beta.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:38:29 +08:00
XuzandClaude Opus 5.5 290650bd18 改 src/testing.rs 时也跑 Windows CI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:38:29 +08:00
XuzandClaude Opus 5.5 1b6a0b6b9b 修复 Windows CI:测试辅助函数 sh 只在 Unix 上编译
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:29:17 +08:00
XuzandClaude Opus 5.5 a20b3bae3b 主机右键菜单增加复制 IP 地址
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:23:21 +08:00
XuzandClaude Opus 5.5 ee8441e301 命令片段:右侧栏的新工具,按分类保存常用命令,点一下输入或执行
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:09:20 +08:00
XuzandClaude Opus 5.5 9143e387bc 历史命令:右侧栏的新工具,列出主机上 bash 的历史命令,点一下放进终端输入行
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:08:16 +08:00
XuzandClaude Opus 5.5 2576b46e43 Docker:右侧栏的 Docker 工具,管理容器、卷、镜像和网络
- 容器按 compose 项目分组,可启停、重启,查看详情和日志;卷、镜像、网络可查看详情和删除
- 工具图标换成 Docker 的鲸鱼;单元测试起子进程时加锁,修好外部 CLI 测试的偶发失败

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:18:27 +08:00
XuzandClaude Opus 5.5 610cb12fbf 系统服务:右侧栏的新工具,管理 Linux 主机的 systemd 服务
- 按自定义 / 系统分组,按状态分页、搜索;启动、停止、重启,启用或禁用开机启动
- 点服务打开详情,含状态和最近的日志

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:51:39 +08:00
XuzandClaude Opus 5.5 3e8f635fa7 进程管理:右侧栏的新工具,列出 Linux 主机的进程,可查看详情和结束进程
- 按内存或 CPU 排序、搜索,每 15 秒刷新;点进程打开详情,含完整命令
- 右键或详情里结束 / 强制结束进程,先确认

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:10:56 +08:00
XuzandClaude Opus 5.5 0d455e02f5 网络连接:右侧栏的新工具,列出 Linux 主机的 TCP、UDP 连接和监听端口
- 卡片显示协议、状态、地址、进程和用户,可搜索、按协议和状态筛选
- 打开或切换终端时读取一次,点刷新再读;系统监控和它共用终端里执行命令的部分

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:41:00 +08:00
XuzandClaude Opus 5.5 8a0436caf6 系统监控:右侧栏的第一个工具,经终端自己的 SSH 连接读取 Linux 主机的 CPU、内存、网络和磁盘
- 右侧栏只跟远程终端出现,已知不是 Linux 的主机不显示监控图标,最窄 320 px
- 端口转发、凭据列表和开始页的滚动条挂到不滚动的外层上,修正滑块位置

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:34:23 +08:00
XuzandClaude Opus 5.5 616bcead0f 终端右侧栏:窗口最右边一列工具切换,右侧 Dock 里显示所选工具,先放四个占位面板
- 窗口级的一份右侧栏,只跟着终端出现(远程终端和本地终端):SFTP、设置、开始页在前时
  右侧栏连同按钮列都不显示,回到任一终端时按原样恢复,内容对应这个终端(主机或本机)
- 右侧 Dock 只有一个面板 ToolSidebar,按钮列由工作区画在 DockArea 外面;点工具显示、
  再点收起,⌘⌥B / Ctrl+Alt+B 显示或隐藏;默认收起、宽 320 px,不持久化
- 占位工具:命令片段、历史命令、Docker、系统监控,写明将来显示什么和「即将推出」
- 当前标签统一经 Workspace::set_active_tab 改,它同步右侧栏的显示与指向的终端
- 右侧 Dock 不可折叠,只在关闭时临时可折叠,避免中间区标签栏多出 gpui-kit 的折叠按钮;
  按钮不抢终端焦点,收起时焦点在右侧栏里就交还给中间的标签
- 新增 UI 测试;CLAUDE.md 记下产品决定、实现要点和测试标识

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:42:31 +08:00
XuzandClaude Opus 5.5 0ff59d07bc 侧栏三个列表的行 tooltip 统一成主机列表的样式:开在行的右侧、主题反色、在行间跟着指针移动
- 主机列表的 TooltipOverlay、主人判断和动画抽成 shared::RowTooltips,主机、端口转发、凭据三个面板共用;
  转发和凭据原来用 ListItem::tooltip,贴着鼠标弹出、盖住下面的行、和菜单同色
- 内容一律是一行要点、下面小一号的补充:主机是 user@地址:端口加备注,转发是完整的规则说明,
  凭据是行里第二行的全文,密钥凭据下面是私钥文件的完整路径
- 转发行的启停按钮有自己的 tooltip:指针在按钮上时行的 tooltip 收起,回到行里再出来;
  ListItem 的 suffix 不在内容盒里,按钮区另外量一次,tooltip 按并集定位,不再压在按钮上
- 新增转发和凭据行 tooltip 的 UI 测试;主机备注的测试 id 改为 host-tooltip-note;CLAUDE.md 同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:57:06 +08:00
XuzandClaude Opus 5.5 471282f3e3 SFTP 列表的框选框改用选中名称的配色:浅蓝底色加深一点的蓝色边框(table_active / table_active_border),原来是 primary 的灰底深色框,和选中的浅蓝不搭
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:18:26 +08:00
Xuz 874f3149df 增大应用图标内容边距并更新各平台图标 2026-10-02 01:18:45 +08:00
XuzandClaude Opus 5.5 0b72cf098f 发布的最后一步调用部署挂钩,让官网 shellrs.com 重新构建:官网的下载页和更新日志在构建时读更新清单,所以放在清单上传、清 CDN 缓存之后;挂钩地址是 release 环境的 secret SITE_DEPLOY_HOOK
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:03:02 +08:00
XuzandClaude Opus 5.5 260e5f493f README 的下载部分加上官网 shellrs.com/download
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:54:05 +08:00
XuzandClaude Opus 5.5 8ae0c38998 许可证改为 GPL-3.0:分发修改版本须同样以 GPL-3.0 公开源代码;README 开头一句强调 Rust 编写、GPU 渲染界面
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:45:21 +08:00
Xuz 3d6801d469 README 改写成开源首页:中英文两版、真实窗口截图(主界面深浅两色、分栏、SFTP、端口转发),原来的逐项说明、发布和开发说明移到 docs/;以 MIT OR Apache-2.0 双许可发布 2026-10-01 23:36:05 +08:00
Xuz 8df26abf37 版本号改为 0.1.0-beta.4 2026-10-01 21:24:13 +08:00
Xuz e887ffc66b Windows 上 SFTP 本地面板的路径不再带 \\?\ 前缀:读目录和上传的来源改用 dunce 规范化,Windows CI 加跑本地读目录的测试 2026-10-01 21:23:45 +08:00
Xuz 7fa7fefeb0 版本号改为 0.1.0-beta.3 2026-10-01 21:02:31 +08:00
Xuz 75a0e76e17 开始页没有连接记录时去掉「双击左侧的主机即可连接」:标题下面已经写了 2026-10-01 21:00:22 +08:00
Xuz ef2bd8c3a6 macOS 上点关闭按钮改为把 ShellRS 隐藏到后台:连接、传输和端口转发照常运行,点 Dock 图标恢复窗口,⌘Q 才退出 2026-10-01 21:00:22 +08:00
Xuz bcf2db0413 Windows CI 只在推送改到平台相关代码、构建脚本或依赖时运行,tag 不再触发 2026-10-01 20:12:03 +08:00
Xuz e1a74faa53 版本号改为 0.1.0-beta.2:beta.1 打包失败、没有发布,第一个测试版改用这个版本号 2026-10-01 20:08:02 +08:00
Xuz a9a7e7087f 发布流程:建 DMG 时显式给出映像大小和 HFS+ 文件系统,失败重试;hdiutil 自己估的大小装不下应用 2026-10-01 19:59:33 +08:00
Xuz 0fbcb7adbf 版本号改为 0.1.0-beta.1,准备第一个测试版 2026-10-01 19:34:32 +08:00
Xuz 43b5f81d51 README 去掉 SHELLRS_RELEASE_BASE 的说明:make-manifest.sh 里没有这个变量 2026-10-01 19:05:04 +08:00
Xuz 533d7aa3a1 更新清单里给每个包加上 GitHub Release 作为第二个下载地址 2026-10-01 19:04:15 +08:00
Xuz ee6633c401 make-manifest.sh 可用 SHELLRS_RELEASE_BASE 把下载地址指向本地服务器,便于本机演练升级 2026-10-01 17:32:09 +08:00
Xuz 1f330212e8 填入在线升级的发布公钥:日常签名用的一把和离线备用的一把 2026-10-01 17:32:09 +08:00
298 changed files with 61628 additions and 3792 deletions

No files matched your search

+48 -17
View File
@@ -1,12 +1,15 @@
# A release: push a tag `v<version>` matching Cargo.toml's version.
#
# verify the tag, the CHANGELOG section, the update keys
# macos universal app: signed, notarized, stapled; update zip and DMG
# macos per-architecture apps: signed, notarized, stapled; update zip and DMG
# windows per-user Inno Setup installer (unsigned for now)
# linux AppImage
# publish (environment `release`, approved by hand) uploads to R2 behind
# dl.shellrs.com, creates the GitHub release, and only then
# replaces the channel's manifest, which is what clients follow
# replaces the channel's manifest, which is what clients follow;
# the manifest lists the GitHub release as each package's
# second address; last, it has Cloudflare rebuild shellrs.com,
# whose download page and changelog read that manifest
#
# A tag with a pre-release (v0.3.0-beta.1) goes to the beta channel only. A
# stable release also becomes the beta channel's, unless beta is ahead.
@@ -14,13 +17,13 @@
# Running it by hand (workflow_dispatch) builds and packages from the
# current commit without publishing, to try the pipeline.
#
# Secrets, see README.md › 发布:
# Secrets, see docs/release.md:
# repository: APPLE_CERTIFICATE_P12 (base64), APPLE_CERTIFICATE_PASSWORD,
# APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID, APPLE_API_KEY_P8
# (base64), APPLE_API_KEY_ID, APPLE_API_ISSUER
# environment `release`: MINISIGN_SECRET_KEY, MINISIGN_PASSWORD,
# R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY,
# R2_BUCKET, CF_ZONE_ID, CF_API_TOKEN
# R2_BUCKET, CF_ZONE_ID, CF_API_TOKEN, SITE_DEPLOY_HOOK
name: release
on:
@@ -79,9 +82,16 @@ jobs:
macos:
needs: verify
# 暂时只打 Windows 包,另行通知后再打开。
if: false
runs-on: macos-14
strategy:
fail-fast: false
matrix:
arch: [aarch64, x86_64]
timeout-minutes: 120
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.verify.outputs.version }}
SHELLRS_UPDATE_CHANNEL: ${{ needs.verify.outputs.channel }}
SHELLRS_COMMIT: ${{ github.sha }}
@@ -89,18 +99,17 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install
- run: rustup target add aarch64-apple-darwin x86_64-apple-darwin
- run: rustup target add "$ARCH-apple-darwin"
- uses: Swatinem/rust-cache@v2
- name: Build both halves and join them
with:
key: macos-${{ matrix.arch }}
- name: Build the architecture-specific app
run: |
export SHELLRS_BUILD_DATE=$(date -u +%Y-%m-%d)
cargo build --release --locked --bin shellrs --target aarch64-apple-darwin
cargo build --release --locked --bin shellrs --target x86_64-apple-darwin
cargo build --release --locked --bin shellrs --target "$ARCH-apple-darwin"
mkdir -p dist build
lipo -create -output build/shellrs \
target/aarch64-apple-darwin/release/shellrs \
target/x86_64-apple-darwin/release/shellrs
packaging/macos/bundle.sh build/shellrs "$VERSION" "$GITHUB_RUN_NUMBER" build
lipo "target/$ARCH-apple-darwin/release/shellrs" -verify_arch "${ARCH/aarch64/arm64}"
packaging/macos/bundle.sh "target/$ARCH-apple-darwin/release/shellrs" "$VERSION" "$GITHUB_RUN_NUMBER" build
- name: Sign
env:
CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12 }}
@@ -144,20 +153,31 @@ jobs:
xcrun stapler staple build/ShellRS.app
# The update package: the stapled bundle, zipped the way the
# updater unzips it (ditto keeps the signature intact).
ditto -c -k --keepParent build/ShellRS.app "dist/ShellRS-$VERSION-macos-universal.app.zip"
ditto -c -k --keepParent build/ShellRS.app "dist/ShellRS-$VERSION-macos-$ARCH.app.zip"
# The first-install disk image, with the Applications shortcut.
mkdir -p "$RUNNER_TEMP/dmg"
cp -R build/ShellRS.app "$RUNNER_TEMP/dmg/"
ln -s /Applications "$RUNNER_TEMP/dmg/Applications"
dmg="dist/ShellRS-$VERSION-macos-universal.dmg"
hdiutil create -volname ShellRS -srcfolder "$RUNNER_TEMP/dmg" -ov -format UDZO "$dmg"
dmg="dist/ShellRS-$VERSION-macos-$ARCH.dmg"
# hdiutil's own estimate of the image size once came out too small
# for the app ("No space left on device"), so the size is given,
# with room to spare; the compressed image is about half the app.
# It also fails now and then with "Resource busy" on the runners.
df -h "$RUNNER_TEMP" .
size=$(( $(du -sm "$RUNNER_TEMP/dmg" | cut -f1) * 2 + 20 ))
for attempt in 1 2 3; do
hdiutil create -volname ShellRS -srcfolder "$RUNNER_TEMP/dmg" -ov \
-fs HFS+ -size "${size}m" -format UDZO "$dmg" && break
[ "$attempt" = 3 ] && exit 1
sleep 10
done
codesign --force --timestamp --sign "$IDENTITY" "$dmg"
notarize "$dmg"
xcrun stapler staple "$dmg"
spctl -a -vv -t install "$dmg"
- uses: actions/upload-artifact@v4
with:
name: macos
name: macos-${{ matrix.arch }}
path: dist/*
windows:
@@ -194,6 +214,8 @@ jobs:
linux:
needs: verify
# 暂时只打 Windows 包,另行通知后再打开。
if: false
# Ubuntu 22.04's glibc (2.35) is the oldest the AppImage runs on.
runs-on: ubuntu-22.04
timeout-minutes: 120
@@ -251,7 +273,7 @@ jobs:
- name: Collect
run: |
mkdir -p dist
cp artifacts/macos/* artifacts/windows/* artifacts/linux/* dist/
cp artifacts/macos-aarch64/* artifacts/macos-x86_64/* artifacts/windows/* artifacts/linux/* dist/
cp artifacts/notes/notes.md notes.md
ls -l dist
- name: Signing key
@@ -284,6 +306,10 @@ jobs:
- name: Manifests, last
env:
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
# The GitHub release just created: clients try it when
# dl.shellrs.com fails. The repository is public, so it needs no
# sign-in.
SHELLRS_MIRROR_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download/v${{ needs.verify.outputs.version }}
run: |
publish() {
packaging/make-manifest.sh "$VERSION" "$1" notes.md dist "$1.json"
@@ -318,3 +344,8 @@ jobs:
curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
--data "$files"
# After the purge: the site's build reads the manifests from dl.shellrs.com.
- name: Rebuild shellrs.com
env:
SITE_DEPLOY_HOOK: ${{ secrets.SITE_DEPLOY_HOOK }}
run: curl -fsS -X POST "$SITE_DEPLOY_HOOK"
+47
View File
@@ -0,0 +1,47 @@
# `shellrs exec --terminal` against the shells it types into: bash 4.2 to
# 5.2, dash, busybox ash and a bash started as sh, each in a container on a
# real terminal (`terminal::run::tests::shells`). The Macs this is
# developed on run it only with Docker Desktop up.
#
# A push runs it when the run's code, the test helpers or the dependencies
# change; run it by hand otherwise (Actions › shells › Run workflow).
name: shells
on:
push:
branches: ["**"]
paths:
- "src/terminal/**"
- "src/testing.rs"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".github/workflows/shells.yml"
workflow_dispatch:
jobs:
shells:
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v4
- name: Build dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake clang pkg-config libfontconfig-dev \
libfreetype-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libxcb1-dev libvulkan-dev libdbus-1-dev libssl-dev
# Pulled first, so that no test waits on a download.
- name: Images
run: |
for image in bash:4.2 bash:4.4 bash:5.0 bash:5.1 bash:5.2 \
debian:stable-slim alpine:3 rockylinux:9; do
docker pull --quiet "$image"
done
# rust-toolchain.toml picks the toolchain; this installs it with the
# components it lists before the cache key is computed.
- run: rustup toolchain install
- uses: Swatinem/rust-cache@v2
- run: cargo test --lib terminal::run -- --include-ignored
+34 -1
View File
@@ -1,10 +1,42 @@
# The Windows build, which the Macs this is developed on cannot compile:
# the whole crate through clippy, the external CLI's tests, including the
# named pipe between two processes, and the updater's.
# named pipe between two processes, the updater's, and the local SFTP pane's
# paths (std canonicalizes to `\\?\C:\…` there).
#
# A push runs it only when it touches code that differs on Windows (the
# files with `cfg(windows)` and friends), the build script or the
# dependencies. Windows code added to another file goes in this list too;
# until then, run it by hand (Actions › windows › Run workflow). Tags never
# run it: the release workflow builds Windows itself.
name: windows
on:
push:
branches: ["**"]
paths:
- "src/cli/**"
- "src/analytics/**"
- "src/update/**"
- "src/app/paths.rs"
- "src/app/window_hiding.rs"
- "src/main.rs"
- "src/bin/**"
- "src/ssh/connection.rs"
- "src/sftp/download.rs"
- "src/sftp/model.rs"
- "src/sftp/upload.rs"
- "src/sftp/sync.rs"
- "src/credential/credential_dialog.rs"
- "src/settings/settings_panel.rs"
- "src/app/shortcuts.rs"
- "src/terminal/paste_image.rs"
- "src/workspace/window_state.rs"
- "src/testing.rs"
- "tests/cli_bin.rs"
- "build.rs"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
workflow_dispatch:
jobs:
@@ -25,4 +57,5 @@ jobs:
- run: cargo clippy --all-targets -- -D warnings
- run: "cargo test --lib cli::"
- run: "cargo test --lib update::"
- run: "cargo test --lib sftp::tests::local_listing"
- run: cargo test --test cli_bin
+1
View File
@@ -4,3 +4,4 @@
.env
.env.*
!.env.example
.claude/settings.local.json
+3 -3
View File
@@ -2,11 +2,11 @@
## 项目是什么
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案有简体中文和英文两种(中文是源语言,文案在 `locales/`,经 `crate::i18n::t!`),标识符用英文。
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `docs/manual.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个主机独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。
@@ -45,7 +45,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `update/` — 在线升级:`feed.rs` 从 `dl.shellrs.com` 取已签名的清单和安装包(reqwest,走系统代理),`verify.rs` 先验 minisign 签名(trusted comment 绑定通道和版本)再信清单、按大小和 SHA-256 信安装包,`install*.rs` 按安装方式装(macOS 整包交换、Windows 运行 Inno 安装程序、Linux 覆盖 AppImage),`updater.rs` 的 `Updater` 实体管检查、下载和重启,工作线程的事件由 UI 定时轮询。不依赖 `workspace` 和 `settings`;`new_with_services` 里的 `Updater` 没有服务、从不联网,生产路径才 `set_services` + `start`,UI 测试经 `workspace.updater()` 注入假的。详见 `CLAUDE.md` 和 `README.md` 的「下载与更新」「发布」。
- `update/` — 在线升级:`feed.rs` 从 `dl.shellrs.com` 取已签名的清单和安装包(reqwest,走系统代理),`verify.rs` 先验 minisign 签名(trusted comment 绑定通道和版本)再信清单、按大小和 SHA-256 信安装包,`install*.rs` 按安装方式装(macOS 整包交换、Windows 运行 Inno 安装程序、Linux 覆盖 AppImage),`updater.rs` 的 `Updater` 实体管检查、下载和重启,工作线程的事件由 UI 定时轮询。不依赖 `workspace` 和 `settings`;`new_with_services` 里的 `Updater` 没有服务、从不联网,生产路径才 `set_services` + `start`,UI 测试经 `workspace.updater()` 注入假的。详见 `CLAUDE.md`、`docs/manual.md` 的「下载与更新」和 `docs/release.md`。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。
关键流程与不变量:
+245 -1
View File
@@ -1,6 +1,160 @@
# 更新日志
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和 ShellRS 的更新对话框里,用 Markdown 书写,面向使用者。
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
## [0.1.8]
### 新增
- 设置 › 终端新增「终端交互」:选中即复制,右键可设为粘贴、显示菜单或无动作。
### 改进
- macOS 安装包分为 Apple Silicon 和 Intel 两种,下载更小。
### 修复
- 修复 ESXi 等主机「测试连接」报需要键盘交互式认证、保存的密码不起作用。
- 修复启动后不久检查更新时,「重启并安装」报找不到文件。
## [0.1.8-beta.1]
### 新增
- 设置 › 终端新增「终端交互」:选中即复制,右键可设为粘贴、显示菜单或无动作。
### 改进
- macOS 安装包分为 Apple Silicon 和 Intel 两种,下载更小。
### 修复
- 修复 ESXi 等主机「测试连接」报需要键盘交互式认证、保存的密码不起作用。
## [0.1.7]
### 新增
- Windows 上 SFTP 本地目录列表列出各个驱动器,可切换到其他盘。
### 改进
- SFTP 文件列表点列名即可排序。
### 修复
- 修复经部分堡垒机上传文件失败、编辑器保存时 SFTP 连接断开。
- 修复 SFTP 换目录后拖宽的列恢复原宽。
## [0.1.7-beta.2]
### 新增
- Windows 上 SFTP 本地目录列表列出各个驱动器,可切换到其他盘。
### 改进
- SFTP 文件列表点列名即可排序。
### 修复
- 修复 SFTP 换目录后拖宽的列恢复原宽。
## [0.1.7-beta.1]
### 修复
- 修复经部分堡垒机上传文件失败、编辑器保存时 SFTP 连接断开。
## [0.1.6]
### 新增
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
- 终端右侧显示滚动条,可拖动翻看历史输出。
## [0.1.6-beta.1]
### 新增
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
## [0.1.5]
### 新增
- 界面支持英文:默认跟随系统语言,也可在「设置 › 外观 › 界面语言」切换,改了立即生效。
- 英文用户手册;README 默认显示英文版。
- 匿名使用统计,可在「设置 › 关于 › 隐私」关闭。
### 改进
- 默认深色外观改为不刺眼的炭灰。
- 列表里悬停和选中的行更容易分清。
- 分屏时右侧栏跟着点进去的远程终端,标题显示主机名。
- Docker 的标签放不下时横向滚动,不再截断。
### 修复
- 把标签拖进另一组盖住终端后,右侧栏随之收起。
## [0.1.4]
### 新增
- 终端主题:浅色、深色外观各有 10 套配色可选,界面也可以选择跟着主题配色。
- 终端关键字高亮:按规则给匹配的文字上色,匹配时还可以发通知。
- 设置新增「键盘快捷键」:查看、改绑、禁用或恢复默认快捷键,并新增切换标签的快捷键。
- 终端里 ⌘= / ⌘- / ⌘0(其他平台 Ctrl)调终端字号,终端外缩放界面。
- 设置 › 应用:记住窗口大小和位置。
- 设置 › 关于:官网链接。
- 外部 CLI 可以查看、新建、修改和删除主机与凭据(`shellrs hosts`、`shellrs credentials`),并新增目录同步 `shellrs sync`。
### 改进
- 主机列表的搜索也能按分组名找。
- 设置页切到别的标签再回来,停在原来的分类上。
- 终端字号范围改为 10–28。
- 外部 CLI 列出主机改为 `shellrs hosts list`。
### 修复
- 远程程序卡在半帧时,终端画面不再冻住。
## [0.1.3]
### 新增
- SFTP 内置编辑器:双击文本文件在编辑器标签里编辑,⌘S 直接保存回服务器,常见语言语法高亮。
- SFTP 预览:双击图片快速查看,可放大、缩小、看原图;Markdown 和 SVG 右键「预览」。
- SFTP「显示隐藏文件」开关,本地、远程各自独立。
- SFTP 右键「复制路径」。
- 终端里的网址显示为链接,⌘ 单击(其他平台 Ctrl 单击)用浏览器打开。
- 终端支持鼠标:vim、htop、tmux 里可以点击和滚动。
- 远程程序(如 tmux、vim)复制的内容直接进本机剪贴板。
- 终端通知:程序请求通知或响铃时,ShellRS 不在前台会发系统通知。
- 外部 CLI 新增 `shellrs exec --json`,Windows PowerShell 下不再有引号和乱码问题。
### 改进
- SFTP 目录的右键菜单去掉了多余的「打开」,双击即可进入。
## [0.1.2]
### 新增
- 临时连接:标题栏右上角的「临时连接…」连一台主机而不保存,密码只留在内存里(新建主机请用主机列表的「+」或 ⌘N)。
- 开始页的「快速连接」:搜索保存的主机,回车或单击连接。
### 改进
- 「测试连接」不再要求填写密码,没填时按「无密码」的方式尝试。
## [0.1.1]
### 改进
- 精简了给 AI Agent 的 Skill 说明。
## [0.1.0]
@@ -8,6 +162,96 @@ ShellRS 的第一个公开版本。
### 新增
- 主机与分组:持久保存,分组可以多层嵌套,支持搜索和拖动整理。
- 凭据:密码、密钥(本机文件、粘贴或生成新密钥)和 SSH Agent,多台主机可以共用一条凭据。
- 连接方式:直接连接、SSH 跳板或 HTTP / SOCKS5 代理。
- 终端:本地终端和多个 SSH 终端,支持查找、清屏和延迟显示。
- 右侧栏:跟着当前远程终端的系统监控、网络连接、进程管理、系统服务、Docker、历史命令和命令片段。
- SFTP:对标 WinSCP 的双栏文件浏览器,上传和下载都支持断点续传和传输队列。
- 端口转发:本地、远程和动态转发,断线后自动重连,可随 ShellRS 一起启动。
- 从堡垒机打开:像调用 Xshell、WinSCP 一样用 `ssh://`、`sftp://` 链接打开终端或 SFTP,不保存连接。
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
## [0.1.0-beta.9]
### 新增
- 堡垒机可以像调用 WinSCP 一样,用 `sftp://` 链接打开 ShellRS 的 SFTP。
- 从堡垒机打开时,左侧主机栏自动收起,终端或 SFTP 占满窗口。
## [0.1.0-beta.8]
### 修复
- 从堡垒机打开的连接登录后立即断开。
## [0.1.0-beta.7]
### 修复
- 连不上部分堡垒机:Apache SSHD 等不支持 curve25519、只能提供 2048 位 DH 组的 SSH 服务器,以前握手时会被断开。现在密钥交换和 OpenSSH 的默认一致,支持 ECDH(NIST 曲线),DH 组交换也接受 2048 位的组;OpenSSH 默认能连上的服务器,ShellRS 都能连上。
- 连接失败时写明具体原因,比如连接被拒绝、连接超时,或者和服务器没有共同的算法(列出服务器支持哪些),不再只有一句「无法建立 SSH 连接,请检查地址、端口和主机密钥」。
- Docker:同一个镜像打了多个标签时,每个标签单独一行,查看详情和删除都对应这一行的标签,删除只去掉这个标签。只有仓库名、没有标签的镜像显示为「仓库:<none>」,不再被当成「仓库:latest」。带仓库地址的镜像(如 `registry.example.com/app:1.0`)现在也能删除和查看详情。
## [0.1.0-beta.6]
### 新增
- 从堡垒机打开:JumpServer 等堡垒机可以像调用 Xshell 一样调用 ShellRS(`ShellRS ssh://用户@地址:端口`,或 `-url ssh://… -newtab 名称`),打开一个终端标签连过去。这是临时连接:不保存到主机列表,标签都关掉后就没了;链接里的密码只留在内存里。ShellRS 已经在运行时,链接交给它打开,不会再起一个。堡垒机里要填的程序:Windows 是安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`),macOS 是 `/Applications/ShellRS.app/Contents/MacOS/shellrs`。
- 外部 CLI:`shellrs list` 也列出标签开着的临时连接(标为临时),Agent 可以用它执行命令、传输文件。
- 外部 CLI:Agent Skills 可以安装到 WorkBuddy。
### 改进
- 新版本下载好后,标题栏右上角的更新按钮改成绿色,更醒目。更新对话框不再列出更新说明,改为「查看更新内容」打开官网的更新日志,旁边是「重启并安装」。
- Docker:有多个容器的 compose 项目默认折叠,只有一个容器且在运行的项目默认展开。
## [0.1.0-beta.5]
### 新增
- 终端右侧栏:SSH 终端在前时,窗口最右边有一列工具按钮,点一个就在右侧打开,⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏。工具都通过这个终端已有的 SSH 连接执行,不用再次登录:
- **命令片段**:按分类保存常用命令,点一下输入到终端或直接执行。
- **历史命令**:列出主机上 bash 的历史命令,可以搜索,点一下输入到终端或直接执行。
- **Docker**:容器按 compose 项目分组,启动、停止、重启和查看日志;卷、镜像和网络可以查看详情和删除。
- **系统服务**:systemd 服务的状态和日志,启动、停止、重启和开机启动。
- **进程管理**:进程的内存和 CPU 占用,查看详情、结束进程。
- **网络连接**:TCP、UDP 连接和监听端口,以及占用它们的进程。
- **系统监控**:CPU、内存、网络和磁盘的实时状态。
系统服务、进程管理、网络连接和系统监控只支持 Linux 主机。
- 主机右键菜单增加「复制 IP 地址」(地址是域名时叫「复制主机名」)。
### 改进
- 端口转发和凭据列表的提示和主机列表一样,显示在行的右侧。
- SFTP 框选的颜色和选中的文件一致。
- 应用图标留出更多边距。
### 注意
- 第一次启动会升级本地数据库(加入命令片段),升级前自动备份为 `shellrs.db.v13.bak`。升级后 0.1.0-beta.4 及更早的版本无法再打开这个数据库。
## [0.1.0-beta.4]
### 修复
- Windows:SFTP 本地面板的路径不再显示成 `\\?\C:\Users` 这样,而是平常的 `C:\Users`,传输队列的「来源」也一样。以前添加的本地书签如果带着 `\\?\`,删掉重新添加即可;升级前停在一半的上传不能接着续传,需要重新开始。
## [0.1.0-beta.3]
### 改进
- macOS:点窗口左上角的关闭按钮,现在会把 ShellRS 隐藏到后台,已打开的连接、SFTP 传输和端口转发照常运行;点 Dock 图标就能把窗口找回来,原来的标签都在。要退出请用 ⌘Q,或右键 Dock 图标选「退出」。以前关掉窗口后,ShellRS 留在 Dock 里却打不开窗口。
- 开始页没有连接记录时,去掉了一句重复的提示。
## [0.1.0-beta.2]
ShellRS 的第一个测试版本,只发到 Beta 渠道,用来验证安装和在线升级。之后的测试版会通过在线升级自动送达。
### 新增
- 主机与分组:持久保存,分组可以多层嵌套,支持搜索和拖动整理。
- 凭据:密码、密钥(本机文件、粘贴或生成新密钥)和 SSH Agent,多台主机可以共用一条凭据。
- 连接方式:直接连接、SSH 跳板或 HTTP / SOCKS5 代理。
+349 -138
View File
@@ -4,16 +4,91 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目是什么
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案有简体中文和英文两种(中文是源语言,见「多语言」),标识符用英文。功能说明见 `docs/manual.md`(开源首页 `README.md` 是英文、默认显示,中文版是 `README.zh-CN.md`;截图在 `docs/screenshots/`,英文界面的在 `docs/screenshots/en/`)。
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。详见 `README.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
- **术语。** 保存的一项叫「主机」(量词「台」),代码和库里叫 host(`Host`、`HostId`、`HostStore`、`hosts` 表)。它以前叫「会话」/ `Session`,已全部改掉,新代码和文案别再用。填 IP / 域名的字段叫「地址」(`Host.address`)。SSH 协议层的 session(「SSH 会话通道」、russh 的 `Session`、SFTP 会话)照旧。英文用词见「多语言」的用词表。
- **两种不保存的连接。** 「外部连接」:堡垒机像调 Xshell(`ssh://`、`-url`)或 WinSCP(`sftp://`)一样拉起 ShellRS,只开一个终端或 SFTP 标签(`HostStore::is_external`)。「临时连接」:标题栏「临时连接…」打开,功能齐全。两者共同的「不保存」在代码里叫 temporary(`HostInfo::temporary`)。
- **全是真的。** SQLite(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)、本地终端(`portable-pty` + `alacritty_terminal`)、SSH(`russh`)、SFTP(双栏、传输队列、断点续传)、端口转发(`-L` / `-R` / `-D`)、凭据、连接方式(直连、多级跳板、HTTP / SOCKS5 代理)、右侧栏七个工具、内置编辑器和预览、外部 CLI、在线升级、匿名使用统计(Aptabase)。密码和口令只进系统钥匙串(`keyring`),**数据库里永远不出现秘密**。
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
## 已定的产品决定
用户定过的事,不要改回去。细节以 `docs/manual.md` 和代码为准。
- **界面语言**:「设置 › 外观 › 界面语言」有跟随系统(默认:中文系统用简体中文,其余用英文,系统没说时用中文)、简体中文、English。改了立刻生效,不用重启。老用户的 settings.json 里已存着 `zh-CN`,不迁移。框架用 gpui-kit 自带的 `rust-i18n`,不另引 Fluent 之类。
- **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。SFTP 标签暂不做目录同步(外部 CLI 有 `shellrs sync`)、过滤、查找、目录树和 Dock 布局持久化。
- **主机树**:搜索匹配主机(名称、地址、用户)和分组名;分组名匹配时列出整个分组,回车只连本身匹配的第一台主机(按树的顺序)。首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
- **主机对话框**:
- 认证方式三选一:「密码」(留空就每次连接时询问,不悄悄试密钥)、「使用凭据」、「无密码」(依次试服务器免认证、SSH Agent、`~/.ssh` 默认私钥,服务器要密码就报错、不询问)。主机自己没有私钥文件,要用私钥就建密钥凭据。
- 「测试连接」是真登录,不强制填密码。
- 连接方式三选一:直接连接、SSH 跳板、代理连接。跳板只走列出的链(跳板自己的连接方式不参与),不做拖动排序;删掉的跳板留「已删除的主机」占位,连接时直接报错。代理是 HTTP `CONNECT` 或 SOCKS5,失败直接报错、不弹问答。跳板和代理不叠加。
- **凭据**:侧栏第三个列表(密码 / 密钥 / SSH Agent),凭据带用户名。
- 密钥的来源「本机文件 / 粘贴 / 生成新密钥」在同一个对话框里(Ed25519 默认,另有 RSA 4096)。粘贴和生成的私钥存成数据目录 `keys/` 下的文件,不进钥匙串:Windows 凭据管理器装不下 RSA 私钥。
- 删除仍在用的凭据要确认,那些主机改回自己登录(密码凭据的改成「密码」,其余改成「无密码」)。
- 主机对话框里不做「新建凭据…」(不叠弹层)。
- **端口转发**:侧栏第二个列表,不是 Dock 标签;标题栏「ShellRS」后面三个图标切换主机 / 端口转发 / 凭据。
- 一条规则经由一台保存的主机,自己建连接:不算主机的连接状态,主机「断开连接」不停它;删除主机连同规则一起删。
- 「创建」只保存不启动,可设「启动 ShellRS 时自动开启」;新建时「经由主机」留空,由用户自己选。
- 断线按 1、3、10 秒自动重连三次,重连不弹问答。
- 对话框是一行三张类型卡片、实时示意图(服务器按角色叫「SSH 服务器」)、一句话说明和一直循环的流向动画。
- **临时连接与外部连接**:
- 临时连接的密码只在内存。
- 外部连接只认命令行参数,不注册系统的 `ssh://` 链接;打开时收起左侧栏。
- 外部 CLI 照样列出这两种主机,分组写「(未保存)」。
- **外部 CLI**:`hosts` / `credentials` 的 `list`、`show`、`create`、`update`、`delete`,以及 `exec`、`upload`、`download`、`sync`,写法对齐 termark 的 skill。对象叫 host / host-id,不学 termark 的 asset(ShellRS 只有「主机」一种)。没有顶层的 `shellrs list`(早先的写法,已去掉,不兼容),列主机用 `hosts list`。不做交互式的 `shell`(给人在自己终端里用,Agent 用不上)。
- 改主机和凭据跟着「启用外部 CLI」走,不另设开关;校验和文案同对话框。
- 秘密只写不读:JSON 里的 `password` / `passphrase` / `private_key` 进钥匙串或 `keys/`,任何输出都没有,`show` 只说有没有保存,ShellRS 保存的私钥连路径也不给。
- 删除不确认:`hosts delete` 遇到有打开标签的主机报 `host_in_use`,`--force` 才关标签;删凭据时用它的主机改回自己登录。临时连接和外部连接只能 `show`。
- `sync` 只从本机到主机,大小和修改时间都没变的跳过;`--delete` 不删 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录不清理,拒绝同步到 `/`。
- `exec --terminal`(`exec --json` 里 `"terminal": true`):给只允许登录一次的堡垒机(外部连接上 exec 重新登录会被拒,另开通道又会断会话)。把命令打进这台主机已打开的远程终端(右侧栏跟的那个,否则最新已连接的),所有远程终端都能用,不限外部连接。先发 ^C,**不做忙碌检测**(用户定的:用的人有基础,交给 Agent 的标签由用户负责;别加回光标行、密码提示之类的检查),只拒绝备用屏和同一终端已有一次运行。只支持 Linux 上的 bash、dash、busybox ash。输出经管道(没有颜色和分页器)、stderr 并入;`--json` 多一项 `context`(user、host、cwd、shell、interp)。
- **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。
- **右侧栏**:窗口最右一列竖排的工具切换。
- 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。
- 中间区分屏时不隐藏:跟着最近一次在前(切到或焦点点进)、而且还在屏幕上的远程终端;点进另一半的 SFTP、本地终端等不换;屏幕上一个远程终端都没有才隐藏。标题写出主机(「系统监控 · web-01」),状态栏也跟焦点走。用户比较过「分屏就隐藏」,选了这个。
- 工具从上到下:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。
- 已知不是 Linux 的主机不显示系统监控、网络连接、进程管理、系统服务;已知是 Windows 的再去掉 Docker 和历史命令;外部连接只有命令片段。
- 系统监控每 2 秒读一次(运行时长随读数刷新,不自己走),磁盘每 30 秒,只在显示时读。≥ 70% 黄、≥ 90% 红。
- 网络连接、系统服务、Docker、历史命令的列表不自己刷新:打开、换终端、点刷新、执行命令之后才读。进程管理每 15 秒读一次。
- 结束进程,停止 / 重启服务,停止 / 重启 / 删除容器都先确认。不是 root 时用 `sudo -n`,要密码就报「需要 root 权限…」。
- 系统服务和 Docker 的标签都用 gpui-kit 的下划线 `TabBar`:每个标签按文字和数量的宽度排,放不下时横向滚动,不截断。均分放不下「已停止 202」,英文的 "Containers 12" 也放不下,不管什么语言都这样。
- 历史命令读主机的 `~/.bash_history`,不自己记录;点一条是放进输入行、不执行,另有执行按钮。
- 命令片段所有主机共用(库里预留了「适用范围」列);「点击时自动执行」新建时默认勾上;执行按钮鼠标移上去才出现,位置一直留着。
- **在线升级**:
- 清单地址 `https://dl.shellrs.com/update/v1/{channel}.json`、平台 key 的名字和公钥槽位一经发布就不能改。
- 默认在后台检查并下载,下好、校验完才在标题栏亮按钮(`success` 色,出错时 `warning` 色)。
- 更新对话框不列更新内容:「查看更新内容」打开整页的 `https://shellrs.com/changelog`,另一个按钮是「重启并安装」,没有「稍后」。不点也在退出时装好。
- 「设置 › 关于」三组:「应用更新」三行(当前版本、更新渠道(稳定版 / Beta,运行时切换)、自动升级),「隐私」一行(见下条),「ShellRS」一行「官网」(打开 `https://shellrs.com`)。
- 检查只发 User-Agent,不发安装标识。
- **匿名使用统计**:发给 Aptabase(没有 Rust SDK,自己调 `POST /api/v0/events`;App Key 是 `analytics::APP_KEY`,不是秘密)。「设置 › 关于 › 隐私 › 发送匿名使用统计」默认开,关掉就清空本机攒下的计数。
- 只发版本、系统名和版本、CPU 架构、设置快照(取值固定,主机等数量只报档位)和功能使用次数。不发主机、凭据、命令、文件名、路径、语言,也没有任何标识(Aptabase 用 IP + UA 加每日的盐算日活)。
- 四种事件:`app_installed`(`analytics.json` 记着已报过,送达后才记)、`app_started`、`app_active`(窗口到前台且当天 UTC 还没报过,macOS 藏在后台不算)、`usage`(功能次数,从第一次使用起满 4 小时汇总一条,不是每次使用一条:免费额度每月 2 万个事件)。
- 不用定时器:只在启动、窗口激活、用到功能、改设置时动作,满 4 小时的汇总和失败后的重试都等下一个信号。用户明确不要定时任务,数据有误差可以接受。
- 只有发布构建发;开发构建要设 `SHELLRS_ANALYTICS_DEBUG=1`,发的是调试事件,汇总周期 1 分钟、失败 10 秒后重试(`Pace::Debug`),好在面板上马上看到。
- **编辑器**:中间区的「编辑器」Dock 标签,不用系统程序打开。
- 双击、回车、F4、右键「编辑」都打开它,本地文件也行;新建文件后直接打开。
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
- **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。
- **终端交互**:「选中复制」默认关;「右键行为」无动作 / 粘贴 / 显示菜单,默认显示菜单。本地终端和已保存主机的远程终端里,剪贴板是图(没有文字)时粘贴会把图存成文件并把路径打进输入行,给终端里的 Agent 读;外部连接和临时连接不做。
- **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的灰(浅色是 gpui-kit 默认主题,深色见下条),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。
- 默认深色(ShellRS Dark)不用 gpui-kit 的近黑,用参照微信深色的炭灰:底色 `#1e1e1f`,标题栏、标签栏、状态栏 `#2f2f30`,边框和悬停 `#3b3b3d`,文字 `#e0e0e4`。用户嫌近黑刺眼。次要文字比微信的 `#7b7b7f` 亮一些(`#929296`),保持对比度 ≥ 4.5。侧栏三个列表和「最近连接」的选中行同色(`selected_row_color`):浅色是悬停色三倍的对比,深色那样太刺眼,用加深后的悬停色,列表和表格的悬停只有它的一半(`DARK_HOVER_SHARE`)。两种外观下悬停和选中都要分得开。页面「重置」对卡片和开关各管各的。
- 左栏只列浅色主题,右栏只列深色主题;卡片是名称加示例输出,选中的整张加底色,右上角是蓝底(主题的 `blue`)对勾。
- 只有内置的,浅色、深色各 10 套,默认 ShellRS Light / Dark(底色与界面一致),Solarized 第二,Tokyo Day / Tokyo Night 第三。同一主题的浅、深两版在两栏同一行,没有另一版的(Rosé Pine Dawn、Everforest Light、Dracula、Nord)排最后。只收宽松许可(MIT、Apache-2.0)的配色。不做自定义主题、导入、按主机指定。
- **键盘快捷键**:设置左栏单独一个分类,排在「关键字高亮」下面,分通用、标签、终端、编辑器四组。
- ⌘= / ⌘- / ⌘0 在终端(和它的查找条)里调终端字号(每次 1 像素,经设置写进「终端 › 字号」,范围同那里的 10–28,`FONT_SIZE_RANGE`),在终端外缩放界面(基准字号 12–20,步长 2,不保存)。同主流终端,别改回去。
- 只收主要功能(含新加的下一个 / 上一个 / 切换到标签 1…9),不收 SFTP 文件列表、图片预览的键和 Enter、方向键、Tab、Esc 这类固定键。
- 每行:按键(点了录制「按下组合键」,Esc 或失焦放弃)、禁用、恢复默认。键要带 ⌘ / Ctrl / Alt,或是 F1–F24,或是 Shift 加 Insert / Delete / Home / End / Page Up / Page Down(方向键上方那六个,不挡打字;单独按仍给终端和列表)。撞上同一处的另一个快捷键或输入框等内置键就拒绝并提示占用者,不抢、不允许重复。
- 存在 `settings.json` 的 `shortcuts`,只存和默认不同的(`null` 是禁用)。`Shortcut` 的 `id` 一经发布不能改。
- **关键字高亮**:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在 `settings.json`,所有终端共用。
- 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit `ColorPicker` 加 `#rrggbb` 输入框)、通知、删除(不确认)。规则都是正则,区分大小写;颜色是用户数据,任意 RGB,不跟主题;不做加粗。重叠时靠前的优先。
- 总开关默认关闭。首次(文件里没有这一项)预置三条示例:ERROR 红、WARN 琥珀、IPv4 蓝,各自启用、都不通知,打开总开关就能用;用户删光后保持为空。空的新行照样保存。
- 通知同响铃:看不到那个终端才提醒,同一终端 10 秒一次,标题用备注。全屏程序(备用屏)里不高亮也不通知。
- **预览**:图片和 Markdown(不做 HTML),显示在快速查看式的大对话框里,不是标签。
- 双击图片预览;Markdown 和 SVG 是文本,双击编辑,右键另有「预览」。
- 图片默认适合窗口居中,可放大、缩小、看原图、滚动;比例只写百分比。
## 常用命令
```bash
@@ -24,6 +99,7 @@ cargo test --lib # 只跑单元测试
cargo test --test ui # 只跑 UI 集成测试
cargo test --test ui new_host -- --nocapture # 按名称片段跑单个 UI 测试
cargo test --test cli_bin # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑)
cargo test --lib terminal::run -- --include-ignored # exec --terminal 对 Docker 里的真实 shell(CI:shells.yml)
cargo test --lib update:: # 在线升级;装了 minisign 和 jq 时还验证发布脚本签出的清单客户端认
cargo clippy --all-targets -- -D warnings # 必须无警告
cargo fmt --check
@@ -33,147 +109,282 @@ security find-generic-password -s shellrs -a "password:<user>@<host>:<port>" #
cargo test -- --ignored # 会读写真实钥匙串的测试,默认跳过
```
工具链:`rust-toolchain.toml` 固定 Rust 1.98.1,因为 `gpui-pre` 用到了 1.94 尚未稳定的 std API。`Cargo.lock` 把 `gpui-pre*` 系列固定在 0.3.2(gpui-kit 0.6.1 发布时配套的版本),不要随手 `cargo update` 它们,除非确认仍能编译。`cargo test` 需要 gpui-kit 的 `test-support` 特性,首次会再编译一遍(同样慢)。
工具链:`rust-toolchain.toml` 固定 Rust 1.98.1,因为 `gpui-pre` 用到了 1.94 尚未稳定的 std API。`Cargo.lock` 把 `gpui-pre*` 系列固定在 0.3.2(gpui-kit 0.6.1 发布时配套的版本),不要随手 `cargo update` 它们。`cc` 停在 1.2.x,因为 SQL 高亮的 `tree-sitter-sequel` 要求 `cc ~1.2`。`cargo test` 需要 gpui-kit 的 `test-support` 特性,首次会再编译一遍。
本终端的 `screencapture` 被 macOS 权限拦截,视觉检查只能靠 UI 集成测试或让用户看窗口。
## 架构
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分:功能模块不依赖 `workspace`(窗口壳),也不碰彼此的内部实现。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`(`Latency` / `LatencyLevel` 在 `connection.rs`,画法是 `shared::LatencyLabel`)。SFTP 标签同样显示:工作线程另起一个 `measure_latency` 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 `SftpEvent::Latency`;`ExplorerPanel` 在 `Connecting` / `Disconnected` 时清掉,只在已连接时由 `title_suffix` 显示。这个任务只在一次 ping 期间攥着 `Arc<SftpClient>`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「SFTP 空闲时也要发现断网」的约束)。
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `forward/` — 端口转发。规则的模型在 `host/forward.rs`,这里是运行和界面。
- `transport.rs`:可注入的接口 `ForwardTransport` / `ForwardTransportProvider`,命令 `ForwardCommand`(`PromptReply`、`Stop`)和事件 `ForwardEvent`(`Connecting`、`Prompt`、`Listening`、`Connections(n)`、`ConnectionFailed`、`Reconnecting`,以及两个终结事件 `Failed` / `Stopped`)。
- `worker.rs`:真实实现 `SshForwardTransportProvider`。每次启动一条规则就是一个线程(`shellrs-forward`)加一个 current-thread runtime,结构照 `sftp/worker.rs`;本地转发是 `channel_open_direct_tcpip` 加 `copy_bidirectional`,远程转发是 `tcpip_forward` 加处理器交出来的通道,动态转发先过 SOCKS 握手。`describe_*` 把错误映射成中文。
- `socks.rs`:SOCKS5(无认证、`CONNECT`)与 SOCKS4 / 4a 的纯函数解析(`Parsed::{Incomplete, Done, Invalid}`)加一个喂字节的 `negotiate`。没有引入 SOCKS 依赖。
- `manager.rs`:`ForwardManager` 实体,持有每条在跑的规则的工作线程和状态 `ForwardStatus`,每 50 ms 轮询事件(不从后台线程唤醒 GPUI);自己订阅 store:规则被删(含随主机级联)就停,`ForwardSettingsChanged` / `ConnectionSettingsChanged` 就重启在跑的。
- `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。
- `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。
- `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
- `protocol.rs`:套接字上的帧:1 字节类型(`J` JSON / `1` stdout / `2` stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;`ErrorCode` 是给 Agent 判断用的稳定英文码。`Request::Activate` 不属于 `shellrs` 命令,是 ShellRS 被再次打开时发的(见下面「一个数据目录只跑一个 ShellRS」)。
- `client.rs`:命令这一端,连 `app::cli_socket_path()`(即 `app::cli_endpoint(data_dir)`:Unix 上是 `<data_dir>/cli.sock`,Windows 上是 `\\.\pipe\shellrs-cli-<小写的数据目录绝对路径的 sha256 前 16 位>`,所以 `SHELLRS_DATA_DIR` 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。`connect` 按平台实现,读帧循环 `talk` 共用。进度行用 `\r` 加补空格覆盖,不用 `\x1b[2K`(老 conhost 不认)。
- `pipe_windows.rs`:Windows 命名管道的 Win32 调用(`windows-sys`):带 DACL 的实例、`ConnectNamedPipe`、客户端的打开与属主校验。
- `server.rs`:`CliServer` 与可注入的 `CliBackend` trait;`backend.rs` 是真实实现 `SshCliBackend`(exec 走 `ssh::run_command`,传输走 `sftp_provider`)。
- `install.rs`:把 `shellrs` 放进 PATH(macOS / Linux 是符号链接)、Agent skill 的安装 / 更新 / 移除,纯文件系统操作,路径全来自 `IntegrationPaths`;`SKILL.md` 是 skill 正文(`include_str!`)。`install_windows.rs`:Windows 的做法(复制 + 用户 PATH),`cfg(any(windows, test))`,所以在 Mac 上也跑它的测试。`integration.rs`:设置页用的状态实体 `CliIntegration`。
- `update/` — 在线升级,不依赖 `workspace` 和 `settings`(只用 `app` 的路径和动作)。
- `build_info.rs`:编译期由发布流程注入的 `SHELLRS_UPDATE_CHANNEL`(stable / beta;没有就是开发构建,从不检查;有的话只决定「更新渠道」的默认值,实际查哪个渠道由设置决定,`UpdateServices::release_build`)、`SHELLRS_COMMIT`、`SHELLRS_BUILD_DATE`、`SHELLRS_APPLE_TEAM_ID`,清单地址 `MANIFEST_URL`,以及发布公钥 `TRUSTED_KEYS`(两个槽位:日常用的和离线备用的;为空时 release workflow 拒绝发布)。
- `manifest.rs`(纯函数):清单是一个**信封** `{manifest: <清单原文>, signature: <minisign 签名>}`,一个文件、一次请求,不会出现清单和签名缓存不同步;`Manifest::offer(当前版本, 平台 key, 本机抽签)` 给出 `Offer::{UpToDate, Update(Release), Manual}`。格式只加字段:不认识的字段忽略,`schema` 比 `SCHEMA` 大的清单只读出版本、让用户去下载页。平台 key 是 `macos-aarch64` / `macos-x86_64` / `windows-x86_64` / `linux-x86_64`(`platform.rs`),macOS 两个 key 指向同一个 universal 包。
- `verify.rs`:`open_envelope` 先验签再解析,签名的 trusted comment 必须是 `shellrs-manifest <通道> <版本>` 且和清单一致(防拿旧清单冒充新版本、拿 beta 清单冒充 stable);安装包靠清单里的大小和 SHA-256(`verify_file`)。debug 构建额外信任 `SHELLRS_UPDATE_PUBLIC_KEY`,release 构建只信编进去的。
- `feed.rs`:`UpdateFeed` trait(阻塞,只搬字节,所以假 feed 绕不过验签)与真实的 `HttpFeed`(reqwest blocking,默认特性里的 rustls + 系统代理:从 Finder / 开始菜单启动的进程没有 `HTTPS_PROXY`);下载写 `.part`、用 Range 续传、`urls` 依次回退,`timeout` 在阻塞客户端里是每次读的超时,所以等于停滞判断;超时被包成「解码响应体出错」,`read_error` 从内层的 `reqwest::Error` 认出来。debug 构建可用 `SHELLRS_UPDATE_URL` 指向本地服务器。客户端在工作线程上按任务新建(阻塞客户端自带 runtime,不能在 async 上下文里建或丢)。
- `install.rs`:纯函数 `detect` 决定 `InstallKind`(`MacBundle` / `WindowsSetup` / `AppImage` / `Unsupported(..)`:开发构建、App Translocation、DMG 里运行、没写权限、不是安装程序装的、不是 AppImage);`Installer` trait 的 `stage` 在工作线程上做慢的部分,`apply` 在 UI 线程上只改名或准备一个进程,返回 `Relaunch::{Restart(path), Hand(Launch), Nothing}`。平台实现 `install_macos.rs`(`ditto` 解到 app 旁边同一卷上的 `.ShellRS.app.update-<版本>`,`codesign` 核对 Team ID,两次改名交换,第二步失败就换回去)、`install_windows.rs`(隐藏的 PowerShell 等 ShellRS 退出后静默运行新版本的安装程序,再按需启动 ShellRS)、`install_linux.rs`(复制到 `$APPIMAGE` 旁边,一次改名覆盖;重启用 `$APPIMAGE`,current_exe 是会消失的挂载点)都在 `cfg(test)` 下也编译,Mac 上能跑它们的测试;外部命令在 `MacTools` 之类的接缝后面。
- `updater.rs`:`Updater` 实体,阶段 `Phase::{Off, Idle, Checking, UpToDate, Available, Downloading, Verifying, Ready, Installing, Failed{stage, error}}`。每个任务一个 `shellrs-update` 线程,只在有任务时每 100 ms 轮询事件(同端口转发),新任务取消旧任务、丢掉旧任务的事件。`start()`(只在生产路径调用):报告上一次更新(读 `applied.json`)、30 秒后清理并第一次检查、之后每 6 小时(带抖动)、`on_app_quit` 时把 Ready 的版本装好。下载进度只 `notify`(设置页和对话框观察 `Updater`),阶段变化才发 `UpdaterEvent::Changed`,工作区只订阅它来重画标题栏。
- `status.rs`(纯函数):设置页的状态行、标题栏按钮 `UpdateBadge`、哪些按钮可用、设置页和对话框共用的下一步 `UpdateStep`(`UpdateSnapshot::step`),以及 `restart_note(RestartImpact)`。`update_dialog.rs`:更新对话框。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、远程终端和 SFTP 标签栏右侧的延迟 `LatencyLabel`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、校验错误的通知 `form_error_notification` 与收起它的 `dismiss_form_error`(见下面「表单的校验错误一律弹错误通知」)、端口字段的 `parse_port` 和删除确认 `confirm_delete`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。
- `app/`:
- `actions.rs` 是全部用户命令;带 id 的动作由 `id_actions!` 声明。
- 另有快捷键、数据目录路径(`paths.rs`)、额外的 Lucide 图标(`CatalogIcon`)、操作系统 logo 和品牌图标(单色 SVG)、退出守卫(`quit.rs`)、macOS 关窗口即隐藏(`window_hiding.rs`)。
- `host/`:主机、分组、凭据、端口转发规则和命令片段的模型(它们同库、随主机级联删除,所以都放在这里,host 不依赖其他功能模块),以及:
- `HostStore`:内存是唯一事实来源,写穿到 SQLite;
- `database.rs`:`SCHEMA`、迁移;
- `login.rs`:登录快照 `HostLogin`;
- 主机树面板、主机和分组对话框、快速连接、`ssh_link.rs`(解析堡垒机链接)。
- `connection.rs`:问答协议(`ConnectionPrompt`…)、`Latency`、测试连接的 `LoginTest` / `ConnectionTester`。
- `ssh/`:
- `SshConnector`:所有登录都走它(终端、SFTP、exec、测试连接、端口转发、外部 CLI)。
- 远程终端的传输:pty、系统探测、延迟、旁路 exec。
- 代理握手;外部 CLI 用的 `run_command`。
- `terminal/`:本地 PTY、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、关键字高亮(`highlight.rs`)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。
- `sftp/`:可注入的传输和本地目录接口、`RemotePath`、`RemoteFs` 协议适配、worker(批次、三次重连、空闲断网检测),以及:
- `upload.rs` / `download.rs`:断点续传,续传记录由 `journal.rs` 管;
- `operations.rs`:删除、重命名、新建、改权限;
- `edit.rs`:编辑器和预览的整文件读写。
- `explorer/`:SFTP 标签的界面:两侧面板、列表、路径标签、各种对话框、传输队列,以及:
- `file_edit.rs`:给编辑器和预览读写文件;
- `preview.rs` / `image_preview.rs`:预览对话框和可缩放的图片。
- `editor/`:编辑器标签(`EditorPanel`),语言、缩进和状态栏文字是 `model.rs` 的纯函数。
- `forward/`:端口转发的运行(`ForwardManager`、每条规则一个工作线程、SOCKS 解析)和界面。`credential/`:凭据的界面。
- `monitor/`、`netstat/`、`processes/`、`services/`、`docker/`、`history/`、`snippets/`:右侧栏的七个工具。各自是「命令(`linux.rs` 等)+ 纯解析和模型(`model.rs`)+ 面板」,确认和通知在工作区。
- `secrets/`:`SecretStore`(钥匙串)、`SecretRef`(条目归属)、`TemporarySecretStore`(不保存的连接的密码只在内存)。
- `settings/`:`AppSettings`、写 `settings.json` 的 `SettingsStore`、把设置落到窗口上的 `apply`、设置标签。
- `cli/`:外部 CLI(`shellrs hosts/credentials/exec/upload/download/sync`,给 AI Agent 用)、套接字协议、应用里的服务端、主机和凭据改动的校验(`manage.rs`)、PATH 和 Agent skill 的安装。
- `update/`:在线升级,包括清单、验签、下载、各平台安装器和 `Updater`。
- `analytics/`:匿名使用统计。`model.rs` 是纯的计数、事件和发送时机(`Reporter`),`system.rs` 用 `os_info` 读系统名和版本,`client.rs` 是投递线程和工作区拿着的 `Analytics` 句柄;工作区的接线在 `workspace/analytics.rs`。
- `workspace/`:窗口壳。持有 store、`DockArea`、各类标签的注册表和**全部动作处理器**,按领域拆成多个文件(`forwards.rs`、`credentials.rs`、`links.rs`、`editors.rs`、`tools.rs`、`updates.rs`…)。另有标题栏、状态栏、左侧栏 `Sidebar`、右侧栏 `ToolSidebar`、开始页。
- `shared/`:多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`SegmentedControl`、`RowTooltips`、`confirm_danger`、`form_error_notification` 等)。
关键流程与不变量:
## 关键约定与坑
- **传输队列在标签这一层,引擎一次只跑一批。** `ExplorerPanel` 持有 `TransferQueue`:确认框派发的 `ExplorerCommand::Enqueue(TransferJob)` 只是入队(请求在入队时就校验,失败直接报错不入队),`start_next_if_idle` 在引擎空闲(`engine_busy` 为假,它由发出 Upload / Download / Resume / Discard 置真、由引擎的 `Idle` 清掉)且已连接时把第一个排队中的批次交出去。引擎处理完每个命令都会发 `Idle`(完成、停止、丢弃、准备失败都一样),标签收到后才推进,所以前一批的事件不会串到下一批上。进度事件只归队首(运行中或已停止的那一批),没有队首时丢弃(例如丢弃续传进度后引擎回的空 `Completed`)。**已停止的批次挡在队首**:引擎还留着它等「继续」,开始新批次会把它换掉,所以要等用户继续、丢弃续传进度或「移出队列」(续传记录留在磁盘上)之后队列才往下走。确认框和询问框分开记录(`question_shown`):批次停止或结束时只关询问框,不碰用户正在填的下一批的确认框。`TransferProgress` 为队列补了当前文件(`current_source` / `current_bytes` / `current_total`)、只在传输阶段累计的用时 `elapsed`、最近 3 秒的速度(`sftp/speed.rs` 的 `TransferClock`,等待回答和重连时归零)、跳过和失败文件的字节 `settled_bytes`(`fraction()` 因此能到 100%,`remaining()` 按剩余字节除以速度)和重连说明 `note`(`current` 始终是路径)。队列面板和双栏在同一个 `v_resizable` 里,队列为空时那一块 `visible(false)`,双栏在元素树里的位置不变,分栏比例不丢。
- **SFTP 独立于终端。** 主机状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除主机会停止传输并保留续传数据。每个 SFTP 标签的传输引擎一次跑一个批次,其余在标签的传输队列里等,上传和下载共用(`TransferBatch`,类型统一叫 `Transfer*`,方向在 `TransferProgress::direction`),来源、端点和目标在确认时固定。SFTP 标签按 `ExplorerId` 登记(工作区的 `explorers`,`explorers_of(host)` 列出一个主机的全部 SFTP 标签),`ExplorerAction`、`CloseExplorer`、`RenameExplorer`、`CenterTab::Explorer` 和 SFTP 认证问答的 `PromptOwner::Sftp` 都按标签 id 寻址;按主机做的事(连接状态汇总、断开、删除主机 / 分组、统计要停止的传输)要遍历 `explorers_of`。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **下载严格按顺序写盘。** 读请求并发且可能乱序、短读返回,`download_file` 用重排缓冲区按偏移顺序写入 `.filepart`,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。
- **文件操作从不跟随符号链接。** 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走 `trash` crate 的 NSFileManager 方式(不弹自动化权限、不放声音)。文件操作与传输批次可以并行;同一侧面板有操作在跑时(`FilePane::is_busy`)第二行工具栏和对应命令都不接受新操作,结果回来后面板重新读目录。
- **选择归面板,不归表格。** `DataTable` 只支持单行选择,所以表格设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`(`..` 永远不进选择集),通过 `ListingContext` 快照交给委托;同 WinSCP 关掉「整行选择」,**名称单元格才是项目**:`render_td` 给出外层 `name-cell:<名称>`(`size_full`,画悬停灰底、选中底色和光标框,接单击、双击打开和右键,`FileListing::name_events`)和里面只有内容宽的 `name:<名称>`(图标加文件名,只接文件拖动)。所以在名称单元格里任何地方单击都选中这一行,但只有拖图标和文件名才是拖文件,从名称右边的空白拖是框选。行上不画任何状态:`DataTable` 在 `render_tr` 的样式之后给整行加 hover 底色、我们改不掉,所以行的第一个子元素是一块铺满的 `tokens.table` 底色(表格自己的不透明背景)把它盖住,单元格画在它上面;右键时表格给整行画的边框也在容器的右键处理里立刻清掉(`press_empty` 每次都 `clear_menu_row`)。为了连单元格的内边距一起铺满,名称列用 `name_column` 把表格的单元格内边距设成 0,名称单元格和它的列标题各自 `px_1p5()`(等于 `.small()` 表格的 6 px,文字因此和标题对齐);`Size::table_cell_padding` 不是公开 API,拿不到表格自己的值。单击、双击打开、右键都挂在名称上(`FileListing::name_events`),行上只剩拖放目标和记几何的 canvas。选中的名称一律只有底色,光标框只画在未选中的光标项上(`..`、刚用 ⌘ 取消选择的项),否则多选的最后一项会多出一圈边框。名称单元格的 `on_click` 读 `ClickEvent` 的修饰键和点击次数,直接更新面板(这是事件时刻,不是渲染时刻)。名称单元格和列标题以外都是空白处:按下落在哪里记在 `ListGeometry::pressed`(`Pressed::Empty` / `Name` / `Title`,容器在捕获阶段复位成 `Empty`,名称和标题在冒泡时改写),容器的 `on_click` 和右键按下在 `Empty` 且没按 ⌘ / Shift 时清空选择(`FilePane::press_empty`,连光标一起清)。表格底部的横向滚动条会在按下时停止冒泡,点在它上面不算空白处。键盘上的方向键 / Home / End / 翻页绑在 `"LocalFileList > DataTable"` 这一层,盖过表格自带的单行选择键;←、→ 绑 `NoAction`,因为表格在行模式下也会把它们切成列选择。
- **文件列表快捷键按焦点路由。** 一个动作 `ExplorerShortcut(ExplorerCommand)`,侧别写进绑定本身;工作区找 focus handle 含焦点的那个 SFTP 面板(`contains_focus`),不用最近激活的标签,否则两个 SFTP 分栏并排时会发错。同一命令绑多个键时,最后注册的那个会显示在 tooltip 里(`tooltip_with_action`),所以 WinSCP 的键放最后。菜单项派发 `ExplorerAction`,按动作相等查不到绑定,菜单里不显示快捷键。
- **框选同 WinSCP 关掉「整行选择」。** 拖文件去另一侧的 `on_drag` 只挂在名称单元格(图标加文件名,`FileListing::drag_from`)上,不挂在行上;名称上不带 ⌘ / Shift 的左键按下会在按下时就把未选中的项单独选中(`FilePane::select_pressed`,右键按下也用它),所以拖走的就是高亮的:未选中的项单独走,已选中的项带着整个选择走(拖动的载荷在 render 时按选择算好,按下前后算出的都是这一项,不会不一致)。拖动中只有指针在接收它的另一侧列表上(且可以传输)时才显示「上传 / 下载 N 个项目」并用 `DragCopy` 光标,其余地方(包括自己的列表)不显示预览、光标是 `OperationNotAllowed`(WinSCP 同):载荷带一个 `DropSpot`,接收侧容器的 `on_drag_move` 在指针落在自己范围内时记下指针位置,预览在 render 时比较它和 `window.mouse_position()`。存位置而不是布尔值,是因为 `on_drag_move` 会通知所有监听这个类型的元素(并排的另一个 SFTP 标签也会收到),「不在我这儿就清掉」会被后调用的覆盖。光标由预览里一个 canvas 在 paint 时 `set_window_cursor_style`:拖动层最后绘制,所以它的光标请求最后一个入栈、生效;测试平台不暴露光标,这点只能肉眼确认,测试只查预览 `file-drag-preview`(`aria_label` 是显示的文字)。列表容器 `("file-list", side)` 自己挂一个 `MarqueeDrag`,它带着开启它的面板的 `EntityId`:GPUI 的 `on_drag_move` 会把同一类型的拖动通知给**所有**挂了监听的元素,不管是谁开启的,所以监听里要核对 `event.drag(cx).pane == cx.entity_id()`,否则另一侧面板(以及别的 SFTP 标签)会跟着画框、改选择。所以从名称以外按下拖动就是框选(GPUI 的拖动由最上层先达到阈值的元素开启并停止冒泡,名称的拖动因此优先;滚动条在按下时就停止冒泡,不会触发框选)。容器在按下时记下位置,从列标题(`Pressed::Title`)开始的拖动不画框,其他地方都画(名称的图标和文件名有自己的文件拖动,GPUI 由它先开启,所以轮不到框选)。行高固定,每行用绝对定位的 `canvas`(**必须** `top_0().left_0()`)在 prepaint 记下「第一行顶端的位置和行高」(`ListGeometry::rows`),选框的起点按「距第一行顶端」保存,所以列表滚动时起点跟着内容走;框住哪些行由纯函数 `selection::swept_rows` / `row_at` 算,`Selection::sweep` 合并(开始时按住 ⌘ 或 Shift 则加到原有选择上)。拖到边缘用 `gpui_kit::base::AutoScroll` 滚动表格的 `vertical_scroll_handle`。拖动中 GPUI 每画一帧都会清掉各元素挂起的按下(`div.rs` 里 `has_active_drag` 时清 `pending_mouse_down`),所以松开时起点那个名称单元格不会再收到单击;拖动开始和松开落在同一帧里(快速一甩)时这条不成立,名称单元格的单击因此还用 `ends_a_drag`(按下和松开相距超过 GPUI 的 2 px 拖动阈值)排除掉;松开(`on_mouse_up` / `on_mouse_up_out`)时清掉选框并停止滚动。选框是容器里的绝对定位 `selection-rectangle`,夹在列表范围内。
- **列表右键菜单挂在列表容器 `("file-list", side)` 上**,和主机树一样:名称的右键处理器先把没选中的项选中、把 `MenuHit::Item(名称)` 写进 `menu_hit`(右键空白处则清空选择,给空白处菜单),列标题(委托的 `render_th`)写 `MenuHit::Column(列键)`,容器在捕获阶段清空它;容器的菜单构造器按命中给出文件菜单、「大小」标题的「文件大小显示为」(其他标题给空菜单,不弹出)或空白处菜单。**不要在标题或行上另挂 `context_menu`**:菜单的右键监听不阻止事件冒泡,外层容器的菜单会一起弹出。大小格式 `explorer::FileSizeFormat` 存在 `AppSettings::file_size_format`(`settings.json`),由 `settings::apply` 设成全局量(explorer 不依赖 settings,同终端字体),菜单项派发 `SetFileSizeFormat`,工作区写设置;委托的 `context_menu` 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。
- **`Select` 必须放进有尺寸的容器。** 它的外层是 `size_full()`,直接放进工具栏行会盖住整行,别的按钮全点不到。
- **SFTP 面板的工具栏永远一行高,放不下的按钮整个藏起来。** 折行会让这一侧的列表比另一侧低一截,所以工具栏行是 `flex_wrap` 加固定一行高(`h_6`,和 small 按钮、下拉框一样高)加 `overflow_hidden`:放不下的按钮折到被裁掉的第二行,整个隐藏(不会只露半个),从最右边的开始;命令在菜单和快捷键里都还有。边框和内边距在外层 `frame` 上,行高才不含它们。`sftp_controls_fit_small_window_in_light_dark_and_zoom` 查小窗口大字号下每个按钮要么完整可见要么不可见、两侧列表顶端对齐。新标签的两侧对半分(两个 `resizable_panel` 都 `flex_basis(relative(0.5))`,只管第一帧,之后分栏状态记下实际宽度),默认窗口(1280 px)开着侧栏时每侧约 500 px,第一行(目录下拉框 `rems(10.)`)约 404 px,放得下(`sftp_panes_open_half_and_half` 查按钮都可见、两侧列表顶端对齐)。改工具栏按钮或下拉框宽度时要重新算这笔账。
- **断开时对远程侧的操作要有回应。** `ExplorerPanel::execute` 一进来先看 `needs_connection(command)`(远程侧的导航、打开、删改、属性,以及所有传输;选择、书签、复制路径、队列按钮不算):标签是「未连接」时不执行,弹出「与 主机名称 的 SFTP 连接已断开」(说明是远程面板的 `problem`),确定按钮「重新连接」派发 `ExplorerCommand::Reconnect`;`can_reconnect` 为假(队列里有停止的批次,或传输正在自动重连)时只有「知道了」并说明该怎么办。为了让这些命令发得出来,远程面板在「未连接」时不禁用工具栏、菜单和路径标签(`FilePane::takes_commands`:只有「连接中」才禁用),传输按钮和拖放在「未连接」时也可用(`sync_available` 的 `transfer`)。
- **SFTP 标签自己持有分栏状态。** 双栏之间和双栏与队列之间的分隔线位置是 `ExplorerPanel` 的 `panes_split` / `queue_split`(`ResizableState` 实体,`with_state` 交给 `h_resizable` / `v_resizable`)。不给 `with_state` 时分栏用 keyed element state,标签一不渲染(切到别的标签)就丢,切回来回到初始宽度(`sftp_panes_keep_their_split_across_tab_switches`)。和 Dock 布局一样只活在内存里,不持久化。
- **路径标签按上一帧的宽度折叠。** render 时拿不到布局宽度,所以 `path-parts` 里放一个 `canvas`,在 prepaint 记下宽度,变了就用 `on_next_frame` 通知面板重画(绘制阶段里 `notify` 会丢)。各段宽度在 render 里用 `text_system().shape_line` 按 `text_sm` 量,和实际布局一致(各段不加内边距,否则 `/ home/ tester/` 看起来像多了空格)。折叠规则是纯函数 `folded_parts`:留住根,中段换成 `…`,末尾尽量多留几级;`…` 点了去它藏起的最后一级(WinSCP 同)。「当前面板」是最近获得焦点的一侧(`on_focus_in`,必须 `defer_in`,见下条),标签重新显示时焦点回到它;界面上不标出来(路径标签两侧一样是 `list_active` 底色加普通下边框):以前给当前面板加一条 `list_active_border` 下划线,用户看它像「这一侧处于激活状态」,而它和命令实际作用的一侧对不上。
- **焦点监听器里改渲染状态要 `defer_in`。** `on_focus_in` / `on_focus` / `on_blur` 在 `draw` 的焦点阶段同步执行,这时 `cx.notify()` 只记下脏视图、不置窗口脏也不排新帧,改动要等下一次别的原因重绘才看得见。点列表空白处(GPUI 对 `track_focus` 元素在按下时自动聚焦,没有别的处理器触发重绘)因此要点两下才切换当前面板;改为 `cx.defer_in` 在绘制结束后执行就好。测试 `sftp_clicking_empty_list_space_makes_that_pane_current_at_once` 不自己 `render_frame`,只看点击本身触发的帧。
- **SFTP 空闲时也要发现断网,和终端同时。** 终端靠主循环一直 `channel.wait()`,russh 的 keepalive(`SshConnector` 里 30 s × 3,所有 SSH 连接共用)一关掉连接它就返回。SFTP 连接同样会被关掉,但 russh-sftp 只在下一次请求时才报 `session closed`,而且不公开关闭状态。所以 `SftpClient` 把通道流包一层 `WatchedStream`:读到结尾或出错就置位一个 `watch`,`SftpClient::closed()` 等它;工作线程空闲等命令时同时等它,一触发就 `forget` 这个连接并发 `Disconnected`。等待时只持有 `Weak` 和那个 `watch` 接收端,**不能持有 `Arc<SftpClient>`**:主动「断开」靠丢掉最后一个引用来关连接,监听攥着强引用会让 SSH 连接一直开着(`an_idle_connection_that_drops_is_reported_without_a_request` 用服务器端的 `ended` 计数守着)。`is_network_error` 也认 russh-sftp 的 `session closed`。
- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status`(`FilePane::render_status`)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。列表没有行时表格中央的占位文字(委托的 `render_empty`,id `list-placeholder`,文字来自 `FilePane::placeholder`,经 `ListingContext::placeholder` 传进去)也按阶段说:连接中「正在连接 SFTP…」,还没读到过目录(`listed` 为假)时「正在读取目录…」,读到过之后才是「空目录」(重读一个空目录时同样等 `SLOW_LOAD` 才改口);没读成功过时是「未连接」或「无法读取目录」。非根目录总有 `..` 一行,所以「空目录」实际只出现在空的根目录。改加载状态的地方都要 `sync_listing`,否则委托里的占位文字不更新。读不了目录、远程侧断开的原因是面板的 `problem`,显示在窗口状态栏最左边、平时写「已连接 主机名称」的 `status-connection`:SFTP 标签在前时,工作区用 `ExplorerPanel::status`(这个标签自己的连接状态,不是主机汇总的,加上 `problem`,远程侧优先)生成 `WorkspaceStatus::Explorer`,未连接时写「未连接 主机名称:原因」,已连接但读不了目录时只写原因,两种都是 `danger` 红字;主机(终端标签)断开时同样是红字。`problem` 变化时面板发 `FilePaneEvent::ProblemChanged`,标签转成 `ExplorerPanelEvent::StatusChanged`,工作区只在那个标签在前时 `notify`(不观察整个标签,传输进度每 50 ms 一次)。
- **「打开目录/书签」的选中项是推导出来的。** 列表里被选中的就是与目录框(按面板规则展开 `~`、相对路径并去掉末尾分隔符后)相同的书签,没有另存选中状态,和 WinSCP 的 `DirectoryEditChange` 一致:单击书签只是把它写进目录框。书签的增删和排序都派发 `ExplorerCommand`(`AddBookmark { path: Some(..) }`、`RemoveBookmark`、`MoveBookmark`),改动立即写库,取消对话框也不回滚(WinSCP 同)。对话框打开后在 `open_dialog` 同步返回之后立刻 `focus` 目录框并全选,这样能生效(`Root::open_dialog` 是同步聚焦对话框宿主的,之后同一更新里的聚焦会覆盖它);延迟到下一帧的聚焦才会失效。
- **书签按主机、按侧持久化。** `bookmarks` 表对 `hosts` 有 `ON DELETE CASCADE`;`remove_unnotified` / `remove_group_unnotified` 必须同时丢掉内存里被删主机的书签。`BookmarkSide` 放在 host 模块,因为 host 不能依赖 explorer。
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串;`FakeLocalDirectory` 只记录本地操作,不真的改文件。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。
### 状态与持久化
- **端口转发独立于终端和 SFTP:规则在 store,运行状态在管理器。**
- `HostStore` 持有规则(`forwards`,写穿到 `forwards` 表,对 `hosts` 有 `ON DELETE CASCADE`,`remove_unnotified` 同时丢掉内存里的规则,复制主机不复制规则);`ForwardManager` 只持有「在跑」这件事。规则没有运行状态字段,启动应用时全部是停止的,除了 `auto_start` 的(`start_automatic`,工作区构造末尾调用)。
- 转发不进 `refresh_host_connection_state`,`on_disconnect_host` 不碰它,`cancel_prompts_for_host` 也跳过 `PromptOwner::Forward`:主机的「断开连接」只管标签。删除主机 / 分组时工作区不用显式停转发,管理器看到规则从 store 消失就停。
- 认证问答走和终端、SFTP 同一套对话框,归属是 `PromptOwner::Forward(ForwardId, 代次)`,代次是管理器每次启动递增的编号;`StatusChanged` 时工作区丢掉过期代次的问答。转发可能是自动开启的,屏幕上没有任何东西说明谁在问,所以对话框开头多一行「端口转发“名称”正在通过 主机名称 连接。」(`forward_prompt_origin`)。取消问答等于停止(`manager.stop`),不算失败、不弹通知。
- 转发自己失败(`ForwardManagerEvent::Failed`)弹错误通知,因为转发列表可能没在显示。单个连接转发不了(`ForwardEvent::ConnectionFailed`,例如服务器连不上目标)只记成这一行的 `problem`,转发继续跑;连接数变化只 `notify`、不发 `StatusChanged`,繁忙的 SOCKS 代理上它一直在变。
- **转发发现断线靠处理器被丢弃,不靠 `Handler::disconnected`。** russh 0.63.3 在套接字 shutdown 失败时会跳过 `disconnected`(`client/mod.rs` 的 `Session::run`)。`SshClientHandler` 由会话任务持有,任何退出路径都会丢掉它,所以 `connect_forwarding` 把一个 `mpsc` 发送端放进处理器,工作线程 `forwarded.recv()` 读到 `None` 就是连接没了(SSH keepalive 30 s × 3,和终端同时发现)。同一个通道也承载远程转发:`server_channel_open_forwarded_tcpip` 把通道连同 `ChannelOpenHandle` 交给工作线程,连上本机目标之后才 `accept`,连不上就 `reject(ConnectFailed)`,对端看到的是拒绝而不是接受后挂断。别的连接(终端、SFTP、exec)没有这个发送端,服务器主动开来的 forwarded-tcpip 通道一律拒绝。
- **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。
- **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。
- **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。
- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `HostPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
- **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。
- **`ListItem` 里会增长的文字要零宽加裁剪。** `ListItem` 把内容包在两层 `w_full` 里、`suffix` 是它们的兄弟,整行靠内容的最小宽度收缩来给 `suffix` 让位。`flex_1().min_w_0()` 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 `suffix`(状态和启停按钮)挤出列表。转发行的文字列因此是 `w_0().flex_grow(1.).overflow_x_hidden()`(`the_forward_dialog_and_a_long_row_fit_the_smallest_window` 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:`RadioGroup::horizontal` 的那一行是 `flex_wrap`,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 `flex_1().min_w_0().self_stretch()`(那一行是 `items_center`,不拉伸的话高度不齐),卡片里那句用途同样是 `w_0().flex_grow(1.).overflow_x_hidden()`,放不下时在卡片内折行。`Radio` 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(`RADIO_DOT_COLUMN`:small 圆点 `size_3p5` 加 `gap_x_2`)拉回到圆点下面,占满卡片的整个宽度。对话框宽 `rems(42.)` 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 `Radio` 的圆点尺寸或间距时 `RADIO_DOT_COLUMN` 要跟着改,`the_three_kinds_of_forward_share_one_row` 查用途这一行的两端离卡片边缘的距离。
- **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。
- **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。
- **表结构(`SCHEMA_VERSION` 是 13)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件;`STEPS` 目前是空的,`Step` 枚举留着给下一次改表用。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path())`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate` 后直接退出。
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
- 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
- **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动升级」和「更新渠道」存在 `AppSettings::update`(`automatic`、`channel`,后者直接用 `update::Channel`,在 settings 里实现 `Choice`),工作区在 `observe_in(&settings)` 里 `sync_updater`(`set_automatic`、`set_channel`),`update` 模块不依赖 `settings`。动作是 `CheckForUpdates` / `DownloadUpdate` / `ShowUpdate` / `RestartToUpdate` / `OpenDownloadPage`,处理器在 `workspace/updates.rs`;对话框里「重启会中断什么」由工作区数(`restart_impact`:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 `cx.set_restart_path` + `cx.restart()`,它会照常跑 `on_app_quit`(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。**Windows 不在退出过程中起进程**:gpui 退出时在 App 仍被借用的状态下跑 `on_app_quit`,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 `update::start_handed_over()`,由 `main` 在 `run()` 返回之后启动(Windows 的事件循环结束后 `run` 会返回)。更新前写 `updates/applied.json`(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 `app::updates_dir()`(用户缓存目录;设了 `SHELLRS_DATA_DIR` 时在数据目录下)。
- **发布走 tag。** 推 `v<版本>`(和 `Cargo.toml` 一致,`CHANGELOG.md` 里要有 `## [版本]` 一节)触发 `.github/workflows/release.yml`:分平台构建打包(`packaging/macos/bundle.sh`、`packaging/windows/shellrs.iss`、`packaging/linux/appimage.sh`),在需要人工批准的 `release` 环境里上传 R2、建 GitHub Release,**最后**才用 `packaging/make-manifest.sh` 生成并上传清单、清 CDN 缓存。清单格式见 `packaging/manifest.example.json`(`the_example_manifest_parses` 守着它和解析器一致,`the_release_script_signs_what_the_client_accepts` 守着脚本和验签一致)。一次性准备和 secrets 见 `README.md` 的「发布」。
- **库只往前升,旧版本不碰新库。** `migrate` 遇到 `user_version > SCHEMA_VERSION`(或早于第一步)直接报错、不动文件(`open_store` 退回内存 store 并提示);迁移已有的库之前先 `VACUUM INTO '<库>.v<旧版本>.bak'`(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。
- **窗口还在搭的时候不能直接弹通知。** 通知层属于窗口的 `Root`,而 `Root` 要等 `open_window` 的构造闭包返回才挂上;在那之前 `window.push_notification` 会 panic(「window first layer should be a gpui_component::Root」),应用启动即崩溃。`main` 里读库 / 读设置的问题、`Workspace::new` 里外部 CLI 启动失败,都用 `workspace::notify_once_open`(`window.defer`)延后到窗口建好。`cx.emit` 出去的事件本来就在构造结束后才送达,订阅里弹通知没有这个问题(自动开启的转发启动失败就是这样报的)。
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
- **设置写穿到 JSON,由工作区落到窗口上。** 设置是应用级的,不进主机数据库,存在 `settings.json`(`app::settings_path`),整文件先写临时文件再改名替换。`SettingsStore` 和 `HostStore` 一样内存说了算:`update` 改内存 → 写文件 → `notify`,写失败发 `SettingsStoreEvent::PersistFailed`,工作区弹错误通知;`update_unnotified` 是给单元测试的纯内存一半。文件缺字段用默认值,读不出来就用默认设置并在启动时提示,文件留到下一次改动时覆盖。设置页的下拉只写 store(下拉的 setter 拿不到 `Window`);工作区 `observe_in` store 调 `settings::apply`,另外 `observe_window_appearance` 让「跟随系统」跟着系统切换,构造时在第一帧之前先应用一次。标题栏的主题开关(`ToggleTheme`)也只是把「应用外观」写成当前的反面,不再直接改主题,所以两处永远一致并且会保存。界面语言目前只影响 gpui-kit 自己的文案(`set_locale`),ShellRS 的文案还是中文,所以默认是「简体中文」而不是「跟随系统」;翻译完成后再改默认值。locale 是进程级全局量,UI 测试并行运行,所以 UI 测试不切换语言,只由单元测试覆盖 `locale` 的解析。
- **外部 CLI 经由正在运行的应用执行。** 命令本身不读钥匙串、不开数据库,只把请求交给应用:所以不会多弹钥匙串授权框,也不会和「内存说了算」、内存里分配 id 冲突。
- **服务端**:生产路径 `Workspace::new` 用终端同一个 `SshConnector`(共享 known_hosts 锁)和 SFTP 同一个 provider 启动 `CliServer`;`new_with_services`(UI 测试)不启服务,测试绝不监听真实套接字。工作区在 store / 设置变化时 `sync_cli_server`:主机快照(`CliTarget::all`,带分组全路径)和「启用外部 CLI」开关。
- **一直监听,按开关拒绝**:开关关着时回 `not_enabled`,Agent 才分得清「没运行」和「没开」。套接字 `0600`,每个连接再用 `peer_cred` 核对 uid。残留的套接字文件:连得上说明另一个实例在跑(启动失败并弹通知),连不上就删掉接管。
- **Windows 用命名管道,两端互相确认。** 管道名是整机的命名空间,不在用户自己的目录里,所以:
- 服务端每个实例都带 DACL `D:P(A;;GA;;;<当前用户 SID>)` 和 `PIPE_REJECT_REMOTE_CLIENTS`,第一个实例带 `FILE_FLAG_FIRST_PIPE_INSTANCE`(名字已被占用就启动失败,报 `AddrInUse`)。DACL 在创建时原子生效,所以不像 Unix 那样再查对方的身份(查进程属主会误伤以管理员身份运行的 Agent)。
- 客户端用 `SECURITY_IDENTIFICATION` 打开(抢注者不能冒充当前用户),发请求之前用 `GetSecurityInfo` 确认管道属主是当前用户或 BUILTIN\Administrators(提权运行的 ShellRS 建出来的属主是后者),否则拒绝。
- 用同步的 Win32 管道,不用 tokio 的:请求线程要阻塞的 `File`,而 tokio 的管道是 overlapped 句柄,拿去做同步 IO 时 std 会中止进程。
- 监听线程先建好下一个实例再把已连接的交给请求线程,否则那一刻来的客户端会看到「没运行」。请求线程结束时直接关句柄,**不要** `DisconnectNamedPipe`(会丢掉客户端还没读的数据)也不要 `FlushFileBuffers`(客户端挂住时会一直阻塞)。停止时置标志、反复以客户端身份连一下唤醒 `ConnectNamedPipe`,直到线程结束。
- **Windows 的 PATH 是副本加用户 PATH。** `IntegrationPaths::system()` 在 Windows 上:`exe` 是 `shellrs.exe` 旁边的 `shellrs-cli.exe`,`bin_link` 是 `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe`(副本,不是链接),`user_path` 是 `UserPath::Registry`(`HKCU\Environment\Path`,写回 `REG_EXPAND_SZ` 后广播 `WM_SETTINGCHANGE`;只有之后启动的程序看得到)。副本按长度加修改时间判断是否本版(安装时用 `set_modified` 把时间设成源文件的)。正在运行的副本不能覆盖但能改名,所以安装、移除都先把旧的改名成 `shellrs.exe.<uuid>.old` 再尽量删掉。生产环境启动时 `CliIntegration::update_outdated` 在后台更新旧副本,否则 Agent 会一直碰到 `version_mismatch`。测试一律 `user_path: None` 或 `UserPath::File`,绝不碰注册表。开发时 `cargo run` 只构建 `shellrs`,要 `cargo build` 才有 `shellrs-cli.exe`,否则设置页显示找不到。
- **Windows 只在 CI 上编译和测试**(`.github/workflows/windows.yml`:clippy 全 crate、`cargo test --lib cli::`、`cargo test --test cli_bin`),本机没有 Windows 工具链;改 `pipe_windows.rs`、`install_windows.rs` 的 Windows 部分和 `cfg(windows)` 代码后要看 CI。
- **每个请求一个线程**,后端方法都是阻塞的;SSH 句柄绑在创建它的 runtime 上,所以 `ssh::run_command` 和 SFTP 工作线程各自建 current-thread runtime。
- **exec 不许挂住**:`SshPrompts` 非交互;陌生主机在 events 回调里直接返回 `false`(`ask` 没人回答会永远等下去,因为超时不计提示时间),缺凭据由 `MissingCredential` 失败。`exec(true)` 后立刻 `eof()`,远程没有 stdin;读循环读到 `Close` / `None` 为止,退出码可能在 EOF 之后才到。输出回调写失败(Agent 断开)就放弃命令、断开连接。
- **传输无人值守**:`SshCliBackend::transfer` 先等 `Connected` 再发请求;连接前的 `Prompt` 一律回 `Cancel` 并按提示种类给出错误码;`Question` 按固定策略回答(冲突覆盖并勾选全部、续传一律重新开始、出错跳过并记为失败);以 `Idle` 为结束。请求用 `UploadRequest::scp` / `DownloadRequest::scp`:worker 连上后按 scp 规则解析目标(`scp_remote_target` / `scp_local_target`,目标目录必须存在,远程还要 `stat` 确认,因为有的服务器 realpath 不存在的路径也成功),并让新建文件保留源文件的权限位(上传整套 `0o777`,下载只补可执行位);SFTP 标签的请求不受影响。批次准备失败时 worker 也发 `Idle`。
- **安装集成的路径保护**:`CliIntegration` 没有路径(UI 测试默认)时页面显示「暂不支持」、没有安装按钮,测试不会写到真实的 `~`;需要时用 `workspace.cli_integration()` 注入临时目录。安装 / 移除是动作(`InstallCliCommand`、`RemoveCliCommand`、`InstallAgentSkill(AgentKind)`、`RemoveAgentSkill(AgentKind)`、`RefreshCliIntegration`、`CopyAgentSkill`),由工作区在后台线程执行后 `refresh`。
- **终端字体走全局量。** 终端模块不能依赖 settings,所以 `settings::apply` 把字体设成 `terminal::TerminalFont` 全局量并 `window.refresh()`,`TerminalView` 在 render(字体族、字号)和 prepaint(行高 `row_height`,按设备像素取整)里读它;格子变了,下一帧 prepaint 就按新尺寸 resize,已打开的终端立即生效。默认值就是加设置之前的样子:主题的等宽字体(macOS 上是 Menlo)、13 px、行高 1.54(13 × 1.54 取整后正好是原来的 20 px 行)。字体族存 `None` 表示跟随主题默认,下拉里选默认那一项也存 `None`。文件里的字体族只有装着才用(`is_font_installed`,进程内读一次字体列表;GPUI 找不到字体族时会换成别的字体),否则退回默认,文件里的值保留。数字在 `AppSettings::normalized` 里夹到范围并取整(字号整像素、行高两位小数),读文件和每次修改都过一遍;gpui-kit 的数字框用 `f64::to_string` 显示,步进 0.1 会显示 `1.7000000000000002`,store 取整后数字框下一次渲染会同步成取整后的值。字号用下拉不用数字框:数字框每敲一个字符就夹到最小值,输入 14 时敲下「1」就变成 8。字体下拉只列等宽字体:`monospace_font_families` 要加载每个字体族(macOS 上约 150 ms),在后台线程跑一次、进程内缓存,结果回来前下拉里只有默认字体;判定是「i」和「M」一样宽且窄于 0.8 em,后一条排除 Webdings 这种方块符号字体。预览 `TerminalFontPreview` 用和终端一样的排法(每格一个字形、宽字符后面补一个空格格子、终端的行高和配色),不是普通文字排版,否则中文的宽度和终端里对不上。
- **持久化写穿,内存说了算。** `HostStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `HostStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删主机的 id 返回给工作区去关标签页。
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,主机保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `HostStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_host` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,主机树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加主机名称第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。主机树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。
- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `HostLogin`(`HostStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 `route: LoginRoute`——每台跳板主机的登录也查好了,`JumpLogin::Host { name, login }` 或已删除的 `JumpLogin::Deleted`;跳板自己的 `route` 一律是直连;主机表单测试连接时用 `HostStore::route_login` 把表单上的 `Route` 解析成同样的东西),不是 `Host`。`HostLogin` 只含影响登录的字段并派生 `PartialEq`(`JumpLogin` 手写 `PartialEq`、不比 `name`,跳板改名不让后面的主机重连),主机 `update` 和 `update_credential` 都在改之前取一遍**全部主机**的快照(`logins`),改完按「前后快照不相等」逐台发 `ConnectionSettingsChanged`(`emit_changed_logins`),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的主机也会重连;删除主机不发(同删除凭据,已打开的连接不断)。引用凭据的主机在内存的 `Host.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`;库里这一行的 `username` 是 NULL、`auth` 是 `credential`),store 在保存主机(`normalized`)、改凭据和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `host.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。
- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `HostStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或主机在用时原地覆盖:路径不变,用它的主机不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。
- **id 由内存分配。** `next_host_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`HostStore::seed()` 生产环境不再使用,是 UI 测试的夹具。
- **对外只给 `PublicId`,不给 `HostId`。** 主机右键「复制 ID」复制的是 `Host.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `hosts.public_id`(可为空,`UNIQUE`)。新建和复制主机时由 `HostStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建主机时必须带上原来的 `public_id`,`update_host` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<HostStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer` / `CloseLocalTerminal` / `CloseSettings`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`HostStore::recent_hosts()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把主机移到最前。
- **终端内容区的右键菜单与查找、清屏。**
- **菜单**:`TerminalView` 自己给出复制、粘贴、查找、清屏。面板通过 `set_menu_items` 在下面追加一段:远程终端追加打开 SFTP、重新连接、断开连接(`DisconnectTerminal` 只断这一个标签);本地终端不追加。追加段的构造器在菜单打开时运行,拿到的是事先读好的 `TerminalLifecycle`,因为这时 `TerminalView` 正在更新,读它会重入。
- **动作**:`FindInTerminal` / `FindNextInTerminal` / `FindPreviousInTerminal` / `DismissTerminalFind` / `ClearTerminal` 和复制、粘贴一样,由工作区通过 `active_terminal` 转给当前标签的 `TerminalView`。
- **快捷键**:⌘K 在 `Terminal` 上下文里是清屏,比全局的 `FocusSearch` 更深,所以优先生效。
- **查找**是字面量加智能大小写(`RegexSearch` 本身就是智能大小写)。
- 渲染时只扫视口,起止扩到所在逻辑行的首尾,每帧几十行。
- 全量扫描只在查询变化(去抖 80 ms)和上下切换时各做一次,而且是流式计数,不收集结果:一个字母可能匹配上百万次。release 构建里一万行乘 200 列约 15–55 ms。
- 焦点匹配按回滚历史的增长量跟着输出上移,再用 `regex_search_right` 核对,对不上就丢掉。计数不随新输出刷新。
- **查找条**必须是终端 `div` 的兄弟元素,不能是子元素:否则输入框里敲的键会冒泡到终端的 `on_key_down`,被发给 shell。它还要 `.occlude()`,否则点它会被下面终端的鼠标处理器抢走焦点。
- **清屏**只在本地模拟器里做:把光标所在的逻辑行(含折行)挪到第 0 行,清空回滚历史,远端不知情。`ALT_SCREEN` 下什么也不做,菜单项置灰。
- **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。双击标题派发 `ToggleHostPanel`(显示 / 隐藏左侧栏,同 ⌘B),也挂在标题上,所有中间区标签共用;第一下单击照常选中标签,关闭按钮的单击停止冒泡,双击它不会切换侧栏。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。中间区各面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 `custom_title`)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(`RenamableTab::default_title`:终端是主机名称,SFTP 是「主机名称 · SFTP」)时回到默认,默认名随主机改名。SFTP 标签的菜单(`explorer_panel.rs` 的 `TabMenu`)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑主机…」;标签栏按钮和远程终端一样是「SFTP」(`("open-sftp", id)`)和「重新连接」(`("reconnect-sftp", id)`)。SFTP 的「重新连接」是 `ExplorerCommand::Reconnect`:已连接时先 `disconnect` 再发 `Resume`(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦主机面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效,要预先聚焦某个字段只能在 `open_dialog` 返回后的同一次更新里同步调用(见 `open_directory_dialog.rs`)。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_host_stay_closable`)。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`HostPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `HostPanel` 比对 `known_groups` / `seen_hosts` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
- **主机行的 tooltip 开在行的右侧。** 悬停主机行 500 ms 后,行右侧显示 `user@address:port`,主机有备注时下面再用小一号、淡一些的字显示备注(`host_tooltip` / `RowTooltip`,不显示系统;备注最宽 20 rem、自动折行,超过 6 行省略),配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字)。只有这一个 tooltip 这样:gpui-kit 的 tooltip 和菜单、下拉共用 `popover` 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip(`Button::tooltip_placement` 那套)是 crate 私有的,普通元素的 `.tooltip()` 只会贴着鼠标弹、盖住下面的行,所以 `HostPanel` 自己持有一个 `gpui_kit::base::TooltipOverlay`(`row_tooltip`,作为面板的子元素渲染,面板收起就一起消失;动画 `animate_row_tooltip` 仿 gpui-kit 自己的)。行在 `on_hover` 里带着行的边界 `request_show(...placement(Placement::Right))`,任意鼠标按下立即 `hide`,拖动中不显示。**只有 tooltip 的当前主人才能收起它**(`row_tooltip_owner`):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 `request_hide` 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(`the_row_tooltip_follows_the_pointer_down_the_list`)。按键会收起 tooltip 是 GPUI 的 `HoverListenerMode::InputModalityAware`:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 `canvas` 在 prepaint 记下,**必须**带 `top_0().left_0()`:`ListItem` 的内容盒不是 `relative`,不设偏移的 canvas(gpui-base 的 `ElementExt::on_prepaint` 就是这样)会落到内容下方,tooltip 随之偏下将近一行。
- **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `HostPanel` 把 `.context_menu(..)` 挂在 `#host-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc<Cell<Option<HostNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。开始页的列表 `recent-host-list` 照样挂在容器上(右键行同时选中该行,空白处得到空菜单、不弹出);开始页是 `deferred` 画的,但 `Deferred` 的子元素在主布局阶段就做 `request_layout`,菜单抢焦点仍早于任何 prepaint,不会触发上面的断言。
- **表单里的几选一用 `shared::SegmentedControl`。** 用户给的样式:背景色的轨道加 `border` 细边框,选中的一段是 `accent` 底色、比轨道深一级的细边框(`border` 混入 8% 的 `foreground`,浅色主题约 neutral-300,深色主题反过来变亮)和轻阴影的块(截图里的灰太深,`secondary_active` 是 neutral-300,用户要浅一些;`accent` 浅色主题是 neutral-100,深色是 neutral-800),没选中的是 `muted_foreground` 字、悬停时半透明的 `accent` 底,比选中的淡;各段等宽铺满轨道(`flex_1`),高度是中号控件的 `h_8`,选中块的圆角按 2 px 内缩与轨道同心。行为全来自 gpui-base 的 `Radio` / `RadioGroup`(点击、回车 / 空格、每段一个 Tab 停靠点、无障碍角色),这里只管外观;每段的元素 id 是它的序号,和 gpui-component 的 `RadioGroup` 一样,所以测试照旧 `within(组).click(1usize)`。gpui-base 的 `RadioGroup` 是 `RenderOnce`,不能 `test_support()`,测试里找不到轨道本身,只能量各段。主机对话框的认证方式和连接方式、凭据对话框的类型 / 私钥来源 / 算法都用它;端口转发的三张类型卡片带说明和示意图,仍是 gpui-component 的 `RadioGroup`。
- **主机对话框和转发对话框一样从窗口高度的 5% 开始、最高占 90%**,选了跳板或代理、框里的内容多起来时正文滚动、按钮不动。
- **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `window.open_dialog` 都要 `.overlay_closable(false)`(默认是 `true`,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(`a_click_beside_a_dialog_does_not_close_it` 查主机、分组、端口转发和凭据四个)。`open_alert_dialog` 开的确认框本来就不会被点外面关掉。
- **表单的校验错误一律弹错误通知。** 新建 / 编辑对话框(主机、分组、端口转发、凭据,SFTP 的重命名 / 新建和「打开目录」)校验不过时 `window.push_notification(form_error_notification(..))` 并让 `on_ok` 返回 `false`,不在表单底下放错误行(用户要的样子)。同一种通知新的替换旧的,不会一次次叠起来;点它就收起;每个对话框的 `on_close` 调 `dismiss_form_error`,免得对话框关了错误还挂着。从别处 `window.close_dialog` 不经过 `on_close`(凭据对话框在口令加密完后就是这样关的),那里要自己调。新加的对话框照此办理(`a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it` 逐个查)。
- **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`DialogAction`)和回车都是 `window.dispatch_action(Confirm)`,先到焦点元素;gpui-base 的 `Select` 在收起状态下收到 `Confirm` 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」、主机对话框的「凭据」「分组」都没有绕开(`ForwardForm::focus_bind_port` 只在打开新建对话框时聚焦一次,代码里没有订阅 `SelectEvent`;「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这些下拉之后要先点一下别的字段再点 `commit`,否则后面第一次点击会被吃掉。
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_hosts_are_read_back_from_the_database`)。
- **通知层要 `deferred` 并高过对话框。** gpui-base 的对话框(连同半透明背景)是用 `deferred` 画的,优先级是 `10 + 对话框的层号`(`gpui-base` 的 `dialog.rs`;开始页是 0);通知层的优先级比它低就会画在对话框背景底下,被压暗,点它点到的是背景。所以工作区把 `Root::render_notification_layer` 包成 `deferred(..).with_priority(NOTIFICATION_PRIORITY)`(= `POPUP_PRIORITY - 1` = 99:高过任意层叠的对话框,低于菜单等弹出层的 `POPUP_PRIORITY` = 100 和 tooltip 的 200)。以前这里写成 1,以为对话框是 0,通知一直在对话框背景底下。无头测试里点击走真实的命中测试,被背景盖住的元素收不到点击:`a_form_error_sits_above_the_dialog_and_a_click_puts_it_away` 点主机对话框的错误通知,它在背景底下时这个测试会超时。
- **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如主机面板里各主机系统的 `Rc<HashMap<HostId, HostOs>>`)。
- `px(..)` 只允许出现在 API 要求 `Pixels` 的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和 `cx.theme()` token。
- **内存说了算,写穿。** `HostStore` 的 mutator「改内存 → 写库 → `notify`」,`*_unnotified` 是纯内存的一半(给单元测试和加载)。写失败时内存照样改,store 发 `PersistFailed`,工作区弹错误通知,绝不静默吞掉。消费方 `cx.observe(&store, ..)`。设置同理:写穿到 `settings.json`(整文件替换),工作区观察后用 `settings::apply` 落到窗口上。
- **秘密只进钥匙串。** `schema_never_contains_secret_columns` 守着这条线。`SecretStore` 的方法是阻塞的,只在后台执行器或工作线程上调;写入只经 `HostStore::save_secret`。
- `SecretRef` 决定条目归属:密码按 `user@host:port`,私钥口令按文件路径,密码凭据按随机的 `keychain_id`,代理密码按代理地址加用户名,不保存的连接只在内存。
- 没人再用的条目由 store 清理(`password_in_use`),私钥口令从不自动删。
- 秘密不放进派生了 `Debug` 的 `HostDraft` / `CredentialDraft`。
- 对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前,空字段不算「用户清空了」。
- **私钥文件。** ShellRS 保存的私钥在 `keys/` 下(目录 0700、文件 0600,经临时文件改名写入)。只有没人再用时才删这个目录下的文件;用户自己的私钥文件从不改动或删除。
- **登录快照在 UI 线程解析。** 工作线程读不到 store,拿到的是 `HostLogin`:凭据和跳板都已查好。它只含影响登录的字段并派生 `PartialEq`:改主机或凭据时比较前后快照,不同才发 `ConnectionSettingsChanged` 重连,所以只改名称不重连。
- **id 由内存分配**,四个独立计数器,重启后从库里最大 id + 1 开始,删掉的最大号会被复用。对外只给 `PublicId`(16 位随机串,改名、改地址都不变,不复用),不给 `HostId`。
- **改表**:现在 `SCHEMA_VERSION` 是 14,所有表都是 `STRICT`。
- 改表要升版本、在 `STEPS` 里加一步,并加一个「用冻结的旧 schema 升级、再和新库比对」的测试。
- 加列直接 `ALTER TABLE ADD COLUMN`,在 `SCHEMA` 里也写在最后一列之后。
- 库只往前升:旧版本遇到新库直接报错、不动文件。迁移前先 `VACUUM INTO` 备份。
- **窗口的大小和位置存在 `window.json`,不进 `settings.json`**(`workspace/window_state.rs`):移动、缩放时 `observe_window_bounds` 记下,停 0.5 秒再写;退出时再写一次,用 App 级的 `on_app_quit`,因为 Windows / Linux 关窗口时工作区已经先没了。只有 `Workspace::new` 记,UI 测试不碰这个文件。
- 启动时 `main` 先读设置和 `window.json` 再开窗口,`window_options` 按两个开关算 `window_bounds` 和 `display_id`。
- 屏幕按 `PlatformDisplay::uuid` 认(`DisplayId` 重启会变)。GPUI 的窗口坐标在 macOS 上从所在屏幕左上角算,别处是整个桌面,原样存、原样交回,配上同一个 `display_id` 就能落回原处。
- macOS 的 `window_bounds()` 从不报最大化,靠 `is_maximized()`(大小等于屏幕可见区)判断,恢复时给可见区大小的普通窗口;别的平台用 `WindowBounds::Maximized`。
- **一个数据目录只跑一个 ShellRS。** 起图形界面之前先连外部 CLI 的套接字:连得上就发 `Request::Activate`(带启动链接)然后退出。为兼容已装的 CLI,不升 `PROTOCOL_VERSION`,新请求只加变体。
- **临时主机在 `HostStore` 的 `temporary` 里**:`host()` / `login()` 找得到,`hosts()` 看不到,也不写库。会写外键的检查用 `saved_host`。最后一个标签关掉就 `remove_temporary`。
### 多语言
- **文案都在 `locales/<模块>.yml`**(rust-i18n 的 `_version: 2`):最外层直接写键的全名(`host.dialog.title_new:`),下面 `zh-CN`、`en` 并排。代码用 `crate::i18n::t!`(返回 `SharedString`)和 `tn!`(带数量,填 `%{count}`:英文数量为 1 取 `.one`,否则 `.other`;中文只写 `.other`)。不用 rust-i18n / gpui-kit 自己的 `t!`、`set_locale`。
- **键说用途**(`host.dialog.title_new`),不拿中文句子当键。一句话一个键,不拼片段:`format!("无法{}{}", 动词, 对象)` 改成每种情况一个整句。插值写 `%{name}`;列表用 `i18n::join_list`(「A、B 和 C」/ "A, B and C")。以 `%`、`{` 开头的值、含 `: ` 的值和 `true`、纯数字这类值要加引号,否则 YAML 不当它是字符串,那条文案就悄悄没了。
- **中文是源语言**:改中文时同一个键的英文一起改。转换旧代码时中文输出要和原来逐字相同。
- **实时切换**:语言变了,`settings::apply` 调 `i18n::set_locale`、设全局量 `UiLocale`、`refresh`。文案在 render 时翻译,实体里不存翻译好的字符串,存键、枚举或原始数据。gpui-kit 要预先存下的(`InputState` 的占位、表格的列)用 `cx.observe_global_in::<UiLocale>` 重设。终端回滚里已写下的提示、已弹出的通知、已存成 `String` 的报错留在旧语言,可以接受。
- **启动**:`main` 读完设置、建任何东西之前先 `set_locale`;外部 CLI 的客户端按 settings.json 设,与应用一致。
- **标签和静态表**:返回文案的函数返回 `SharedString`,不是 `&'static str`。`static` 表里存键,显示时 `t!(key)`;键要以完整的字面量出现在源码里,守卫测试靠它找没人用的文案。
- **英文文风**按 Design Guides:sentence case;省略号用单字符 `…`;按钮写结果(Delete,不写 Confirm deletion);不写 Please、successfully、Are you sure;引号用 “ ” 和 ’。语言名用各自的语言写(「简体中文」「English」),这样的中文字面量所在行加 `// i18n: keep`。
- **守卫测试**在 `src/i18n.rs`:两种语言都齐、占位一致、英文里没有中文和 `...`、代码里的键都有文案且文案都被用到、`src/` 里测试以外没有中文字面量(扫描时跳过注释和 `#[cfg(test)]` / `#[cfg(any(test…))]` / `#[cfg(all(test…))]` 下的条目以及 `*tests.rs`)。键名不要以 `.one`、`.other` 结尾,除非它就是复数形式:守卫按复数处理这两个后缀。
- **测试**:单元测试默认就是中文。UI 夹具经 `support::init_app` 和 `settings_store()`:本线程的语言与别的线程隔开(`i18n::isolate_thread`),并固定中文;切到英文的测试不影响并行的其他测试,也碰不到 gpui-kit 的全局语言。
用词表(新文案照此,改要整体改):
| 中文 | English |
|---|---|
| 主机(台) | host(1 host / 3 hosts) |
| 分组 / 地址 / 用户名 | group / address / username |
| 凭据 | credential |
| 密码 / 密钥 / 私钥 / 公钥 / 口令 | password / key / private key / public key / passphrase |
| 认证:密码 / 使用凭据 / 无密码 | Password / Credential / No password |
| 连接方式:直接连接 / SSH 跳板 / 代理连接 | Direct / Jump hosts / Proxy |
| 跳板主机 | jump host |
| 测试连接 | Test connection |
| 端口转发:本地 / 远程 / 动态;经由主机 | port forwarding: Local / Remote / Dynamic; via host |
| 临时连接… | Quick connect… |
| 开始页「快速连接」 | Find a host |
| 外部连接 | external connection |
| 最近连接 | Recent |
| 本地终端 / 远程终端 | local terminal / remote terminal |
| 传输队列 / 书签 | Transfers / Bookmarks |
| 显示隐藏文件 | Show hidden files |
| 废纸篓 | macOS 写 Trash,Windows 写 Recycle Bin |
| 右侧栏七个工具 | Snippets / History / Docker / Services / Processes / Network / Monitor |
| 设置七类(外观、终端、关键字高亮、键盘快捷键、外部 CLI、应用、关于) | Appearance / Terminal / Highlighting / Keyboard shortcuts / External CLI / General / About |
| 应用更新 / 稳定版 / 重启并安装 / 查看更新内容 | Updates / Stable / Restart to update / What’s new |
| 连接 / 断开连接 / 重新连接 | Connect / Disconnect / Reconnect |
| 已连接 / 未连接 / 连接中 | Connected / Not connected / Connecting |
| 目录、文件夹 | folder(上级目录 Parent folder,主目录 Home folder) |
| SFTP 列:名称 / 大小 / 类型 / 修改时间 / 权限 / 所有者 | Name / Size / Type / Modified / Permissions / Owner |
| 目标(转发、传输) | destination |
| 本机 | this computer |
| 系统钥匙串 / 主机信任文件 | system keychain / known hosts file |
| SSH Agent(句子里) | the SSH agent |
| 删除(容器、规则等) | Delete(不用 Remove) |
| 正在读取… / 读取失败 | Loading… / Couldn’t load |
### GPUI / gpui-kit
- **一个命令一个处理器。** 按钮、菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理,不写临时闭包直接改状态。例外是对话框内部自己的状态(预览的缩放):工作区够不着对话框里的视图,由视图自己处理。
- **焦点。**
- `window.dispatch_action` 只能到达焦点路径上的处理器,所以工作区里必须有东西持有焦点。
- 永远不要聚焦工作区根节点的 handle,否则对话框的焦点陷阱拿不到焦点。
- 中间区每个面板都要在 `set_active(true)` 里收下焦点,否则「×」、⌘W 和快捷键会静默失效。
- 对话框关掉后焦点为空:标题栏、开始页、对话框和右侧栏的按钮一律经工作区 focus handle 的 `dispatch_action` 派发,在对话框里还要用 `window.defer` 延后。
- 要预先聚焦对话框里的字段,只能在 `open_dialog` 返回后的同一次更新里同步调用。
- **焦点监听器里改渲染状态要 `defer_in`**:`on_focus_in` 等在绘制的焦点阶段执行,这时 `notify` 不排新帧。
- **后台线程不直接唤醒 GPUI 前台任务。** 工作线程的事件由 UI 定时轮询:终端和 SFTP 每 16 ms,转发和右侧栏工具每 50 ms,升级每 100 ms。等回答的 `oneshot` 在轮询的事件处理里(UI 线程)完成。
- **渲染回调不得读取实体**(树行、`render_td`),只传快照。
- **窗口还在搭的时候不能弹通知**:`Root` 还没挂上,会 panic。用 `notify_once_open` 延后。
- **对话框**:
- 每个 `open_dialog` 都要 `.overlay_closable(false)`。
- 表单校验错误一律弹 `form_error_notification`,并在 `on_close` 里 `dismiss_form_error`。
- 通知层要包成 `deferred`、优先级 99,高过对话框,否则被压在对话框背景底下。
- 下拉框攥着焦点时,提交会把它弹开,这是 gpui-kit 的行为。
- **右键菜单挂在容器上,不挂在行上**(主机树、文件列表、开始页、进程列表)。`ContextMenu` 在 `request_layout` 里抢焦点,挂在 `uniform_list` 的行上会触发「set_focus called more than once」断言。行把命中的项写进一个 `Rc<Cell<..>>`,容器在捕获阶段先清空它。行和容器不要各挂一个菜单,两个会一起弹出。
- **滚动条挂在不滚动的外层上**,或直接用 `overflow_y_scrollbar()`:挂在滚动元素自己身上,滚动条会跟着内容一起移走。
- **按上一帧的尺寸重排**(路径标签、监控的竖条、图片预览的画框):render 时拿不到布局尺寸。放一个 `canvas`,在 prepaint 记下尺寸,变了就 `on_next_frame` 通知重画。绝对定位的 `canvas` 必须 `top_0().left_0()`。
- **`img` 会给自己的盒子加上图片的宽高比**,压过百分比高度。要控制尺寸就给宽和高都写绝对值。
- **gpui-kit 的 `Settings` 把选中的分类放在元素状态里**,标签切走、不再绘制时就丢了,切回来回到第一类。设置页靠页头的 `title_suffix`(只给正在显示的页绘制)记下当前分类,再经 `default_selected_index` 恢复;页内滚动位置不保留。
- **`Select` 必须放进有尺寸的容器**,否则会盖住整行。`ListItem` 里会增长的文字要 `w_0().flex_grow(1.).overflow_x_hidden()`,否则会把 `suffix` 挤出去。
- **`ColorPicker` 要自己给常用色**(`featured_colors`):默认的第一行是主题色,它们也在下面的调色板里。gpui-kit 按十六进制给色块起 id,同一颜色出现两格就是重复的无障碍节点,调试版在辅助技术(或借助它的窗口管理工具)连着时直接 panic;测试平台不建无障碍树,测不出来。常用色不能取调色板(shadcn 的 stone、red … pink)里的颜色。
- **要彩色就不能用 `Icon`**:它把 SVG 当成单色遮罩画。系统徽章是「主题色圆底 + 单色 logo」。
- **`px(..)` 只用在 API 要 `Pixels` 的地方**,其余用 rem 助手和 `cx.theme()` 的 token。
- **快捷键改了要重建整个 keymap**(`app/shortcuts.rs`):GPUI 不能删单条绑定,`clear_key_bindings` 又会清掉 gpui-kit 在 `gpui_kit::init` 里注册的输入框、对话框、列表等绑定且无法重新注册。所以 `app::init` 先存 gpui-kit 绑定的快照,`apply_shortcuts` 每次按「快照 → 可改的通用键 → 固定的通用键 → 带上下文的键(可改的、固定的)」重绑。
- 同一按键上下文越深越优先,深度相同后加的赢,所以带上下文的必须排在通用的后面(终端的 ⌘K、预览的 ⌘= 靠这个)。新加固定键放进 `fixed_*_bindings`,可改的放进 `SHORTCUTS`。
- 录制用 `cx.intercept_keystrokes`:动作在 `on_key_down` 之前就执行了,只有拦截能让 ⌘W 之类在录制时不生效。录制框是普通可聚焦 div,不用 `Input`(中文输入法会先截字母)。
- 显示按键别写死文字,用 `tooltip_with_action` / `Kbd` 从 keymap 取,否则改绑后会过期。
- **标签页全部可关。** 关闭走 `DockArea::remove_panel`(`TabGroup::close_panel` 拒绝关最后一个)。中间区面板的 `closable()` 返回 `false`,否则「…」菜单会多一个绕过确认的「关闭」。标题用 `ClosableTabTitle`,它带标签菜单,双击切换侧栏。`panel_name()` 是持久化键,不能改。
- **左右侧栏各是一个面板。** 左侧 Dock 里只有 `Sidebar`(三个列表),右侧 Dock 里只有 `ToolSidebar`。不用 `set_dock` 换面板,否则焦点会丢。
- 当前标签只经 `Workspace::set_active_tab` 改,否则右侧栏不跟着变。分屏时焦点决定谁在前:每个中间区标签创建时都要用 `front::follow_focus` 登记焦点(它在焦点阶段触发,处理要 `defer_in`);布局变化(拖动合并标签组)后 `sync_front` 重新核对。右侧栏跟的终端由 `visible_center_tabs`(各组显示的标签,放大时只算放大的那组)判断是否还在屏幕上。
- 测试里拆分、合并标签组用 `DockArea::move_panel` 加 `InsertTarget::Split` / `Tabs`,同拖放;`split_at` 不会把面板从原组移走。
- 右侧 Dock 不可折叠,否则标签栏会多出 gpui-kit 的折叠按钮;关闭时临时设成可折叠。
- 最窄 320 px,由 `hold_tool_sidebar_width` 维持。
- **动画用 gpui-kit 的 `animate_keyframes`**(转发示意图),不自己写播放。
### SSH
- **登录。** 所有登录都走 `SshConnector`,共享 known_hosts 写锁。known_hosts 写在 `<data_dir>/known_hosts`,不碰 `~/.ssh/known_hosts`。
- 每次先发 `none`,再按 `LoginMethod` 走。
- 密码和口令先查钥匙串,查不到或被拒才弹框;被拒时不删钥匙串条目。
- 失败的说明一律经 `describe_login_error`,它沿错误链找真正的原因。
- **算法按 OpenSSH 的默认来**:加了 `ecdh-sha2-nistp*`,素数组交换最小 2048 位,因为用户的堡垒机是老 Java 上的 Apache SSHD。没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的算法。
- **区分断网和要人处理**:`is_network_error`,它要显式认 `russh::Error::IO`。只有网络类错误才自动重连。agent 的错误只格式化进文字,不挂进错误链。
- **跳板。** 每台跳板开好通道就丢掉自己的 `Handle`,整条链靠下一跳攥着的通道流活着:最终连接一断,链依次关闭。问答写明是哪台跳板;跳板和代理的失败包一层 `RouteFailure`。
- **外部连接只开一个通道**(`shell_only`)。用户的堡垒机在同一连接上多开一个通道就会结束会话,所以不做系统探测,旁路 exec 直接拒绝。以后给 SFTP 加别的通道,也要对 `is_external` 关掉。
- **右侧栏的工具在终端自己的连接上执行命令**(`TerminalTransportCommand::Exec`),不另起登录。
- 一次一条,10 秒超时,输出最多 1 MB。
- 命令写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`,因为要经得过 bash、zsh、fish、csh。
- 拼进命令的名字先过 `valid_*`。
- 各工具都有用本机 `sh` 真跑脚本的单元测试。
- **每次连上都重探主机系统**(`uname` / `os-release`,Windows 再试 `cmd /c ver`),结果经 `HostStore::set_host_os` 入库。往返延迟每 5 秒量一次(`keepalive@openssh.com`)。
### SFTP
- **每个标签独立连接、独立传输引擎。** 引擎一次只跑一批,其余在 `TransferQueue` 里排队,引擎的 `Idle` 推动队列前进;进度只归队首;停止的批次挡在队首,等用户继续或移出。
- **SFTP 独立于终端。** 关闭终端不停传输;断开或删除主机会停止传输,并保留续传数据。
- **下载严格按偏移顺序写 `.filepart`**,因为续传只信它的长度。
- **只发 WinSCP 也发的请求**:用户的堡垒机对 `fsetstat` 回 Failure,收到 `fsync@openssh.com`(即使宣称支持)就断开整个会话。所以上传关闭文件后才按路径 `setstat` 设时间和权限;不用任何扩展:不发 fsync,覆盖也不用 `posix-rename@openssh.com`,而是先把旧文件改名成备份、发布后再删(不是原子的)。`protocol_tests` 的 `bastion_server` 对 `fsetstat` 和所有扩展请求都断开会话,守着这条。
- **文件操作从不跟随符号链接**:删除只删链接本身,改权限跳过链接,传输原样创建链接。
- **空闲时也要发现断网**:通道流包一层 `WatchedStream`。等待时只持有 `Weak`,不能攥着 `Arc<SftpClient>`,否则「断开」关不掉连接。
- **选择归面板,不归表格。** `DataTable` 设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`。「名称」单元格才是项目,拖文件只从图标和文件名开始,其余地方按下拖动是框选。
- GPUI 会把同一类型的拖动通知给所有监听者,所以框选的监听要核对是不是自己发起的。
- 快速一甩时拖动的开始和松开落在同一帧,起点仍会收到单击,用 `ends_a_drag` 排除。
- 文件列表的快捷键按焦点路由(`ExplorerShortcut`,找 `contains_focus` 的那个标签),不按最近激活的标签。
- **工具栏永远一行高**,放不下的按钮整个藏起来。新标签两侧对半分,改工具栏宽度时要重算放不放得下。分栏状态归标签(`ResizableState`)。读目录不挪动列表;出的问题写在窗口左下角(`status-connection`)。
- **断开时**,对远程侧的操作要弹「SFTP 连接已断开 / 重新连接」。
- **编辑器和预览:先读后开**,读写走 SFTP 标签自己的连接。
- `ReadFile`、`ReadBytes`、`WriteFile` 和 `List` / `Operate` 一样另起任务,和传输并行。
- 文件读成功才开标签或对话框;读不了的在 `refuse_open` 说明,远程的给「下载…」。
- 查重在工作区(`editor_for_location`):已开就切过去。
- **编辑器写回**:`open_replace` 截断写,保存前核对 `FileStamp`(大小加修改时间)。写到一半失败是 `SaveFailure::Interrupted`:作为 context 包在原错误外面,所以仍认得出断网;这时作废戳,下一次保存不再核对。
- **编辑器的文本规则**:只认 UTF-8;所有换行都是 CRLF 才转成 LF 给编辑器、保存时换回,BOM 原样保留。⌘S 绑在 `FileEditor` 上下文。
- **编辑器的关闭和退出**:关 SFTP 标签会带走它的远程编辑器;批量关闭、关 SFTP 标签、退出时有未保存的文件,只问一次。
- 退出守卫是全局量(`app::set_quit_guard`):对话框关掉后焦点为空,`Quit` 到不了工作区的元素。Windows / Linux 点窗口的关闭按钮也走它。
- macOS 程序坞右键退出、注销关机,以及「重启并安装」都拦不住。
- **高亮语言**就是 `Cargo.toml` 给 gpui-kit 开的 `tree-sitter-*` 特性;`language_for` 只能给出这些名字。
- **预览的图片**:
- `ImagePreview` 自己在后台解码,所以解不开时能说出来;用 `ImageSource::Render` 画,关掉时 `drop_image`。
- 缩放的四个动作由它自己处理(`ImagePreview` 上下文:⌘= / ⌘- 放大缩小,⌘0 原图,⌘9 适合窗口)。
- 打开后在同一次更新里把焦点交给它,快捷键才有落点。
### 端口转发
- **规则在 store,运行状态在 `ForwardManager`。** 管理器订阅 store:规则删了就停,设置变了就重启。转发不计入主机的连接状态。
- **认证问答**归属 `PromptOwner::Forward(id, 代次)`,对话框开头写明是哪条转发;取消问答就等于停止。
- **发现断线靠处理器被丢弃**,不靠 `Handler::disconnected`:russh 有时会跳过它。远程转发先连上本机目标,再 `accept` 通道。
- **本地和动态转发先监听再登录**,监听器跨重连保留。停止时要 `handle.disconnect`。停止中的运行占着自己的位置(`Link::stopping`),否则重启会撞上「端口已被占用」。
- **首次登录可以问,重连不问**:重连非交互,陌生主机直接答否;只对网络类错误重试。
### 外部 CLI、升级、发布、平台
- **外部 CLI 经正在运行的应用执行**:命令本身不读钥匙串、不开数据库。
- 服务端一直监听,按「启用外部 CLI」开关拒绝。
- Unix 套接字 0600,并用 `peer_cred` 核对 uid。
- Windows 用命名管道:带 DACL 和 `FIRST_PIPE_INSTANCE`;客户端以 `SECURITY_IDENTIFICATION` 打开,并核对属主;用同步的 Win32 管道;关句柄时不 `DisconnectNamedPipe`。
- exec 和传输都不许挂住:非交互,陌生主机直接答否,问题按固定策略回答。
- 读(`list`、`show`)走 UI 线程推过去的快照(`CliServer::set_hosts`);`password_saved` 只在 show 时经 `CliBackend::is_saved` 查钥匙串。
- 改主机和凭据只能在 UI 线程做:请求线程把 `CliChange` 放进服务端的队列后等着,工作区每 100 ms 取一条(`serve_cli`,和「再次打开」共用),经 `apply_cli_change` 做完、快照刷新后再回复,然后才取下一条。超时只算排队的时间:没轮到就在锁里撤回、保证没做;已经开始的一直等,免得 Agent 重试做出两份。服务端 drop 时清空队列。
- `cli/manage.rs` 先校验、解析 ID 和分组路径,再交给 store:store 的 `normalized` 会把不存在的凭据、跳板悄悄改掉,CLI 要先拒绝。主机的密码随地址变化搬到新条目时,先在后台读旧值再改 store(store 会删没人用的旧条目)。
- 写库失败靠 `HostStore::capture_failures` 收集(照常发 `PersistFailed`),钥匙串写入用 `write_secret` 等结果,都报 `save_failed`。
- 凭据对外的 ID 就是 `keychain_id`,发布后不能改。请求里的秘密用 `Secret`,`Debug` 不露明文。
- 协议只加变体、不升 `PROTOCOL_VERSION`:旧应用对新请求回 serde 的「unknown variant」,客户端改说成 `version_mismatch`。服务端先读版本再解析请求。
- `exec --json` 的输入输出都是 JSON,输出只用 ASCII(`ascii_json` 转义其余字符),出错也是 JSON:给 Windows PowerShell 用,绕开引号和代码页。不做 MCP:主流 Agent 都能执行命令,CLI 加 skill 已经够用。
- UI 测试不监听真实套接字,`CliIntegration` 默认没有路径。
- **`exec --terminal`**(`terminal/run.rs`,纯逻辑,状态都在 `TerminalRun` 的锁里):
- 流程:引擎发 ^C → 请求线程等 500 ms(同时到的字节可能被 tty 随 ^C 冲掉)输入探测行 → 解析线程见到标记 P 就输入命令行 → 标记 B(上下文)、输出、标记 E(退出码)。时限在请求线程(`TerminalRun::drive`):P 5 秒(`terminal_busy`),B 15 秒(发 ^C 再报,可能被堡垒机命令过滤或复核拦下);执行中没有时限,每秒一个空 Stdout 帧,写失败就是调用方没了,发 ^C。
- 标记是 OSC 6973 带每次随机的 NONCE(单独的参数,回显里的 `\033` 只是文字,伪造不了)。P 带 `readlink /proc/$PPID/exe`(去掉 ` (deleted)`;不用 `comm`,以 sh 名义启动的 bash 的 comm 是 sh)。E 由外层 sh 在管道之后打:`trap : INT` 挺过 ^C,状态经 fd 3 带出,被打断按 130 报,所以 ^C 后也一定有 E。
- 命令按 `printf %b` 编码(控制字符、`'`、`\`、`!`、`"`、≥ 0x80 写成 `\0NNN`),打出的行里没有引号和控制字符,命令仍大体可读(审计、命令过滤看得见,所以不用 base64)。从不用 bracketed paste:命令行是在探测还在跑、tty 处于规范模式时预输入的,readline 那时没开 2004,一行最多 4095 字节,所以 bash、dash 上限 4000,busybox 的行编辑 1000。
- 运行槽归运行时(`TerminalRuntime::run`):解析线程在 `notices.scan` 旁边喂字节,循环结束时报断开。服务端另有 `runs` 队列,工作区另起 100 ms 的 `serve_cli_runs`(不能排在逐条等改动的 `serve_cli` 后面)。
- 真实 shell 的测试在 `run_tests.rs` 的 `shells`(`#[ignore]`,Docker 里 bash 4.2–5.2、dash、busybox、Rocky 的 sh),CI 是 `shells.yml`。
- **Windows**:
- 发布版是 GUI 子系统,命令改由 `shellrs-cli.exe` 承担;PATH 里放的是副本,加进用户 PATH。
- `main` 先用 `link_arguments` 认堡垒机的链接参数,认出来就不走 CLI。
- Windows 只在 CI 上编译和测试:`windows.yml` 按 `paths` 触发,新加 `cfg(windows)` 代码的文件要加进那个列表。
- **在线升级**:
- `new_with_services` 建的 `Updater` 没有服务(测试从不联网),生产路径再 `set_services` 加 `start`。
- 重启走 `set_restart_path` 加 `restart`。Windows 不在退出过程中起进程,交给 `run` 返回之后的 `start_handed_over`。
- **匿名使用统计**:
- 只在 `Workspace::new` 里启动(`AnalyticsServices::system`),`new_with_services` 没有;UI 测试用 `Analytics::new(sender)` 读信号。
- 计数只能是 `Counter` 的变体,快照字段只有 `&'static str`、开关和数量,用户输入的东西放不进去。计数的键发布后不能改,新加的要同步 `docs/manual.md`「隐私」一节的清单。
- UI 线程只往通道里发信号;系统检测(`os_info` 会起 `uname` 等子进程)、HTTP、写 `analytics.json` 都在 `shellrs-analytics` 线程上,退出不冲队列。
- SSH 连上靠 `TerminalEvent::Started` → `TerminalPanelEvent::Connected`,不能在每批输出都发的 `StatusChanged` 里记;SFTP 看 `StateChanged` 时的 `connection_state()`。外部 CLI 的请求种类由 `CliServer::take_usage` 交给 `serve_cli` 的轮询。
- **发布走 tag** `v<版本>`(`release.yml`,需要人工批准,清单最后才上传)。`CHANGELOG.md` 那一节由改版本号的提交写,功能提交不改。一次性准备见 `docs/release.md`。
- **macOS 上关窗口是隐藏**(`hide_when_closed`),⌘Q 才退出。测试平台的 `hide()` 没实现,所以这条只挂在生产路径上。
### 终端
- **查找条必须是终端 `div` 的兄弟**,不能是子元素,否则输入会被发给 shell;它还要 `.occlude()`。
- **清屏只在本地模拟器里做**,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的 `FocusSearch` 深一层,所以优先。
- **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。
- **终端颜色走全局量 `TerminalColors`**(`theme.rs`):`settings::apply` 按当前外观设成那一栏选中的主题,系统外观变化时也重设。终端格子、边距、选区、链接、颜色查询(OSC 4 / 10 / 11 / 12)和设置里的两个预览都从它取,不用界面主题的 `background` / `foreground`;查找高亮仍用界面的 `warning`。主题的 `key` 存进 `settings.json`,发布后不能改;读不出或深浅不符的 key 退回该外观的默认。
- **界面配色由所选主题推出**(`settings/app_theme.rs`):从 gpui-kit 默认的浅色或深色主题出发重新着色。灰色按它在默认背景与文字之间的位置,换到该主题背景与文字之间;默认里用作可读文字的灰色(对比度 ≥ 4.5)至少保留该主题正文对比度的 80%(最高 4.5)。`base.*` 按名字换成对应的 ANSI 色,其余彩色按色相换;`.light` 和彩色的 `*foreground` 删掉,交给 gpui-kit 自己推。ShellRS Light(以及「界面跟随主题」关闭时的浅色,`TerminalThemeSettings::app_theme`)直接用 gpui-kit 的默认浅色主题;ShellRS Dark(以及关闭时的深色)是 gpui-kit 默认深色主题按 `SOFT_DARK` 把每级灰换成炭灰,彩色不动。`settings::apply` 按名字(`theme_name`)判断要不要重建,把结果放进 `Theme` 的浅色或深色槽再 `Theme::change`。
- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(终端主题的 ANSI 蓝,悬停时亮蓝;界面主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。
- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键从不上报,按「右键行为」处理。写入走 `engine.write`,不算用户输入。
- **关键字高亮只叠加在显示上**(`highlight.rs`)。`snapshot()` 每帧对可见行把折行接成逻辑行,用 `regex` crate 匹配,结果写进 `TerminalCell.highlight`,prepaint 改文字色和粗细;不存坐标,所以缩放重排、回滚都不用失效处理。
- 不用 alacritty 的 `RegexSearch`(查找、链接用的那套):它的 DFA 编不了 `\b`,`^` 只在第一行生效,`$` 会碰到行尾补的空格。
- 通知在解析线程上:按 `\n` 切开输出,在 LF **之前**读光标所在的逻辑行(这时 `\r` 和颜色都已生效),锁外匹配。备用屏和同步更新进行中不读。
- 规则由 `settings::apply` 编译成全局量 `TerminalHighlights`,引擎 `observe_global` 后换进与解析线程共用的槽;重启运行时也要把槽传过去。
- `settings.json` 里读不了的规则单条丢掉(`readable_rules`),否则整个设置文件会回退成默认值。
- 规则表的输入框和取色器状态归 `HighlightRulesEditor`(行 id 不随位置变,拖动后字段跟着走),每次改动整表写回设置;设置被别处改了才重建行。
- **同步更新(DEC 2026)要解析线程自己收尾**:vte 只管攒住 `\e[?2026h` 之后的输出,超时(150 ms)后调 `stop_sync` 是调用方的事,alacritty 自己的事件循环就是这么做的。解析线程等输出时带着截止时间(`receive`,用 `async_io` 的定时器),到点就画出来;输出一直不停时在 `advance` 前补查;退出、出错追加提示前也先 `stop_sync`。否则程序死在一帧中间、连接断在一帧中间时,屏幕会一直冻住。
- **OSC 52 只写不读**(`Osc52::OnlyCopy`),只认剪贴板 `c`,主选择区忽略。
- **通知**:OSC 9 / 777 由 `terminal/notices.rs` 的扫描器在解析线程上认(alacritty 会丢掉),OSC 9 里「数字;」开头的是 ConEmu 的命令,不算通知。投递在 `workspace/notices.rs`:窗口不在前台走 GPUI 的 `show_system_notification`(tag 是 `terminal:local:<id>` / `terminal:remote:<id>`,点击回调按 tag 切标签),在前台走应用内通知。`shellrs::init` 里的 `set_app_identity` 用 bundle id,测试平台没有它就不发系统通知;macOS 上 `cargo run` 没有 bundle,看不到系统通知。
## 测试约定
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、对话框的共性)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、主机行的 tooltip `host-tooltip`(`aria_label` 是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的备注 `host-tooltip-notes`(`aria_label` 是备注全文,没有备注时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commitLine truncated
- **一个测试程序。** UI 测试(`tests/ui/main.rs`,`cargo test --test ui`)按功能分模块,共用的夹具和假实现在 `support/`。只有一个模块用的助手就放在那个模块里。不要在 `tests/` 下另开测试文件:每个文件都要再链接一遍 GPUI。
- **全部注入。** `Workspace::new_with_services` 注入终端、SFTP、本地目录、测试连接和端口转发的假实现。测试不碰用户的服务器、钥匙串(用 `InMemorySecretStore`)、设置文件(用 `SettingsStore::in_memory()`)、真实套接字和网络。`HostStore::seed()` 是夹具:种子主机按插入顺序编号(`web-01` = 1 … `dev-box` = 6),分组也一样(生产 = 1、测试 = 2、开发 = 3);`web-01` 和 `staging-api` 初始已连接,并打开了终端标签。
- **元素定位。** 被测试查询的元素带稳定的领域 id,自定义的 `div` 还要加 `.test_support()`。id 从源码或已有测试里找,这里不列。
- **不要用右键菜单驱动测试**:菜单项的 id 只是序号,`PopupMenu` 还会让测试报「leaked handles」。直接派发菜单会派发的那个动作。测试模块里显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。
- **异步与时序。**
- 动作派发和 `set_active` 都是延迟执行的:先 `run_until_parked` 再 `render_frame`,然后断言。
- 工作线程的事件要推进时钟(`advance_clock`)或用 `cx.wait_for` 等。
- 夹具一律 `set_reduce_motion(true)`。
- GPUI 只给激活的窗口发焦点事件,需要时先 `activate_window`。
- 按上一帧尺寸重排的元素,要 `simulate_next_frame` 才会重画。
- 对话框关掉后焦点是空的,接着派发动作前先点一下面板。
- **看不到的东西换个法子查。**
- 被遮蔽的输入框读不到值,密码一律查 `InMemorySecretStore`。
- 按钮的禁用状态用「是否还能取得焦点」判断。
- ⌘ / Shift 单击用 `modified_click`。
- 测试的文字系统每个字符都是 0.6 em(真实中文是 1 em),靠文字宽度撑出的布局问题在测试里看不出来。
- **单元测试起子进程一律经 `crate::testing`**(`sh`、`sh_accepts`、`no_forks()`),免得子进程继承别的测试刚建的套接字。
- **测试里的二进制常量**(PNG 等)用脚本生成,别手抄:抄错一个字节就解不开。
Generated
+238 -3
View File
@@ -1060,9 +1060,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.5"
version = "1.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2929,6 +2929,22 @@ dependencies = [
"smallvec",
"smol",
"tracing",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-css",
"tree-sitter-diff",
"tree-sitter-go",
"tree-sitter-html",
"tree-sitter-javascript",
"tree-sitter-json",
"tree-sitter-lua",
"tree-sitter-make",
"tree-sitter-md",
"tree-sitter-php",
"tree-sitter-python",
"tree-sitter-sequel",
"tree-sitter-toml-ng",
"tree-sitter-yaml",
"uuid",
"windows 0.58.0",
]
@@ -5460,6 +5476,27 @@ dependencies = [
"objc2-metal 0.3.2",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-cloud-kit",
"objc2-core-data 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image 0.3.2",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
@@ -5588,6 +5625,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
dependencies = [
"android_system_properties",
"log",
"nix 0.31.3",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
"objc2-ui-kit",
"windows-sys 0.61.2",
]
[[package]]
name = "p256"
version = "0.14.0"
@@ -7686,15 +7738,17 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.0"
version = "0.1.8"
dependencies = [
"alacritty_terminal",
"anyhow",
"async-channel",
"async-io",
"base64 0.22.1",
"chrono",
"clap",
"dirs 6.0.0",
"dunce",
"futures",
"gpui-kit",
"keyring",
@@ -7704,12 +7758,16 @@ dependencies = [
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"os_info",
"percent-encoding",
"portable-pty",
"rand 0.9.5",
"regex",
"reqwest",
"rusqlite",
"russh",
"russh-sftp",
"rust-i18n",
"semver",
"serde",
"serde_json",
@@ -7719,6 +7777,7 @@ dependencies = [
"tokio",
"trash",
"unicode-width",
"url",
"uuid",
"windows-registry 0.6.1",
"windows-sys 0.61.2",
@@ -8024,6 +8083,12 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6"
[[package]]
name = "streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
[[package]]
name = "strict-num"
version = "0.1.1"
@@ -8791,6 +8856,176 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "tree-sitter"
version = "0.26.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29"
dependencies = [
"cc",
"regex",
"regex-syntax",
"serde_json",
"streaming-iterator",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-bash"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-css"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-diff"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-go"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-html"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-javascript"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-json"
version = "0.24.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
[[package]]
name = "tree-sitter-lua"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-make"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-md"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-php"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-python"
version = "0.23.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-sequel"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-toml-ng"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-yaml"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "triomphe"
version = "0.1.16"
+59 -3
View File
@@ -1,9 +1,11 @@
[package]
name = "shellrs"
version = "0.1.0"
version = "0.1.8"
edition = "2024"
publish = false
description = "SSH session manager with local terminal support"
description = "SSH client and host manager: terminal, SFTP, port forwarding"
license = "GPL-3.0-only"
repository = "https://github.com/since2006/shell-rs"
default-run = "shellrs"
# The app. On macOS and Linux it doubles as the `shellrs` command when
@@ -19,17 +21,47 @@ name = "shellrs-cli"
path = "src/bin/shellrs-cli.rs"
[dependencies]
gpui-kit = "0.6.1"
gpui-kit = { version = "0.6.1", features = [
# The built-in editor's syntax highlighting, for what servers hold most:
# `tree-sitter` itself brings JSON. Each grammar is compiled C, so the
# list stays short.
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-css",
"tree-sitter-diff",
"tree-sitter-go",
"tree-sitter-html",
"tree-sitter-javascript",
"tree-sitter-lua",
"tree-sitter-make",
"tree-sitter-markdown",
"tree-sitter-php",
"tree-sitter-python",
"tree-sitter-sql",
"tree-sitter-toml",
"tree-sitter-yaml",
] }
alacritty_terminal = "0.26"
anyhow = "1"
async-channel = "2"
# A terminal's parser thread waits for output only until a synchronized
# update has to be drawn.
async-io = "2"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
dirs = "6"
# Canonical local paths without the `\\?\` prefix std adds on Windows.
dunce = "1"
futures = "0.3"
portable-pty = "0.9"
rand = "0.9"
# 关键字高亮: the rules match the terminal's text, not Alacritty's grid
# search, whose DFA cannot build `\b` and loses `^` after the first line.
regex = "1"
russh = "0.63"
# 多语言: the same crate gpui-kit translates its own text with, so one
# locale switches both.
rust-i18n = "4.2"
russh-sftp = "=3.0.0"
serde_json = "1"
sys-locale = "0.3"
@@ -40,6 +72,9 @@ rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt", "sync", "time"] }
unicode-width = "0.2"
# `ssh://user:password@host:port` links, as bastion hosts open Xshell with.
url = "2"
percent-encoding = "2"
zeroize = "1"
keyring = "4"
trash = "5"
@@ -49,6 +84,9 @@ trash = "5"
minisign-verify = "0.2"
reqwest = { version = "0.13", features = ["blocking"] }
semver = { version = "1", features = ["serde"] }
# 匿名使用统计: the system's name and version for Aptabase, as its own Tauri
# plugin reads them.
os_info = { version = "3", default-features = false }
[target.'cfg(unix)'.dependencies]
# `access(2)`: whether the folder ShellRS runs from can take its update.
@@ -75,6 +113,7 @@ windows-sys = { version = "0.61", features = [
"Win32_System_IO",
"Win32_System_Pipes",
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
"Win32_UI_WindowsAndMessaging",
] }
@@ -120,3 +159,20 @@ crypto-bigint = { opt-level = 3 }
crypto-primes = { opt-level = 3 }
bcrypt-pbkdf = { opt-level = 3 }
blowfish = { opt-level = 3 }
# The editor parses whole files of up to 5 MB to highlight them.
tree-sitter = { opt-level = 3 }
tree-sitter-bash = { opt-level = 3 }
tree-sitter-css = { opt-level = 3 }
tree-sitter-diff = { opt-level = 3 }
tree-sitter-go = { opt-level = 3 }
tree-sitter-html = { opt-level = 3 }
tree-sitter-javascript = { opt-level = 3 }
tree-sitter-json = { opt-level = 3 }
tree-sitter-lua = { opt-level = 3 }
tree-sitter-make = { opt-level = 3 }
tree-sitter-md = { opt-level = 3 }
tree-sitter-php = { opt-level = 3 }
tree-sitter-python = { opt-level = 3 }
tree-sitter-sequel = { opt-level = 3 }
tree-sitter-toml-ng = { opt-level = 3 }
tree-sitter-yaml = { opt-level = 3 }
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+158 -283
View File
@@ -1,330 +1,205 @@
# ShellRS
<p align="center">
<img src="assets/logo/shellrs.svg" width="96" alt="ShellRS">
</p>
基于 GPUI Kit 的中文 SSH 主机管理工具,提供持久化的主机与分组、可在多台主机间共用的登录凭据、本地终端、SSH 多终端、对标 WinSCP 的 SFTP 双栏浏览器和 SSH 端口转发。
<h1 align="center">ShellRS</h1>
标题栏「ShellRS」后面的三个图标切换左侧栏显示的内容:主机列表、端口转发列表或凭据列表。侧栏隐藏时点其中一个会把它重新展开。
<p align="center">
A high-performance, native, cross-platform SSH client written in Rust, with a GPU-rendered interface: Xshell-style host management, WinSCP-style SFTP and port forwarding, all in one window.
</p>
ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已有的窗口调到前面,新启动的那个随即退出。两个 ShellRS 各自把主机读进内存再写回同一个数据库,会互相覆盖对方的改动。(用 `SHELLRS_DATA_DIR` 指到不同数据目录的算不同的 ShellRS,可以同时运行。)要并排看两个终端或 SFTP,把标签拖成分栏即可。
<p align="center">
<a href="https://github.com/since2006/shell-rs/releases"><img src="https://img.shields.io/github/v/release/since2006/shell-rs?include_prereleases&label=release" alt="Release"></a>
<a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
</p>
## 主机列表
<p align="center"><b>English</b> | <a href="README.zh-CN.md">简体中文</a></p>
左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/en/main-dark.png">
<img src="docs/screenshots/en/main-light.png" alt="ShellRS main window: a grouped host tree on the left, htop running in a remote terminal on the right, connection latency in the tab bar">
</picture>
主机对话框的「认证方式」有三种:
## About
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style dual-pane SFTP browser and SSH port forwarding together in one cross-platform app, which bastion hosts such as JumpServer can launch the way they launch Xshell and WinSCP. It is written in Rust, and its UI is built on GPUI, the GPU-accelerated UI framework of the Zed editor (through [gpui-kit](https://gpui-kit.com)). There is no Electron and no JVM.
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。
Hosts, groups, credentials and forwarding rules live in a local SQLite database. Passwords and key passphrases go only into the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service); the database never holds a secret. ShellRS also ships a `shellrs` command that lets AI agents such as Claude Code or Codex run commands and transfer files on your saved hosts without ever seeing a password.
主机对话框「分组」下面的「连接方式」决定怎么连到这台主机,终端、SFTP、端口转发、外部 CLI 和「测试连接」都照它走:
## Features
- **直接连接**:默认,本机直接连主机的地址和端口。
- **SSH 跳板**:经过别的已保存主机连过去,支持多级。框里一排标签写出整条路(「本机 → 阿里云99 → 禅道 → 当前主机」),下面每台跳板一行,「×」移除,「添加跳板主机」可按名称或地址搜索;要调顺序就移除后重新添加。连接时先登录第 1 台,由它连第 2 台,依此类推,最后一台连到主机。每台跳板都用它自己的地址、端口、认证方式(密码、凭据或无密码)和主机信任记录,首次连接各问一次主机密钥,询问框里写明是哪台跳板主机;跳板主机自己的「连接方式」在这里不生效,要多级就在这里按顺序列出。主机的地址按跳板主机那边的网络来填,例如内网地址。
- **代理连接**:经由 HTTP 代理(`CONNECT`)或 SOCKS5 代理连接,在下拉框里选。填代理的地址和端口(例如本机的 `127.0.0.1:7890`);代理需要认证时再填用户名和密码,密码保存在系统钥匙串,经由同一个代理、同一个用户名的主机共用。主机的地址交给代理去解析。代理要求认证却没有密码、或者拒绝了用户名和密码时,连接失败并说明原因,不会询问。
**Hosts**
改了一台主机的地址或认证方式,经由它跳转的主机也会重新连接(只改名称不会)。删除一台被别的主机用作跳板的主机,确认框写明有几台主机在用它;删除后那些主机的跳板列表里留下一行「已删除的主机」,连接时直接报错,不会悄悄改成直连或少跳一级,编辑时移除或换一台即可。已经打开的连接不受影响。
- Groups nest to any depth; drag to rearrange, type to search.
- The remote OS is detected on connect and shown as a badge in the host tree and on tabs (Linux distributions, the BSDs, macOS, Windows).
- Three ways to log in: password; no password (the server's `none` auth, then the SSH agent, then the default keys in `~/.ssh`); or a saved credential.
- Three ways to connect: directly, through a chain of SSH jump hosts, or through an HTTP or SOCKS5 proxy. Terminals, SFTP, port forwards and the CLI all follow it.
- "Test connection" performs a real login and explains any failure.
**尚未实现:跳板和代理叠加使用、拖动调整跳板顺序、HTTPS 代理、在主机列表里标出连接方式。**
**Terminal**
对话框最后是「备注」,可以写多行(回车换行),记下机器的用途、负责人、到期时间之类,只保存在本地数据库里,不影响连接。
- Tabbed remote and local terminals (built on `alacritty_terminal`); full-screen programs like vim and htop work as expected.
- Each tab shows the SSH round-trip latency live.
- Find (smart case) and local clear; configurable font, size and line height.
- Drag tabs into side-by-side or stacked splits to watch a terminal and SFTP together.
鼠标停在主机上,行的右侧会显示 `用户@地址:端口`,主机有备注时下面接着显示备注(太长的只显示前 6 行)。右键主机可连接、打开 SFTP、编辑、复制、复制 ID 或删除。「复制 ID」复制的是主机的 16 位随机标识(形如 `Jwg5rHvXCxw89paM`),供其他工具标识这台主机:它在主机创建时生成,改名、改地址都不变,复制出来的主机得到新的 ID,删掉的 ID 不会再分配。没有打开任何标签时,中间区显示「最近连接」列表,双击或回车连接;右键其中的主机弹出同一个菜单,并选中该主机。
**Right sidebar tools**
拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。
- They follow the current remote terminal and read and run on that terminal's own connection, with no second login. ⌘⌥B (Ctrl+Alt+B elsewhere) shows or hides them.
- System monitor: system details, CPU (per core too), memory, network rates and disk use, refreshed every 2 seconds.
- Network connections, processes and services: search and filter, process details, ending processes, starting, stopping and restarting systemd services, switching them on at boot, and their logs. These three and the monitor are for Linux only.
- Docker: containers grouped by compose project, with start, stop, restart, details and logs, plus volumes, images and networks. When not root, passwordless sudo is used (for services as well).
- Command history reads `~/.bash_history` on the host, and snippets, shared by every host, can be sorted into categories; click one to type it into the terminal, or run it straight away.
## 端口转发
**SFTP**
点标题栏的端口转发图标,左侧栏换成转发列表。每条转发规则经由一台已保存的主机,启动时为自己建立一条 SSH 连接,不依赖该主机的终端或 SFTP 标签:关掉标签、对主机「断开连接」都不影响它,主机的连接状态也不把它算在内。有转发在运行时,标题栏的端口转发图标旁显示运行数量。
- A dual-pane browser modeled on WinSCP Commander: symmetric local and remote panes with WinSCP's columns, path labels, bookmarks and shortcuts (F5, F2, F7, F8…).
- Upload and download by dragging or by shortcut, recursively. Start new transfers while one runs; they wait in a per-tab transfer queue.
- Resumable transfers: data goes to a `.filepart` first, dropped connections are retried after 1, 3 and 10 seconds, and after a restart ShellRS offers to resume the same transfer.
- Delete (local items go to the Trash), rename, create, and change permissions (a 3×3 grid plus octal, optionally recursive).
- Built-in editor: double-click a text file to edit it, ⌘S writes it back in place, after checking nobody else changed it; syntax highlighting for common formats. Images and Markdown can be previewed.
- Each SFTP tab has its own connection and asks only for the SFTP subsystem, so no shell is needed on the server.
三种转发,都按「入口 → 出口 → 目标」理解,连接从入口进、经 SSH 隧道到出口、再由出口去连目标:
**Opening from a bastion host**
| 类型 | 入口(监听) | 出口 | 用途 |
| --- | --- | --- | --- |
| 本地转发(`ssh -L`) | 本机 | 服务器 | 在本机访问服务器那边的服务,例如用本机的客户端连只对服务器开放的数据库 |
| 远程转发(`ssh -R`) | 服务器 | 本机 | 让服务器那边访问本机的服务,例如把本机开发中的站点给对方看、接收 Webhook |
| 动态转发(`ssh -D`) | 本机(SOCKS 代理) | 服务器 | 把服务器当代理,目标由应用自己指定,例如让浏览器经由服务器上网 |
- Launched the way Xshell and WinSCP are: in the client of a bastion host such as JumpServer, set ShellRS as the SSH or SFTP client. An `ssh://` link opens a terminal, an `sftp://` link an SFTP tab.
- Understands Xshell's `-url` and `-newtab` and WinSCP's `/sessionname=`. When ShellRS is already running, the link opens there.
- What opens is an unsaved external connection: it stays out of the host list, keeps the link's password in memory only and goes away with its tabs. Each connection uses a single channel, so bastion hosts that allow no more still work. See the [user manual](docs/manual.en.md#opening-from-a-bastion-host-external-connections).
- **新建和编辑**:列表标题栏的「+」打开对话框。上方一行三张卡片选类型,中间的示意图随表单实时填入地址、标出入口在哪台机器上,下面一句话说明这条规则的效果(如「在本机连接 127.0.0.1:8080,就等于从 SSH 服务器连接 db.internal:3306。」)。示意图和这句话里的「SSH 服务器」就是「经由主机」选的那一台,图里按它扮演的角色称呼,不写主机的名称。示意图上有一个圆点沿连接方向缓缓移动:先过 SSH 隧道,再到目标,停一下,周而复始;切换类型时从新的入口重新出发。名称可以不填,列表里显示转发摘要(`8080 → db.internal:3306`、`9000 ← localhost:3000`、`SOCKS 1080`)。
- **启动和停止**:每行末尾的按钮,或双击该行、选中后回车;右键菜单里还有编辑和删除。「创建」只保存规则,不会自动启动。勾选「启动 ShellRS 时自动开启」的规则随应用启动。
- **状态**:行尾显示正在连接、运行中(及当前连接数)、正在重连或已停止的原因。某个连接转发不了(例如服务器连不上目标)只在该行提示,转发本身继续运行。
- **登录**:使用主机的配置、钥匙串里保存的密码和 ShellRS 自己的主机信任记录。首次连接的主机、需要输入的密码会弹出和终端一样的询问框,并注明是哪条转发在连接;取消询问等于停止这条转发。
- **断线重连**:连接中断后按 1、3、10 秒自动重连三次,重连期间本机监听端口保留,新进来的连接会被直接关闭。自动重连不弹任何询问:需要重新输入密码、确认主机密钥时停止并写明原因,手动启动即可。三次都失败也会停止,并弹出通知。
- **修改后**:编辑运行中规则的类型、主机、监听或目标,或编辑它所经由的主机的地址、认证方式和连接方式(包括这台主机所用凭据的用户名、类型或私钥文件,以及它的跳板主机),这条转发会按新设置重新启动;只改名称或自动开启不会。
- **删除主机**:删除主机(或包含它的分组)会连同经由它的转发规则一起删除,运行中的先停止,确认框里写明数量。
- **动态转发**支持 SOCKS5(无认证,`CONNECT`,IPv4、域名、IPv6)和 SOCKS4 / 4a。域名交给服务器解析。
- **监听地址**默认 `127.0.0.1`。填其他地址时,本地和动态转发会被同一网络中的其他设备使用;远程转发需要服务器的 `sshd_config` 设置 `GatewayPorts clientspecified` 或 `yes`,其他机器才连得上;默认是 `no`,这时转发照常运行、不报错,但只监听服务器自己的 `127.0.0.1`(和手写 `ssh -R` 一样)。对话框在填了非回环地址时会提示这一点。1024 以下的端口需要管理员(服务器上是 root)权限。不支持端口 0(由系统挑选端口)。
**Port forwarding**
**尚未实现:通过外部 CLI 管理转发、规则排序。系统的「减少动态效果」设置目前不会关掉示意图的动画。**
- Local (`-L`), remote (`-R`) and dynamic (`-D`, SOCKS5 / SOCKS4) forwards.
- The rule dialog draws a live diagram and a one-line explanation of where connections enter and where they go.
- Every rule keeps its own SSH connection and reconnects three times after a drop; rules can start with ShellRS.
## 凭据
**Credentials**
点标题栏的凭据(钥匙)图标,左侧栏换成凭据列表。一条凭据是一套可以让多台主机共用的登录信息,包括名称、用户名和登录方式:
- Password, key and SSH agent credentials that many hosts can share. Change a credential and every host using it follows.
- Keys can reference a file on disk, be pasted in, or be generated on the spot (Ed25519 or RSA 4096), with the public key one click away.
| 类型 | 保存什么 | 登录时 |
**A CLI for AI agents**
- `shellrs hosts` / `credentials` / `exec` / `upload` / `download` / `sync`: manage hosts and credentials, run commands, move and sync files. The running ShellRS logs in on the command's behalf, so the command itself never reads a password or private key.
- Install `shellrs` into your PATH and an Agent Skill for Claude Code, Codex, OpenCode or WorkBuddy from the settings page.
**Security and updates**
- Secrets stay in the system keychain. Host keys are recorded in ShellRS's own `known_hosts`, never in `~/.ssh`.
- In-app updates: the update manifest is minisign-signed and each package is checked against its SHA-256. Updates download in the background and install on restart or quit. There are stable and beta channels, and an update check sends only the version, OS and CPU architecture.
- Anonymous usage statistics: sent to Aptabase with only the version, OS, CPU architecture and how often each feature is used, never hosts, credentials, commands or any identifier. Turn them off in Settings › About.
## Screenshots
<p align="center">
<img src="docs/screenshots/en/split.png" alt="A split view: a remote terminal on top and SFTP for the same host below"><br>
<em>Dock splits: a remote terminal and SFTP stacked in one window</em>
</p>
<p align="center">
<img src="docs/screenshots/en/tools.png" alt="Five right sidebar tools side by side: Monitor, Processes, Services, Docker and Network"><br>
<em>Right sidebar tools: Monitor, Processes, Services, Docker and Network</em>
</p>
<p align="center">
<img src="docs/screenshots/en/sftp.png" alt="Dual-pane SFTP: three local items selected on the left, remote /etc on the right, and a transfer queue with one batch uploading and one waiting"><br>
<em>WinSCP-style SFTP: highlight selection and a transfer queue with progress, speed and time left</em>
</p>
<p align="center">
<img src="docs/screenshots/en/forward.png" alt="Edit port forward dialog with the diagram and explanation of a local forward, and two running rules in the forward list"><br>
<em>Port forwarding: the diagram shows where each connection goes</em>
</p>
## Download
Download from [shellrs.com/download](https://shellrs.com/download), which offers the package for your system, or from GitHub [Releases](https://github.com/since2006/shell-rs/releases). Betas are marked Pre-release there; to get new features early, switch the update channel to Beta in Settings › About.
| System | Package | Notes |
| --- | --- | --- |
| 密码 | 用户名和密码 | 用凭据保存的密码;没存或被拒绝时询问 |
| 密钥 | 用户名、私钥和口令 | 只用这把私钥;口令按私钥文件保存,用同一个文件的凭据共用 |
| SSH Agent | 用户名 | 只用 SSH Agent 中的密钥:macOS / Linux 上是 `SSH_AUTH_SOCK` 指向的 Agent,Windows 上是 OpenSSH Authentication Agent 服务 |
| macOS 11+ (Apple Silicon) | `ShellRS-<version>-macos-aarch64.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
| macOS 11+ (Intel) | `ShellRS-<version>-macos-x86_64.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
| Windows x64 | `ShellRS-<version>-windows-x86_64-setup.exe` | Installs per user, no administrator rights needed. The installer is not signed yet; if SmartScreen stops it, choose "More info › Run anyway" |
| Linux x64 | `ShellRS-<version>-linux-x86_64.AppImage` | `chmod +x` and run. Needs glibc 2.35 or newer (Ubuntu 22.04, Debian 12 and later) |
- **新建和编辑**:列表标题栏的「+」、空列表里的「新建凭据…」或空白处的右键菜单打开对话框;双击凭据、选中后回车或右键「编辑凭据…」编辑。密码和私钥口令存进系统钥匙串,数据库里只有名称、用户名、类型和私钥路径。
- **密钥从哪里来**:密钥凭据的「私钥」有三种来源。
- **本机文件**:选一个私钥文件,只保存它的路径;文件移动或删除后需要重新选择。
- **粘贴**:贴进 OpenSSH、PEM 或 PuTTY 格式的私钥。贴成公钥或认不出的内容时会说明原因。私钥加了口令时才显示口令栏。
- **生成新密钥**:选 Ed25519(默认)或 RSA 4096,选中就生成;对话框里显示公钥,旁边可以复制。口令可留空;填了就用它加密私钥,口令存进钥匙串。生成的密钥以凭据名称作为注释。
- 粘贴和生成的私钥由 ShellRS 写成数据目录下 `keys/` 里的文件(随机文件名,只有自己的账户能读),凭据引用这个文件,编辑时显示为「本机文件」和它的路径。之后再粘贴或生成会直接覆盖这个文件,已连接的主机不会因此重连;改成别的文件或别的类型、或删除凭据时,这个文件连同它的口令一起删除,对话框会先提示。用户自己的私钥文件 ShellRS 从不改动或删除。
- 空列表和空白处的右键菜单里另有「生成密钥…」,打开的是同一个对话框,已经选好「密钥 · 生成新密钥」。密钥凭据的右键菜单有「复制公钥」,用来把公钥加到服务器的 `~/.ssh/authorized_keys`。
- **主机跟着凭据变**:改凭据的用户名,所有使用它的主机的用户名一起变。改用户名、类型或私钥文件时,使用它的已连接主机会重新连接(只改名称不会);对话框里写明有几台主机在用。
- **删除**:右键「删除」。有主机在用时确认框写明数量和它们之后怎么登录:用密码凭据的主机改为「密码」,连接时询问;用密钥或 SSH Agent 凭据的主机改为「无密码」。用户名不变,密码和私钥不会复制过去。已经打开的连接不受影响,凭据的密码随凭据一起从钥匙串删除。
- **SSH Agent 凭据**连不上 Agent、Agent 里没有密钥或服务器不接受其中任何一把时,会直接说明原因,不会改为询问密码。「无密码」的主机也会尝试同一个 Agent,连不上时静默跳过,接着试默认私钥(Windows 上同样如此)。
Every package comes with a minisign signature (`.minisig`), and each release has a `SHA256SUMS` file.
**尚未实现:在主机对话框里直接新建凭据、通过外部 CLI 管理凭据、凭据排序、自定义 SSH Agent 的套接字、把公钥自动装到主机上、导出 ShellRS 保存的私钥。**
Once installed, ShellRS keeps itself up to date: it checks, downloads and verifies new versions in the background, then shows an icon in the top-right corner of the title bar; click it and choose "Restart to update", or just quit and the update is installed on the way out. Automatic updates are unavailable when ShellRS runs from inside the DMG or outside the Applications folder, or on Linux when it isn't run as the AppImage.
## 设置
## Getting started
侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、外部 CLI、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。
1. Click "New host…" in the title bar (⌘N, or Ctrl+N on Windows and Linux) and fill in the address, port and login. Passwords go into the system keychain.
2. Double-click a host to open a terminal. On the first connection you're asked to confirm the host key.
3. Right-click a host and choose "Open SFTP", or click "SFTP" at the right of the terminal's tab bar. Drag files between the two panes to transfer them.
4. The three icons after "ShellRS" in the title bar switch the sidebar between hosts, port forwards and credentials.
5. ⌘T (Ctrl+T) opens a local terminal. Drag a tab to the top, bottom, left or right edge of the tab area to split it.
- **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认简体中文。英文界面尚在翻译中,目前只有 GPUI Kit 组件自带的文字(如搜索框提示)会切换。
- **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。
- **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(8–32 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
- **外部 CLI**:见下一节。
- **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」。
## For AI agents
## 外部 CLI
`shellrs` 命令让 Codex、Claude Code、OpenCode 等 AI Agent 在 ShellRS 里已保存的主机上执行远程命令、传输文件。命令本身不接触密码和私钥,也不读数据库:它把请求交给正在运行的 ShellRS,由 ShellRS 用保存的配置和钥匙串去连接。请求走本机的进程间通信:macOS、Linux 上是数据目录下的套接字 `cli.sock`,Windows 上是命名管道 `\\.\pipe\shellrs-cli-<数据目录的散列>`。
在 设置 → 外部 CLI 中:
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
- **CLI 二进制**:把 `shellrs` 放进 PATH。
- macOS:链接到 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框。
- Linux:链接到 `~/.local/bin/shellrs`。
- macOS、Linux 上「移除」只删除指向 shellrs 的链接,同名的其他文件不会被改动。
- Windows:图形程序没有控制台,命令是单独的控制台程序 `shellrs-cli.exe`(与 `shellrs.exe` 放在一起)。「安装」把它复制成 `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe`,并把这个文件夹加入当前用户的 PATH(不需要管理员),重新打开的终端和 Agent 才能找到它。ShellRS 升级后启动时会自动更新这份副本;更新不了时显示「更新」。副本正被使用时也能更新。「移除」删掉副本并把文件夹移出 PATH。
- **Agent Skills**:把教 Agent 使用 `shellrs` 的 skill 写到通用目录(`~/.agents/skills`)或 Codex、Claude Code、OpenCode 各自的 skills 目录;内容和当前版本不同时显示「更新」。「复制 skills」把 skill 全文复制到剪贴板。
命令(`shellrs --help` 查看完整说明):
In Settings › External CLI, turn on Enable external CLI, then install the `shellrs` command and the Agent Skills. Your agent can then use the hosts you have saved:
```sh
shellrs list [--query <关键词>] [--json] # 列出主机;输出不是终端时自动用 JSON
shellrs exec <ID> "<命令>" # 或 --stdin 从标准输入读命令
shellrs upload <ID> <本地路径> <远程路径>
shellrs download <ID> <远程路径> <本地路径>
shellrs hosts list -q web # list hosts and their IDs
shellrs exec <ID> "uptime && df -h" # run a command; the exit code is the remote one
shellrs upload <ID> ./dist/app.tar.gz /opt/app/ # upload
shellrs download <ID> /var/log/syslog ./logs/ # download
shellrs sync <ID> ./dist /opt/app --delete # sync a folder, skipping unchanged files
```
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里,多行或带引号的命令用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
The command hands each request to the running ShellRS, which logs in with the saved settings and keychain. An unknown host or a missing password fails right away with a stable error code instead of a prompt that would hang the agent. See the [user manual](docs/manual.en.md#external-cli) for details.
## 标签页
## Building from source
远程终端标签和 SFTP 标签都以主机系统徽章为图标(与主机列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为主机的名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑主机,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「主机的名称 · SFTP」)、打开 SFTP、复制 IP 地址 / 主机名、重新连接加关闭命令,标签栏右侧和远程终端一样有「SFTP」和「重新连接」按钮;本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。双击任一标签的标题可隐藏或显示左侧的侧栏(同 ⌘B 和标题栏的按钮)。远程终端运行时和 SFTP 标签已连接时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。SFTP 传输进行中也照常测量,读数会随传输占满带宽而升高。批量关闭时,正在上传的 SFTP 标签仍会先确认。
## 终端
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
## SFTP
打开主机的「SFTP」标签即可浏览本地与远程目录;和终端一样,每次「打开 SFTP」都新开一个标签,同一台主机可以开多个,各自独立连接、各看各的目录。操作方式对标 WinSCP Commander。SFTP 使用独立 SSH 连接,仅请求 SFTP 子系统,不要求服务器提供 Shell;关闭终端不会中止传输。
### 工具栏
每一侧面板都有两行工具栏,下方是路径标签。面板太窄放不下时,按钮从右往左整个隐藏,不会换行(对应的命令在右键菜单和快捷键里仍然可用)。新开的 SFTP 标签本地、远程两侧各占一半;两侧和队列之间的分隔线拖到哪里,在这个标签关闭之前都会记住,切到别的标签再回来也不变:
- 第一行(导航):
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。
- 书签:点按钮打开「打开目录/书签」(见下);旁边的下拉箭头列出这台主机这一侧的书签,点击即前往,也可添加或移除当前路径。书签保存在本地数据库,删除主机时一并删除。
- 上级目录、根目录、主目录、刷新、后退、前进。
- 第二行(文件):
- 远程是「下载…」,本地是「上传…」,下拉里还有「选择文件上传…」。
- 删除、重命名…、属性…、新建(文件夹… / 文件…)。
### 路径标签
与 WinSCP 的路径标签相同:
- 显示当前目录的完整路径,例如 `/home/tester/`。
- 每一级都可以点:悬停时从根到该级高亮,单击即前往。
- 当前使用的一侧(最近点过的一侧)底部有一条深蓝色线。
- 路径放不下时保留根目录和末尾几级,中间折成 `…`,点 `…` 前往被折叠的最后一级。
- 单击当前目录、双击路径右侧空白处打开「打开目录/书签」。
- 右键:前往、刷新、添加路径到书签、复制路径、打开目录/书签…。
### 打开目录/书签
与 WinSCP 的「打开目录」对话框相同,可以从路径标签、书签按钮、⌘O(其他平台 Ctrl+O;macOS 另有 ⌘⇧G)或路径标签的右键菜单打开:
- 「目录」预填当前目录并已选中,直接输入即可替换。`~` 表示主目录,相对路径从当前目录算起。本地侧另有「浏览…」。
- 下方是这台主机这一侧的书签。与目录相同的书签处于选中状态;单击书签把它填入目录,双击直接打开。
- 「添加」把目录加入书签,「删除」(或在列表中按 Delete)删除选中的书签并选中相邻的一个,「上移」「下移」调整顺序。
- 书签的改动立即保存,按「取消」也不会撤销,与 WinSCP 相同。
- 回车或「打开」前往目录;Esc 或「取消」关闭。
列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 主机的名称」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单;断开时在远程列表里做任何操作(刷新、打开目录、删除、上传下载等)会弹出「SFTP 连接已断开」对话框,说明原因,并可直接「重新连接」。这些都不会让列表移动。
列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。
- 大小默认以 KB 显示(`4,008,960 KB`,向上取整,只有空文件是 0 KB),与 WinSCP 相同。右键「大小」列标题可切换为「字节」(`4,105,175,040 B`)、「千字节(KB)」或「简短格式」(按大小用 B、KB、MB、GB,如 `3.8 GB`),对所有 SFTP 标签生效并保存在设置里。按大小排序始终按实际字节数。
- 时间形如 `2026/4/22 12:44:53`。
- 权限形如 `rwxr-xr-x`。
- 所有者来自服务器的 `ls -l` 行。
- 链接到目录的符号链接显示为带箭头的文件夹,可以双击进入。
### 选择与右键菜单
- 与 WinSCP 关闭「整行选择」时相同,「名称」格才是项目:鼠标移上去时只有名称格变灰,单击、双击、右键都作用在名称格上,选中时整个名称格高亮;行里的其他列和列表下方都算空白处。
- 鼠标:单击名称格选中(其他选择取消),⌘ 单击(其他平台 Ctrl 单击)增减,Shift 单击选取范围,双击打开。单击空白处取消全部选择(按住 ⌘ 或 Shift 时不取消)。
- 框选:在名称右边的空白、大小、类型、时间等列或列表下方按下拖动,画出选框,框住的行被选中;按住 ⌘ 或 Shift 开始拖动则加到已有选择上。拖到列表上下边缘时自动滚动。拖动文件名才是把文件拖到另一侧传输:按下未选中的文件名时只选中它(其他选择取消),拖走的就是它一个;按下已选中的文件名则拖走全部已选。拖动时只有到了另一侧列表上才显示「上传 / 下载 N 个项目」,在自己的列表或其他不能放下的地方,指针显示为禁止。
- 键盘:↑↓、Home、End、PageUp、PageDown 移动,加 Shift 扩展;空格或 Insert 切换当前行;⌘A 全选;Tab 切到另一侧。
- 右键未选中的名称格会先选中它;右键空白处取消选择并弹出空白处菜单。
- 文件名上的菜单:打开、上传… / 下载…、删除、重命名…、属性…。
- 空白处的菜单:前往(上级 / 根 / 主目录 / 后退 / 前进)、刷新、添加路径到书签、新建。
### 快捷键
| 命令 | 快捷键 |
|---|---|
| 上传 / 下载(按所在面板) | F5 |
| 重命名 | F2 |
| 新建文件夹 | F7 |
| 删除 | F8、Delete;macOS 另有 ⌘⌫ |
| 属性 | F9;macOS 另有 ⌘I |
| 刷新 | ⌘R / Ctrl+R |
| 上级目录 | Backspace;macOS 另有 ⌘↑ |
| 根目录 | ⌘\ / Ctrl+\ |
| 主目录 | macOS ⌘⇧H,其他平台 Ctrl+H |
| 后退 / 前进 | ⌥← / ⌥→;macOS 另有 ⌘[ / ⌘] |
| 添加路径到书签 | ⌘D / Ctrl+D |
| 打开目录 | Enter;macOS 另有 ⌘↓ |
| 打开目录/书签 | ⌘O / Ctrl+O;macOS 另有 ⌘⇧G |
### 文件操作
- 删除前会确认,并写明对象和后果:
- 本地项目移到废纸篓。
- 远程项目永久删除,目录连同其中全部内容。
- 删除符号链接只删除链接本身。
- 重命名和新建:
- 按当前列表检查名称;不能为空,不能是 `.` 或 `..`,不能与已有项目同名。
- 重命名不会覆盖已有项目。
- 属性:
- 显示位置、大小、修改时间,远程另有所有者和组。
- 权限用 3×3 复选框和八进制数字编辑,两者同步。
- 多选时,项目之间不一致的权限位保持原样,除非你改动它。
- 含目录时可以递归应用;递归时默认「为目录添加执行权限(X)」,避免目录无法进入。
- 符号链接不参与修改权限。
### 上传与下载
- 入口:
- 上传:「上传…」、本地列表中的 F5、「选择文件上传…」,或按住本地项目的文件名拖到远程列表,或把 Finder 中的文件拖到远程列表。
- 下载:「下载…」、远程列表中的 F5,或按住远程项目的文件名拖到本地列表。
- 拖到目录行时放进该目录,拖到其余区域时放进当前目录。
- 所有入口都显示确认框,可以修改目标目录;下载可以用「浏览…」选择本机目录。确认框写明数量和种类(如「上传 2 个文件到 …」),来源的所在目录只写一次,每个项目占一行,悬停可看完整路径。
- 确认后来源、端点和目标即固定。每个 SFTP 标签同时运行一个批次(上传和下载共用),批内依次处理;传输进行中还可以继续上传或下载,新的批次排进传输队列,前一批结束后自动开始。其他 SFTP 标签、主机和终端可以继续操作。
- 递归传输包含空目录。符号链接原样创建,不跟随。默认二进制传输,保留普通文件的修改时间。
- 新建项使用目标一侧的默认权限,覆盖时保留目标原有的基本权限。不复制所有者、ACL 或扩展属性。
- 同名目录合并。文件或链接冲突时询问覆盖、跳过或取消,可以应用于本批剩余冲突;目录与非目录冲突不会自动删除。
- 出错时可以重试、跳过或取消,完成后的汇总分别列出成功、跳过和失败。
### 传输队列
与 WinSCP 的队列面板相同,有传输时出现在双栏下方,高度可拖动,清除全部已结束的批次后收起。
- 每一批一行:操作(上传 / 下载)、来源(一个项目时是它的路径,多个项目时是所在目录)、目标、已传输(单位与「大小」列相同)、时间(剩余时间)、速度(最近几秒)和进度。
- 进行中的批次显示总进度条,下面一行是正在传的文件和它自己的进度条,对应 WinSCP 进度对话框里的两个进度条。
- 进度列:等待中、正在扫描、百分比、等待回答、重连说明、已停止、已完成、部分失败(N 项)、已移出。
- 点行首的箭头展开这一批的逐项结果(完成、已跳过、失败及原因),最新的在最上面。
- 工具栏:继续(停止的批次)、停止(进行中的批次)、移出队列(选中的排队中或已结束的批次,或停止的批次)、丢弃续传进度、清除已完成。
- 停止的批次留在队首,等你继续、丢弃续传进度或移出队列之后,后面的批次才会开始;移出队列不删除续传记录,下次传同样的文件仍会询问是否续传。
- 队列只在本次运行中保留,关闭 SFTP 标签时一起清除;有未完成的批次时关闭标签会先确认。
## 断点续传和替换
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后才发布正式文件;服务器支持时先调用 `fsync@openssh.com`。支持 `posix-rename@openssh.com` 的服务器使用原子替换;其他服务器先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,此兼容路径不保证原子性。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
续传记录保存在应用数据目录的 `upload-resume/`,采用版本化 JSON 和原子写入,不写入密码、口令或私钥,不改变数据库结构。记录用于绑定端点、主机指纹、来源、目标和安全替换阶段。续传方式与 WinSCP 相同:读取远端 `.filepart` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。
下载同样先写入本机的 `<目标文件名>.filepart`,完成后设置远程的修改时间、fsync,再在同一文件系统内重命名为正式文件(原子替换)。并发的读取请求可能乱序、短读返回,但临时文件严格按顺序写入,因为续传只信它的长度。下载续传记录保存在 `download-resume/`,绑定端点、主机指纹、远程来源及其大小与修改时间、本地目标;远程文件变化后不能续传,只能重新下载或跳过。
网络故障每批最多自动重连三次,间隔 1、3、10 秒。耗尽后使用「继续上传」或「继续下载」,手动恢复会重置次数。认证失败、主机密钥变化和主动取消需要用户处理。重连沿用原连接配置,编辑主机不会改变正在传输的目标。
取消、关闭标签、断开连接或退出时保留续传记录;关闭正在传输的标签会先确认。重启后不会自动继续,再次选择相同来源和目标时会询问续传。停止状态下可使用「丢弃续传进度」清理该批受管理的临时文件;存在无法判断的恢复备份时会保留文件并说明原因。成功发布的项目自动清理记录和备份。
文件名不能准确表示时会明确报错。当前 `russh-sftp 3.0.0` 的远端字符串解码会替换无效 UTF-8,浏览器因此拒绝包含替换字符 `U+FFFD` 的远端名称,避免访问错误路径。符号链接沿用 OpenSSH 的 SFTP v3 参数顺序。
**尚未实现:目录同步、前台传输进度对话框、队列排序、过滤、查找文件、目录树和 Dock 布局持久化。**
## 下载与更新
安装包在 [shellrs.com/download](https://shellrs.com/download):
| 系统 | 首次安装 | 自动更新 |
|---|---|---|
| macOS(Apple Silicon 和 Intel 共用一个包) | DMG,把 ShellRS 拖进「应用程序」 | 支持 |
| Windows x64 | 安装程序,按当前用户安装到 `%LOCALAPPDATA%\Programs\ShellRS`,不需要管理员 | 支持 |
| Linux x64 | AppImage | 支持 |
ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现更新图标。点开可以看更新内容,点「重启并安装」就换成新版本并重新打开。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。
「设置 › 关于」里的「应用更新」只有三行:
- **当前版本**:下面写着现在的状态(已是最新、正在下载、已下载等),右边是版本号和一个按钮:平时是「检查更新」,下载好之后变成「重启并安装…」。构建号和平台在版本号的悬停提示里。
- **更新渠道**:稳定版适合日常使用;Beta 可以提前收到测试版本,稳定版发布时也会收到。切换后马上按新渠道检查(关掉自动升级时等你手动检查)。
- **自动升级**:关掉之后只在手动检查时才联网,发现新版本时由你决定是否下载。
- **校验**:更新清单由 ShellRS 的发布密钥签名,安装包按清单里的大小和 SHA-256 核对,任何一步对不上都不会安装。macOS 上还会核对新版本的开发者签名。
- **隐私**:检查更新只发送 ShellRS 的版本号、操作系统和 CPU 架构,不发送任何能识别这台电脑或用户的信息。
- **不能自动更新的情况**:从 DMG 里直接运行、没有放进「应用程序」文件夹、对安装位置没有写权限、Linux 上不是用 AppImage 运行,或者是自己编译的开发构建。这时「关于」页会说明原因,有新版本时给出「前往下载页」。
- **退回旧版本**:数据库在升级到新结构之前会备份成 `shellrs.db.v<旧版本>.bak`。旧版本打开新结构的数据库时会拒绝打开,以免把新版本保存的数据弄乱;需要退回时,把备份改名回 `shellrs.db`。
## 发布
在 `CHANGELOG.md` 里写好 `## [x.y.z]` 一节,把 `Cargo.toml` 的版本改成 `x.y.z`,提交后推送 tag `vx.y.z`。`.github/workflows/release.yml` 依次:
1. 核对 tag 与 `Cargo.toml` 一致,截出更新说明,确认代码里有发布公钥;
2. 构建并打包:macOS 合成 universal,签名、公证、staple,产出升级用的 `.app.zip` 和首次安装用的 `.dmg`;Windows 用 Inno Setup 打安装程序;Linux 打 AppImage;
3. 在 `release` 环境里人工批准后:给每个包签名,上传到 R2(`dl.shellrs.com/releases/<版本>/`),建 GitHub Release,**最后**才替换更新清单 `dl.shellrs.com/update/v1/<通道>.json` 并清 CDN 缓存。
版本号带 `-` 的(如 `0.3.0-beta.1`)只发到 beta 通道;正式版同时成为 beta 通道的最新版。在 Actions 里手动运行这个 workflow 只构建打包、不发布,用来演练。客户端只接受比自己新的版本,所以发错的版本撤不回来:把清单改回上一版能阻止更多人升级,修复要发新的补丁版。
一次性准备:
- **更新签名密钥**:`minisign -G -p shellrs-update.pub -s shellrs-update.key` 生成两对(一对日常用,一对离线保存备用),把两个公钥(`.pub` 文件的第二行)填进 `src/update/build_info.rs` 的 `TRUSTED_KEYS`。私钥和口令只放进 GitHub 的 `release` 环境,另外离线备份;丢了私钥,已发出去的 ShellRS 就再也收不到更新。换钥匙时先发一版带上新公钥,再换签名用的私钥。
- **Cloudflare**:shellrs.com 的 DNS 托管在 Cloudflare;建 R2 bucket,绑定自定义域 `dl.shellrs.com`,关闭 `r2.dev` 地址;Cache Rules 让 `/releases/*` 和 `/update/*` 按源站的 Cache-Control 缓存(默认不缓存 `.json`、`.AppImage`);一个只能写这个 bucket 的 R2 API Token,一个只能清这个 zone 缓存的 API Token。
- **Apple**:Developer ID Application 证书(导出 .p12)和 notarytool 用的 App Store Connect API Key。`packaging/macos/Info.plist.in` 里的 bundle id `com.shellrs.ShellRS` 和签名的 Team ID 一经发布就不能再改:钥匙串按它们授权,改了之后每条保存的密码都要重新允许。
- **图标**:`assets/logo/` 里的 `shellrs.icns`(macOS)、`shellrs.png`(1024×1024,Linux,macOS 没有 icns 时也由它生成)和 `shellrs.ico`(Windows 安装程序)。
- **GitHub secrets**:仓库级的 `APPLE_CERTIFICATE_P12`(base64)、`APPLE_CERTIFICATE_PASSWORD`、`APPLE_SIGNING_IDENTITY`、`APPLE_TEAM_ID`、`APPLE_API_KEY_P8`(base64)、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`;`release` 环境(设为需要审批)里的 `MINISIGN_SECRET_KEY`、`MINISIGN_PASSWORD`、`R2_ACCOUNT_ID`、`R2_ACCESS_KEY_ID`、`R2_SECRET_ACCESS_KEY`、`R2_BUCKET`、`CF_ZONE_ID`、`CF_API_TOKEN`。
清单格式见 `packaging/manifest.example.json`,由 `packaging/make-manifest.sh` 生成:一个文件里是清单原文和它的 minisign 签名,签名的 trusted comment 写明通道和版本。格式只增加字段、不改已有的;已发出去的 ShellRS 一直按 `/update/v1/` 这个地址检查。
## 运行和验证
工具链固定 Rust 1.98.1,`russh-sftp` 固定 3.0.0;保持仓库锁定的 GPUI 与 russh 版本。
Install [rustup](https://rustup.rs). The repository's `rust-toolchain.toml` pins Rust 1.98.1, which rustup installs on the first build.
```sh
git clone https://github.com/since2006/shell-rs.git
cd shell-rs
cargo run
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check
```
数据目录默认是系统应用数据目录中的 `shellrs`,可通过 `SHELLRS_DATA_DIR` 覆盖。主机、端口转发规则和凭据(不含密码)保存在 `shellrs.db`,主机信任记录保存在该目录的 `known_hosts`,凭据里粘贴或生成的私钥保存在该目录的 `keys/` 下,密码和私钥口令使用系统钥匙串(服务名 `shellrs`;账户名是主机端点的 `password:<用户>@<地址>:<端口>`、私钥的 `passphrase:<路径>`、密码凭据的 `credential:<随机 ID>` 和代理的 `proxy:<用户>@<代理地址>:<端口>`)。
The first build compiles the whole GPUI stack and takes a while. See [development notes](docs/development.md) (in Chinese) for the Linux system packages and how to run the tests.
`src/sftp/tests.rs` 使用临时目录与故障注入覆盖上传和下载的文件内容、空目录、链接、覆盖、权限、取消、源文件变化、乱序与短读、续传及替换各阶段恢复,以及删除、重命名、新建和递归改权限不跟随链接。Unix 协议测试使用本机 OpenSSH `sftp-server`(macOS `/usr/libexec/sftp-server`,Linux `/usr/lib/openssh/sftp-server`);工作线程测试仅监听 `127.0.0.1` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。
## Documentation
`src/forward/protocol_tests.rs` 用进程内的 SSH 服务器和本机的回显服务测试真实的转发工作线程:三种转发的往返、目标不可达、服务器禁止转发、端口被占用、空闲断线后重连、重试耗尽和停止后的清理,同样只监听 `127.0.0.1` 的随机端口。
Apart from the user manual, the documentation is in Chinese for now:
`tests/ui/` 里的 UI 测试驱动真实 `Workspace`,通过 `Workspace::new_with_services` 注入 SFTP、本地目录、终端和端口转发服务,不连接用户服务器或真实钥匙串;在线升级的测试给 `workspace.updater()` 注入假的更新服务器和安装器,不联网、不改任何安装。Finder 的系统原生拖放仍需在真实 macOS 窗口中补充人工验收;无头测试覆盖原生文件拖入事件和面板内部拖放。
- [User manual](docs/manual.en.md): every feature in detail, with shortcuts
- [Development](docs/development.md): building, testing, the data directory
- [Releasing](docs/release.md): packaging, signing and publishing updates
- [Changelog](CHANGELOG.md)
开发构建(`cargo run`)不检查更新。要在本机走一遍更新流程,用 debug 构建加环境变量 `SHELLRS_UPDATE_CHANNEL=beta` 编译,运行时用 `SHELLRS_UPDATE_URL=http://127.0.0.1:8000/{channel}.json` 指向本地的静态服务器(`python3 -m http.server`),清单用 `packaging/make-manifest.sh` 和自己生成的测试密钥签名,测试公钥放在环境变量 `SHELLRS_UPDATE_PUBLIC_KEY` 里。release 构建不认这两个环境变量。
## Roadmap
上传交互参考 [WinSCP 上传流程](https://winscp.net/eng/docs/task_upload),协议适配参考 [russh-sftp 请求接口](https://docs.rs/russh-sftp/3.0.0/russh_sftp/client/struct.RawSftpSession.html) 与 [OpenSSH 扩展规范](https://raw.githubusercontent.com/openssh/openssh-portable/master/PROTOCOL)。
Not implemented yet:
- Directory sync, filtering, file search and a directory tree in SFTP
- Keeping the Dock layout across restarts
- Combining a jump host chain with a proxy, and HTTPS proxies
- Managing port forwards and credentials through the external CLI
- Code signing for the Windows installer
## Contributing
Issues and pull requests are welcome on [GitHub](https://github.com/since2006/shell-rs/issues). Architecture notes and code conventions are in [CLAUDE.md](CLAUDE.md) (in Chinese). Before sending a change, make sure these pass:
```sh
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```
## Acknowledgements
- [GPUI](https://github.com/zed-industries/zed) (Zed) and [gpui-kit](https://gpui-kit.com): the UI framework
- [russh](https://github.com/Eugeny/russh) and [russh-sftp](https://github.com/AspectUnk/russh-sftp): SSH and SFTP
- [alacritty_terminal](https://crates.io/crates/alacritty_terminal) and [portable-pty](https://crates.io/crates/portable-pty): terminal emulation and local PTYs
- [keyring](https://crates.io/crates/keyring): the system keychain
- [Simple Icons](https://simpleicons.org): the OS logos on host badges and the Docker logo in the right sidebar (CC0)
- [WinSCP](https://winscp.net) and Xshell: the interaction designs ShellRS follows
## License
ShellRS is licensed under the [GNU General Public License v3.0](LICENSE). You may use, modify and redistribute it, commercially or not, but any modified version you distribute must come with its complete source code under the GPL-3.0 as well.
+203
View File
@@ -0,0 +1,203 @@
<p align="center">
<img src="assets/logo/shellrs.svg" width="96" alt="ShellRS">
</p>
<h1 align="center">ShellRS</h1>
<p align="center">
用 Rust 编写、由 GPU 渲染界面的高性能原生跨平台 SSH 客户端:Xshell 式的主机管理、WinSCP 式 SFTP 和端口转发,都在一个窗口里。
</p>
<p align="center">
<a href="https://github.com/since2006/shell-rs/releases"><img src="https://img.shields.io/github/v/release/since2006/shell-rs?include_prereleases&label=release" alt="Release"></a>
<a href="#许可证"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
</p>
<p align="center"><a href="README.md">English</a> | <b>简体中文</b></p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/main-dark.png">
<img src="docs/screenshots/main-light.png" alt="ShellRS 主界面:左侧是分组的主机树,右侧远程终端里运行着 htop,标签栏显示连接延迟">
</picture>
## 简介
ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SFTP 文件管理、SSH 端口转发放进同一个跨平台应用,JumpServer 等堡垒机也可以像调用 Xshell、WinSCP 一样调用它。它用 Rust 编写,界面基于 Zed 编辑器的 GPU 加速 UI 框架 GPUI(通过 [gpui-kit](https://gpui-kit.com)),不依赖 Electron 或 JVM。
主机、分组、凭据和转发规则保存在本地的 SQLite 数据库里。密码和私钥口令只存进系统钥匙串(macOS 钥匙串、Windows 凭据管理器、Linux Secret Service),数据库里没有任何秘密。ShellRS 还带一个 `shellrs` 命令,让 Claude Code、Codex 等 AI Agent 用你保存的主机执行命令、传输文件,不用把密码交给它们。
## 功能
**主机管理**
- 分组可以任意层级嵌套,支持拖动整理和搜索。
- 连上主机后自动识别远程系统,在主机树和标签上显示对应的系统徽章(各 Linux 发行版、BSD、macOS、Windows)。
- 认证方式三选一:密码、无密码(依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥)、使用凭据。
- 连接方式三选一:直接连接、多级 SSH 跳板、HTTP / SOCKS5 代理。终端、SFTP、端口转发和外部 CLI 都按它连接。
- 「测试连接」会真实登录一次,失败时写明原因。
**终端**
- 多标签的远程终端和本地终端(基于 `alacritty_terminal`),vim、htop 等全屏程序正常显示。
- 标签栏实时显示这条 SSH 连接的往返延迟。
- 支持查找(智能大小写)和本地清屏,字体、字号、行高都可以设置。
- 标签可以拖成左右或上下分栏,终端和 SFTP 并排看。
**右侧栏工具**
- 跟着当前的远程终端,在这条终端自己的连接上读取和执行,不另外登录;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏。
- 系统监控:系统信息、CPU(含每个核)、内存、网卡速率和磁盘占用,每 2 秒刷新。
- 网络连接、进程管理和系统服务:搜索和筛选,查看进程详情、结束进程,启动、停止、重启 systemd 服务,开关开机启动,查看日志。这三项和系统监控只支持 Linux。
- Docker:按 compose 项目列出容器,启停、重启、看详情和日志,管理卷、镜像和网络。不是 root 时用免密码的 sudo(系统服务也是)。
- 历史命令读主机上的 `~/.bash_history`,命令片段所有主机共用、可以分类;点一下输入到终端,或直接执行。
**SFTP**
- 对标 WinSCP Commander 的双栏文件浏览器:本地和远程两侧对称,WinSCP 的列、路径标签、书签和快捷键(F5、F2、F7、F8……)。
- 拖动或快捷键上传、下载,支持递归。传输进行中还能继续发起,新批次进传输队列依次执行。
- 断点续传:先写 `.filepart`,断线后按 1、3、10 秒自动重连;重启后再传同样的文件,会询问是否续传。
- 删除(本地移到废纸篓)、重命名、新建、修改权限(3×3 复选框加八进制,可递归)。
- 内置编辑器:双击文本文件直接编辑,⌘S 原地写回服务器,保存前检查文件是否被别人改过;常见格式语法高亮。图片和 Markdown 可以预览。
- 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。
**从堡垒机打开**
- 兼容 Xshell 和 WinSCP 的命令行调用方式:在 JumpServer 等堡垒机的客户端里把 ShellRS 设成 SSH / SFTP 客户端,`ssh://` 链接打开终端,`sftp://` 链接打开 SFTP。
- 认得 Xshell 的 `-url`、`-newtab` 和 WinSCP 的 `/sessionname=`;ShellRS 已在运行时,链接交给正在运行的那个打开。
- 打开的是不保存的外部连接:不进主机列表,链接里的密码只留在内存里,标签关掉就没了。每条连接只用一个通道,不允许多开通道的堡垒机也能用。详见[使用手册](docs/manual.md#从堡垒机打开外部连接)。
**端口转发**
- 本地(`-L`)、远程(`-R`)和动态(`-D`,SOCKS5 / SOCKS4)转发。
- 配置对话框里有实时示意图和一句话说明,看得出连接从哪进、到哪去。
- 每条规则自己建立 SSH 连接,断线后自动重连三次;可以设为随 ShellRS 启动自动开启。
**凭据**
- 密码、密钥、SSH Agent 三种凭据,多台主机可以共用一条;改凭据,用它的主机一起变。
- 密钥可以引用本机文件、直接粘贴,或当场生成 Ed25519 / RSA 4096,并复制公钥。
**给 AI Agent 用的 CLI**
- `shellrs hosts` / `credentials` / `exec` / `upload` / `download` / `sync`:管理主机和凭据、执行命令、传输和同步文件。由正在运行的 ShellRS 代为登录,命令本身不读密码和私钥。
- 在设置页一键把 `shellrs` 放进 PATH,并为 Claude Code、Codex、OpenCode、WorkBuddy 安装 Agent Skill。
**安全与更新**
- 秘密只进系统钥匙串;主机信任记录存在 ShellRS 自己的 `known_hosts` 里,不改动 `~/.ssh`。
- 在线升级:更新清单经 minisign 签名,安装包按 SHA-256 校验;后台下载,重启或退出时安装。分稳定版和 Beta 两个渠道,检查更新只发送版本号、系统和 CPU 架构。
- 匿名使用统计:经 Aptabase 只发送版本、系统、CPU 架构和各功能的使用次数,不含主机、凭据、命令或任何标识,可在「设置 › 关于」关闭。详见[使用手册](docs/manual.md#隐私)。
## 截图
<p align="center">
<img src="docs/screenshots/split.png" alt="Dock 分栏:上面是远程终端,下面是同一台主机的 SFTP"><br>
<em>Dock 分栏:远程终端和 SFTP 上下并排</em>
</p>
<p align="center">
<img src="docs/screenshots/tools.png" alt="右侧栏的五个工具并排:系统监控、进程管理、系统服务、Docker 和网络连接"><br>
<em>右侧栏工具:系统监控、进程管理、系统服务、Docker 和网络连接</em>
</p>
<p align="center">
<img src="docs/screenshots/sftp.png" alt="双栏 SFTP:左侧本地目录多选了三项,右侧是远程 /etc,下方传输队列里一批正在上传、一批等待中"><br>
<em>WinSCP 式双栏 SFTP:高亮多选,传输队列显示进度、速度和剩余时间</em>
</p>
<p align="center">
<img src="docs/screenshots/forward.png" alt="编辑端口转发对话框:本地转发的示意图和说明,左侧转发列表里两条规则正在运行"><br>
<em>端口转发:示意图实时说明连接怎么走</em>
</p>
## 下载
到官网 [shellrs.com/download](https://shellrs.com/download) 下载,页面会按你的系统给出对应的安装包;也可以到 GitHub [Releases](https://github.com/since2006/shell-rs/releases) 下载。Beta 版在 Releases 里标为 Pre-release,想提前用上新功能,可以在「设置 › 关于」里把更新渠道切到 Beta。
| 系统 | 安装包 | 说明 |
| --- | --- | --- |
| macOS 11+(Apple Silicon) | `ShellRS-<版本>-macos-aarch64.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
| macOS 11+(Intel) | `ShellRS-<版本>-macos-x86_64.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
| Windows x64 | `ShellRS-<版本>-windows-x86_64-setup.exe` | 按当前用户安装,不需要管理员权限。安装程序暂未签名,SmartScreen 拦下时点「更多信息 › 仍要运行」 |
| Linux x64 | `ShellRS-<版本>-linux-x86_64.AppImage` | `chmod +x` 后直接运行,需要 glibc 2.35 以上(Ubuntu 22.04、Debian 12 及更新的版本) |
每个安装包都附带 minisign 签名(`.minisig`),另有一份 `SHA256SUMS`。
装好之后不用再手动下载:ShellRS 会在后台检查、下载并校验新版本,标题栏右上角出现提示后点「重启并安装」;不点的话,退出时也会自动装好。直接在 DMG 里运行、没放进「应用程序」文件夹,或者在 Linux 上不是用 AppImage 运行时,无法自动更新。
## 快速上手
1. 点标题栏的「新建主机…」(⌘N,Windows / Linux 上 Ctrl+N),填写地址、端口和认证方式,密码会存进系统钥匙串。
2. 双击主机打开终端。第一次连接时会请你确认主机密钥。
3. 右键主机选「打开 SFTP」,或点终端标签栏右侧的「SFTP」,在两栏之间拖动文件即可上传、下载。
4. 标题栏「ShellRS」后面的三个图标切换侧栏:主机、端口转发、凭据。
5. ⌘T(Ctrl+T)打开本地终端。把标签拖到中间区域的上、下、左、右边缘,就能分栏。
## 给 AI Agent 用
在「设置 › 外部 CLI」里打开「启用外部 CLI」,安装 `shellrs` 命令和 Agent Skills。之后 Agent 就能直接用你保存的主机:
```sh
shellrs hosts list -q web # 列出主机和它们的 ID
shellrs exec <ID> "uptime && df -h" # 执行命令,退出码就是远程命令的退出码
shellrs upload <ID> ./dist/app.tar.gz /opt/app/ # 上传
shellrs download <ID> /var/log/syslog ./logs/ # 下载
shellrs sync <ID> ./dist /opt/app --delete # 同步目录,没变的文件跳过
```
命令把请求交给正在运行的 ShellRS,由它用保存的配置和钥匙串登录。遇到陌生主机或缺少密码时,命令直接失败、返回稳定的错误码,不会弹框卡住 Agent。详见[使用手册](docs/manual.md#外部-cli)。
## 从源码构建
需要先装 [rustup](https://rustup.rs)。仓库里的 `rust-toolchain.toml` 固定了 Rust 1.98.1,第一次构建时会自动安装。
```sh
git clone https://github.com/since2006/shell-rs.git
cd shell-rs
cargo run
```
首次构建要编译整个 GPUI 栈,需要一段时间。Linux 需要的系统依赖和测试方法见[开发与测试](docs/development.md)。
## 文档
- [使用手册](docs/manual.md):每个功能的详细说明和快捷键
- [开发与测试](docs/development.md):构建、测试和数据目录
- [发布流程](docs/release.md):打包、签名和在线更新的发布步骤
- [更新日志](CHANGELOG.md)
## 路线图
以下功能尚未实现:
- SFTP 的目录同步、过滤、查找文件和目录树
- Dock 布局在重启后保留
- 跳板和代理叠加使用、HTTPS 代理
- 通过外部 CLI 管理端口转发和凭据
- Windows 安装程序的代码签名
## 参与贡献
欢迎提交 [Issue](https://github.com/since2006/shell-rs/issues) 和 Pull Request。架构说明和代码约定见 [CLAUDE.md](CLAUDE.md)。提交前请确认下面三条都通过:
```sh
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```
## 致谢
- [GPUI](https://github.com/zed-industries/zed)(Zed)和 [gpui-kit](https://gpui-kit.com):界面框架
- [russh](https://github.com/Eugeny/russh) 和 [russh-sftp](https://github.com/AspectUnk/russh-sftp):SSH 与 SFTP 协议
- [alacritty_terminal](https://crates.io/crates/alacritty_terminal) 和 [portable-pty](https://crates.io/crates/portable-pty):终端模拟与本地 PTY
- [keyring](https://crates.io/crates/keyring):系统钥匙串
- [Simple Icons](https://simpleicons.org):系统徽章和右侧栏 Docker 工具使用的 logo(CC0)
- [WinSCP](https://winscp.net) 和 Xshell:交互设计的参照
## 许可证
ShellRS 以 [GNU 通用公共许可证第 3 版(GPL-3.0)](LICENSE)发布。你可以自由使用、修改和再分发,包括用于商业用途;但分发修改后的版本时,必须同样以 GPL-3.0 公开完整的源代码。
+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Docker</title><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+3 -1
View File
@@ -15,7 +15,9 @@ ShellRS 最终版设计说明
- 画布:1024 × 1024。
- 底板:x=64、y=64、宽高=896、圆角半径=176。
- 底板色:#082F63;主体与字母色:#FFF9EB;光标色:#00D9C5。
- 内容顶部 y=228、底部 y=796;底板内上下留白各 164。
- 内容在原居中版本基础上整体等比缩小至 90%,保持字形比例和底部对齐。
- 内容顶部 y=256.4、底部 y=767.6;底板内上下留白各 192.4。
- 内容左边界约 x=168.4、右边界约 x=855.6;底板内左右留白各约 104.4。
- SVG 字母已转为路径,无外部字体依赖。
- PNG:1024 × 1024,保留透明通道。
- ICNS:多尺寸,包含 1024 × 1024。
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 43 KiB

+4 -2
View File
@@ -1,10 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-labelledby="title desc">
<title id="title">ShellRS — centered lettering preview</title>
<desc id="desc">Original approved RS contours traced to vectors. Foreground group centered within tile; common bottom at y=796.</desc>
<title id="title">ShellRS</title>
<desc id="desc">Approved centered artwork uniformly scaled to 90 percent, preserving glyphs and alignment; side padding approximately 104.4 and vertical padding 192.4.</desc>
<rect x="64" y="64" width="896" height="896" rx="176" fill="#082F63"/>
<g id="padding-adjustment" transform="translate(512 512) scale(0.9) translate(-512 -512)">
<g id="centered-mark" transform="translate(2.235799 -48)">
<path id="terminal" d="M192 340 L468 560 L192 780" fill="none" stroke="#FFF9EB" stroke-width="128" stroke-linecap="round" stroke-linejoin="round"/>
<rect id="cursor" x="556" y="624" width="54" height="220" rx="27" fill="#00D9C5"/>
<path id="letters" fill="#FFF9EB" fill-rule="evenodd" d="M840.5570 704.0286 L832.0306 704.7053 L823.5042 706.3731 L820.0937 707.3839 L815.8305 709.0366 L810.7146 711.5192 L806.4514 714.0701 L802.1883 717.5490 L799.4819 720.2757 L797.4334 722.8336 L795.7694 725.3915 L793.6099 729.6547 L792.2704 733.0653 L791.0868 738.1811 L790.5990 742.4443 L790.5413 747.5601 L790.7635 750.1180 L791.7002 755.2339 L792.4558 757.7918 L794.3592 762.0550 L796.9964 766.3182 L799.6303 769.4577 L802.1883 771.8218 L805.5988 774.5461 L808.5691 776.5498 L815.8305 780.2746 L822.6516 783.0124 L843.9675 790.3191 L849.0833 792.5025 L852.4939 794.4116 L854.1992 795.6653 L856.1929 797.8658 L856.9959 799.5710 L857.3406 802.9816 L857.0343 805.5395 L856.3500 807.2448 L855.2772 808.9501 L854.1992 810.1125 L852.4939 811.5435 L849.0833 813.2610 L845.6728 814.2227 L839.7043 814.9011 L836.2938 814.8548 L832.0306 814.4166 L826.9148 813.3169 L822.6516 811.7738 L818.3884 809.8504 L814.1385 807.2448 L808.1567 802.7252 L805.5988 801.8951 L803.8935 801.9264 L802.1883 802.3942 L800.4830 803.3971 L799.1660 804.6869 L788.3227 819.1817 L787.7227 820.8870 L787.6062 822.5923 L788.2000 825.1502 L789.3987 826.8035 L796.2198 832.2166 L800.4830 834.7954 L807.3041 838.1734 L814.1252 840.6524 L820.9463 842.3956 L826.0621 843.2675 L831.1780 843.6589 L835.4411 844.0000 L839.7043 843.9669 L848.2307 843.3549 L853.3465 842.5103 L860.1676 840.7675 L865.2835 838.9890 L872.1046 835.5885 L874.6625 833.8692 L879.7783 829.6732 L882.3866 826.8555 L885.0715 823.4449 L887.5226 819.1817 L889.3776 814.9185 L890.4223 811.5080 L891.3656 806.3921 L891.5284 799.5710 L891.2722 794.4552 L890.3791 790.1920 L889.5345 787.6341 L887.6063 783.3709 L886.0805 780.8130 L883.4668 777.4025 L879.7783 773.7502 L875.5151 770.3861 L867.8414 766.1816 L856.7571 761.8914 L839.7043 756.5976 L832.8832 753.9576 L831.1780 753.0884 L828.6200 751.4797 L826.3680 749.2654 L825.7118 748.4128 L824.9427 746.7075 L824.7073 745.0022 L824.7202 742.4443 L825.1086 740.7390 L825.8338 739.0337 L828.0465 736.4758 L829.4727 735.3398 L832.8832 733.7190 L836.2938 732.8784 L839.7043 732.5214 L843.1149 732.4138 L847.3781 732.6438 L849.9360 732.9085 L854.1992 733.7941 L859.3150 735.4061 L863.3650 737.3285 L870.3993 741.2306 L872.9572 741.5687 L875.5151 740.9282 L876.3678 740.4199 L877.7431 739.0337 L887.7516 724.5389 L888.5664 722.8336 L888.8099 721.1283 L888.7196 719.4231 L887.9358 717.7178 L886.5994 716.2542 L884.8941 714.9159 L878.9257 711.5689 L872.9572 709.0028 L865.2835 706.5861 L861.0203 705.5923 L855.0518 704.6492 L846.5254 704.0000 Z M671.7346 707.3358 L670.2331 708.3388 L669.1767 709.4809 L668.1227 711.7493 L668.0006 712.6020 L668.0000 833.6766 L668.2078 835.3818 L668.9052 837.0871 L670.8820 838.9687 L673.4399 839.9149 L694.7559 839.9270 L696.1266 839.6450 L698.1664 838.4701 L699.4174 837.0871 L700.2151 835.3818 L700.3392 834.5292 L700.4254 797.0131 L700.7243 796.6365 L701.5770 796.5234 L713.5139 796.4421 L714.3665 796.6669 L714.6869 797.0131 L739.8139 836.2345 L740.7983 837.4204 L742.6358 838.7924 L745.0615 839.8042 L756.9984 839.9557 L772.3459 839.9086 L774.0512 839.0725 L775.3151 837.9398 L776.3634 836.2345 L776.8081 834.5292 L776.6441 832.8239 L775.8796 831.1187 L749.4882 791.8973 L749.1454 791.0447 L754.3676 788.4868 L758.6726 785.9288 L762.9669 782.6042 L764.8512 780.8130 L768.4362 776.5498 L770.1243 773.9919 L772.4055 769.7287 L774.0738 765.4655 L775.1644 761.2023 L776.0290 755.2339 L776.0817 749.2654 L775.8429 745.0022 L775.0365 739.8864 L774.0512 735.9451 L772.3748 731.3600 L770.0694 727.0968 L767.6365 723.6863 L764.6722 720.4655 L760.4090 716.6653 L757.8511 714.8908 L753.5879 712.4428 L749.3247 710.6256 L740.7983 708.1383 L734.8299 707.2194 L728.0087 706.9237 L674.2926 706.9272 Z M701.5770 734.7052 L700.7243 734.8952 L700.3621 735.6232 L700.3383 768.0234 L700.7243 769.1438 L702.4296 769.3109 L726.3035 769.2572 L730.5667 768.4278 L733.1246 767.5406 L735.6825 766.1302 L737.5993 764.6129 L739.1455 762.9076 L740.2873 761.2023 L741.8388 757.7918 L742.6996 752.6760 L742.7503 750.9707 L742.0800 745.8548 L741.1045 743.2969 L739.4596 740.7390 L737.3878 738.7072 L735.4242 737.3285 L733.1246 736.2431 L730.5667 735.4203 L727.1561 734.8472 Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

+3
View File
@@ -4,6 +4,9 @@
fn main() {
println!("cargo:rerun-if-changed=build.rs");
// The text `rust_i18n::i18n!` reads while compiling, which the macro
// does not tell Cargo about.
println!("cargo:rerun-if-changed=locales");
println!("cargo:rerun-if-changed=assets/logo/shellrs.ico");
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
+44
View File
@@ -0,0 +1,44 @@
# 开发与测试
[返回 README](../README.zh-CN.md)
## 构建
装好 [rustup](https://rustup.rs) 即可:`rust-toolchain.toml` 固定了 Rust 1.98.1,第一次构建时 rustup 会自动装上它,不改动机器上默认的工具链。首次构建要编译整个 GPUI 栈,需要一段时间。
Linux 需要先装这些开发包(Debian / Ubuntu,与发布构建用的一致):
```sh
sudo apt-get install -y cmake clang pkg-config libfontconfig-dev \
libfreetype-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libxcb1-dev libvulkan-dev libdbus-1-dev libssl-dev
```
Windows 只在 CI(`.github/workflows/windows.yml`)上编译和测试。
## 运行和验证
工具链固定 Rust 1.98.1,`russh-sftp` 固定 3.0.0;保持仓库锁定的 GPUI 与 russh 版本。
```sh
cargo run
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check
```
数据目录默认是系统应用数据目录中的 `shellrs`,可通过 `SHELLRS_DATA_DIR` 覆盖。主机、端口转发规则和凭据(不含密码)保存在 `shellrs.db`,主机信任记录保存在该目录的 `known_hosts`,凭据里粘贴或生成的私钥保存在该目录的 `keys/` 下,密码和私钥口令使用系统钥匙串(服务名 `shellrs`;账户名是主机端点的 `password:<用户>@<地址>:<端口>`、私钥的 `passphrase:<路径>`、密码凭据的 `credential:<随机 ID>` 和代理的 `proxy:<用户>@<代理地址>:<端口>`)。
`src/sftp/tests.rs` 使用临时目录与故障注入覆盖上传和下载的文件内容、空目录、链接、覆盖、权限、取消、源文件变化、乱序与短读、续传及替换各阶段恢复,以及删除、重命名、新建和递归改权限不跟随链接。Unix 协议测试使用本机 OpenSSH `sftp-server`(macOS `/usr/libexec/sftp-server`,Linux `/usr/lib/openssh/sftp-server`);工作线程测试仅监听 `127.0.0.1` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。
`src/forward/protocol_tests.rs` 用进程内的 SSH 服务器和本机的回显服务测试真实的转发工作线程:三种转发的往返、目标不可达、服务器禁止转发、端口被占用、空闲断线后重连、重试耗尽和停止后的清理,同样只监听 `127.0.0.1` 的随机端口。
`tests/ui/` 里的 UI 测试驱动真实 `Workspace`,通过 `Workspace::new_with_services` 注入 SFTP、本地目录、终端和端口转发服务,不连接用户服务器或真实钥匙串;在线升级的测试给 `workspace.updater()` 注入假的更新服务器和安装器,不联网、不改任何安装。Finder 的系统原生拖放仍需在真实 macOS 窗口中补充人工验收;无头测试覆盖原生文件拖入事件和面板内部拖放。
开发构建(`cargo run`)不检查更新。要在本机走一遍更新流程,用 debug 构建加环境变量 `SHELLRS_UPDATE_CHANNEL=beta` 编译,运行时用 `SHELLRS_UPDATE_URL=http://127.0.0.1:8000/{channel}.json` 指向本地的静态服务器(`python3 -m http.server`),清单用 `packaging/make-manifest.sh` 和自己生成的测试密钥签名,测试公钥放在环境变量 `SHELLRS_UPDATE_PUBLIC_KEY` 里。release 构建不认这两个环境变量。
上传交互参考 [WinSCP 上传流程](https://winscp.net/eng/docs/task_upload),协议适配参考 [russh-sftp 请求接口](https://docs.rs/russh-sftp/3.0.0/russh_sftp/client/struct.RawSftpSession.html) 与 [OpenSSH 扩展规范](https://raw.githubusercontent.com/openssh/openssh-portable/master/PROTOCOL)。
## README 截图
`docs/screenshots/` 里的截图来自真实窗口(macOS 的 `screencapture -o -l <窗口号>`,2 倍分辨率)。截图时不要用自己的数据:把数据目录复制一份,改掉分组、主机的名称和地址,用 `SHELLRS_DATA_DIR` 指向这份副本启动 ShellRS;钥匙串里的密码按「用户@地址:端口」保存,所以副本里没改地址的主机照样能真实连接。截好后检查状态栏、对话框和终端输出里有没有真实的 IP、主机名或业务信息,有就打码。
+449
View File
@@ -0,0 +1,449 @@
# ShellRS user manual
[Back to README](../README.md)
An SSH host manager built on GPUI Kit, with saved hosts and groups, login credentials that several hosts can share, local terminals, multiple SSH terminals, a WinSCP-style dual-pane SFTP browser and SSH port forwarding.
The three icons after “ShellRS” in the title bar switch what the left sidebar shows: the host list, the port forwarding list or the credential list. When the sidebar is hidden, clicking one of them shows it again.
Only one ShellRS runs at a time: opening it again while it’s running brings the existing window to the front, and the newly launched copy quits. Two copies would each load the hosts into memory and write them back to the same database, overwriting each other’s changes. (Copies pointed at different data folders with `SHELLRS_DATA_DIR` count as different and can run side by side.) To see two terminals or SFTP tabs side by side, drag a tab into a split.
On macOS, the close button at the top left of the window only hides ShellRS: open connections, SFTP transfers, port forwards and the external CLI keep running. Click the Dock icon to bring the window back with all its tabs. To actually quit, press ⌘Q or right-click the Dock icon and choose Quit. On Windows and Linux, closing the window quits.
## Hosts
Each item saved in the left sidebar is a host: an address, a port and a way to authenticate. To log in to the same machine as different users, save it as two hosts.
The host dialog’s “Authentication” has three options:
- **Password**: enter a username and password; the password is kept in the system keychain. Leave the password empty to be asked at each connection. Only the password is used: the SSH agent and private keys aren’t tried first. When the server takes passwords only through keyboard-interactive authentication (ESXi, for one), the saved password answers its single hidden question; other questions, such as a verification code, are still asked.
- **Credential**: choose a saved credential. The username and the way to log in both come from the credential (see “Credentials” below), so the dialog no longer asks for a username or password. “Test connection” also uses the password saved in the credential. To log in with a private key, create a key credential.
- **No password**: enter only the username. ShellRS tries logging in without authentication, then the SSH agent, then the default private keys in `~/.ssh` (`id_ed25519`, `id_ecdsa`, `id_rsa`), the same as the `ssh` command does by default. If the server asks for a password, the connection fails with the reason instead of asking; if the server asks for a verification code after the key, you’re still asked for it.
Whichever you choose, ShellRS first asks the server whether it allows logging in without authentication, and logs straight in if it does. If “Test connection” runs with “Password” chosen but no password entered, it tries what “No password” would (no authentication, the SSH agent, the default private keys) and explains why if it can’t connect.
“Connection”, below “Group” in the host dialog, decides how to reach the host. Terminals, SFTP, port forwarding, the external CLI and “Test connection” all follow it:
- **Direct**: the default. This computer connects straight to the host’s address and port.
- **Jump hosts**: connects through other saved hosts, with as many hops as needed. A row of labels in the box shows the whole route (“This computer → aliyun-99 → zentao → This host”), and below it each jump host has a row: “×” removes it, and “Add jump host” searches by name or address. To change the order, remove jump hosts and add them again. ShellRS logs in to the first jump host, which connects to the second, and so on, until the last one connects to the host. Each jump host uses its own address, port, authentication (password, credential or no password) and known hosts entries; on the first connection you’re asked about each one’s host key, and the prompt says which jump host it is. A jump host’s own “Connection” has no effect here: for several hops, list them all here in order. Enter the host’s address as the jump hosts’ network sees it, such as an internal address.
- **Proxy**: connects through an HTTP proxy (`CONNECT`) or a SOCKS5 proxy, chosen in the drop-down. Enter the proxy’s address and port (such as `127.0.0.1:7890` on this computer); if the proxy needs authentication, also enter a username and password. The password is kept in the system keychain and shared by hosts that go through the same proxy with the same username. The proxy resolves the host’s address. If the proxy requires authentication but has no password, or rejects the username and password, the connection fails with the reason instead of asking.
When you change a host’s address or authentication, hosts that jump through it reconnect too (changing only the name doesn’t). When you delete a host that other hosts use as a jump host, the confirmation says how many hosts use it. Afterwards their jump host lists keep a “Deleted host” row, and connecting fails right away instead of quietly connecting directly or skipping a hop; edit those hosts to remove it or choose another. Open connections aren’t affected.
**Not implemented yet: combining jump hosts with a proxy, reordering jump hosts by dragging, HTTPS proxies, and showing the connection type in the host list.**
The last field in the dialog is “Notes”, which can span several lines (Enter starts a new line): what the machine is for, who looks after it, when it expires and so on. Notes are kept only in the local database and don’t affect connecting.
Hover over a host and `user@address:port` appears at the right of its row, with its notes below if it has any (only the first 6 lines of long ones). A host’s context menu has “Connect”, “Open SFTP”, “Edit host…”, “Duplicate”, “Copy IP address” (“Copy hostname” when the address is a domain name), “Copy ID” and “Delete”. “Copy ID” copies the host’s 16-character random identifier (like `Jwg5rHvXCxw89paM`), which other tools use to identify the host: it’s generated when the host is created and stays the same when you rename the host or change its address; a duplicated host gets a new ID, and deleted IDs are never handed out again. When no tabs are open, the center area shows the “Recent” list; double-click a host or press Enter to connect. Right-clicking a host there opens the same menu and selects the host.
“Find a host”, to the left of “New host…” above that list, opens a search box listing every saved host: recently connected ones first, the rest by name, each row showing its group and `user@address:port`. Type part of a name, address, username, notes or group to filter; with several words separated by spaces, all of them must match. Nothing is highlighted when it opens; once you type, the first match is highlighted and Enter connects to it. You can also move with ↑↓, or click a host to connect to it.
The search box above the host list (⌘K, or Ctrl+K on Windows and Linux) filters hosts by name, address and username, and finds groups by name: when a group’s name matches, the group is listed with all its subgroups and hosts. Results are expanded, and matching hosts appear together with every level of group above them. Enter connects to the first matching host (in list order; hosts listed only because their group matched don’t count). Esc clears the search.
Drag a group or host above or below another item of the same kind to reorder items at the same level; drag a host onto a group to move it into the group, and drag a group onto the middle of another group to make it a subgroup. Drag to an empty part of the list to move an item back to the top level. Groups always come before hosts at the same level, and the order and which groups are expanded are saved in the local database. Reordering by dragging is paused while you search.
## Quick connect
“Quick connect…” at the top right of the title bar connects to a host without saving it, for machines you log in to only once. As for a new host, the dialog asks for the address, port and authentication (Password, Credential or No password); there are no Group, Connection or Notes fields, and the name is optional (the address is used instead). You can “Test connection” first. “Connect” opens a terminal tab:
- **Fully featured**: “Connect in new tab”, “Open SFTP”, “Reconnect” and all the right sidebar tools work. The connection is direct.
- **Not saved**: it stays out of the host list, the start page’s Recent list and the database, and is gone once its terminal and SFTP tabs are all closed. Its tab menu has no “Edit host…”.
- **Password in memory only**: the password you enter doesn’t go into the keychain and is reused when reconnecting; if you left it empty and the server wants a password, you’re asked. With “Credential”, the password or private key saved in the credential is used.
- The external CLI’s `shellrs hosts list` lists it too (`"temporary": true` in the JSON, “(not saved)” in the table’s group column), and you can use its ID while its tabs are open.
Hosts you want to keep are created with “+” at the top of the host list, the context menu, or ⌘N (Ctrl+N on Windows and Linux).
## Port forwarding
Click the port forwarding icon in the title bar and the left sidebar switches to the list of forwards. Each forwarding rule goes through a saved host and opens an SSH connection of its own when it starts, independent of that host’s terminal or SFTP tabs: closing the tabs or choosing “Disconnect” on the host doesn’t affect it, and the host’s connection state doesn’t count it. While forwards are running, the number running appears next to the port forwarding icon in the title bar.
There are three kinds of forward. Think of each as “entrance → exit → destination”: a connection comes in at the entrance, goes through the SSH tunnel to the exit, and the exit connects to the destination:
| Type | Entrance (listens) | Exit | Use |
| --- | --- | --- | --- |
| Local (`ssh -L`) | This computer | Server | Reach a service on the server’s side from this computer, such as connecting a local client to a database that only the server can reach |
| Remote (`ssh -R`) | Server | This computer | Let the server’s side reach a service on this computer, such as showing someone a site you’re developing locally, or receiving webhooks |
| Dynamic (`ssh -D`) | This computer (SOCKS proxy) | Server | Use the server as a proxy, with the destination chosen by the app, such as browsing the web through the server |
- **Creating and editing**: “+” at the top of the list opens the dialog. A row of three cards at the top chooses the type; the diagram in the middle fills in the addresses as you type and marks which machine the entrance is on; and a sentence below explains what the rule does (such as “Connecting to 127.0.0.1:8080 on this computer is the same as connecting to db.internal:3306 from the SSH server.”). The “SSH server” in the diagram and the sentence is the host chosen in “Via host”; the diagram calls it by its role, not by the host’s name. A dot moves slowly along the diagram in the direction of the connection: through the SSH tunnel first, then on to the destination, pausing and starting over; switching type sends it off again from the new entrance. The name is optional; without one, the list shows a summary of the forward (`8080 → db.internal:3306`, `9000 ← localhost:3000`, `SOCKS 1080`).
- **Starting and stopping**: the button at the end of each row, double-click the row, or select it and press Enter; the context menu also has “Edit port forward…” and “Delete”. “Create” only saves the rule and doesn’t start it. Rules with “Start when ShellRS opens” checked start along with the app.
- **Status**: the end of the row shows “Connecting”, “Running” (with the current number of connections), “Connection lost, reconnecting”, or “Stopped:” with the reason. When a single connection can’t be forwarded (for example, the server can’t reach the destination), only that row says so, and the forward keeps running.
- **Login**: uses the host’s settings, the password saved in the keychain and ShellRS’s own known hosts file. A host you’re connecting to for the first time, or a password that has to be entered, brings up the same prompt as in a terminal, saying which forward is connecting; cancelling the prompt stops the forward.
- **Reconnecting after a drop**: when the connection drops, ShellRS reconnects automatically up to three times, after 1, 3 and 10 seconds. While it reconnects, the local listening port stays open, and new incoming connections are closed straight away. Automatic reconnects never prompt: if a password needs entering again or a host key needs confirming, the forward stops with the reason, and you can start it by hand. It also stops after three failed attempts, with a notification.
- **After changes**: editing a running rule’s type, host, listening address or destination, or the address, authentication or connection of the host it goes through (including the username, type or private key file of the credential that host uses, and its jump hosts), restarts the forward with the new settings; changing only the name or auto start doesn’t.
- **Deleting a host**: deleting a host (or a group that contains it) also deletes the forwarding rules that go through it, stopping running ones first; the confirmation says how many.
- **Dynamic forwarding** supports SOCKS5 (no authentication, `CONNECT`, IPv4, domain names, IPv6) and SOCKS4 / 4a. Domain names are resolved by the server.
- **The listening address** defaults to `127.0.0.1`. With another address, local and dynamic forwards can be used by other devices on the same network; for a remote forward, other machines can connect only if the server’s `sshd_config` sets `GatewayPorts clientspecified` or `yes`. The default is `no`, in which case the forward still runs without an error but listens only on the server’s own `127.0.0.1` (the same as a hand-written `ssh -R`). The dialog points this out when you enter a non-loopback address. Ports below 1024 need administrator rights (root, on the server). Port 0 (letting the system pick a port) isn’t supported.
**Not implemented yet: managing forwards through the external CLI, and ordering rules. The system’s “Reduce motion” setting doesn’t turn off the diagram’s animation yet.**
## Credentials
Click the credentials (key) icon in the title bar and the left sidebar switches to the list of credentials. A credential is a set of login details that several hosts can share: a name, a username and a way to log in:
| Type | What it keeps | When logging in |
| --- | --- | --- |
| Password | Username and password | Uses the password saved in the credential; asks if none is saved or it’s rejected |
| Key | Username, private key and passphrase | Uses only this private key; the passphrase is saved per key file and shared by credentials that use the same file |
| SSH Agent | Username | Uses only the keys in the SSH agent: on macOS / Linux the agent that `SSH_AUTH_SOCK` points to, on Windows the OpenSSH Authentication Agent service |
- **Creating and editing**: “+” at the top of the list, “New credential…” in the empty list, or the context menu on empty space opens the dialog; to edit a credential, double-click it, select it and press Enter, or right-click it and choose “Edit credential…”. Passwords and private key passphrases go into the system keychain; the database holds only the name, username, type and private key path.
- **Where the key comes from**: a key credential’s “Private key” has three sources.
- **Local file**: choose a private key file; only its path is saved. If the file moves or is deleted, choose it again.
- **Paste**: paste a private key in OpenSSH, PEM or PuTTY format. If you paste a public key or something unrecognizable, ShellRS says why. The passphrase field appears only when the private key is encrypted with one.
- **Generate**: choose Ed25519 (the default) or RSA 4096, and the key is generated as soon as you choose. The dialog shows the public key, with a button to copy it. The passphrase can be left empty; if you enter one, it encrypts the private key and goes into the keychain. A generated key has the credential’s name as its comment.
- ShellRS writes pasted and generated private keys to files in `keys/` in the data folder (with random file names, readable only by your account), and the credential refers to that file; when you edit the credential, it shows as “Local file” with that path. Pasting or generating again overwrites the file directly, and connected hosts don’t reconnect because of it; switching to another file or another type, or deleting the credential, deletes the file together with its passphrase, and the dialog warns you first. ShellRS never changes or deletes your own private key files.
- The empty list and the context menu on empty space also have “Generate key…”, which opens the same dialog with “Key · Generate” already chosen. A key credential’s context menu has “Copy public key”, for adding the public key to `~/.ssh/authorized_keys` on a server.
- **Hosts follow the credential**: changing a credential’s username changes it for every host that uses the credential. Changing the username, type or private key file reconnects the connected hosts that use it (changing only the name doesn’t); the dialog says how many hosts use it.
- **Deleting**: right-click and choose “Delete”. If hosts use the credential, the confirmation says how many and how they’ll log in afterwards: hosts using a password credential switch to “Password” and ask when connecting; hosts using a key or SSH Agent credential switch to “No password”. Usernames stay the same; passwords and private keys aren’t copied over. Open connections aren’t affected, and the credential’s password is deleted from the keychain along with it.
- **An SSH Agent credential** says why right away when it can’t reach the agent, the agent has no keys, or the server accepts none of them; it never falls back to asking for a password. Hosts set to “No password” try the same agent too, skip it silently if it can’t be reached, and go on to the default private keys (on Windows as well).
**Not implemented yet: creating a credential right from the host dialog, ordering credentials, a custom SSH agent socket, installing the public key on a host automatically, and exporting the private keys ShellRS keeps.**
## Settings
The “Settings” button at the bottom of the sidebar, or ⌘, (Ctrl+, on Windows and Linux), opens a “Settings” tab in the center area; if it’s already open, it switches to that tab instead of opening another. The settings page has two columns: on the left the categories (Appearance, Terminal, Highlighting, Keyboard shortcuts, External CLI, General, About), with a search box at the top and a width you can drag; on the right the settings of the chosen category. Changes take effect at once and are saved in `settings.json` in the data folder.
- **Appearance → Interface → Language**: System, 简体中文 or English. The default is System: Simplified Chinese when the system language is Chinese, English for any other language. Changes apply at once, without a restart: the text in the window, open tabs and search box hints all switch to the new language; prompts already written into terminals and notifications already shown stay as they were. The external CLI’s output uses this language too.
- **Appearance → Interface → Appearance**: Dark, Light or System, System by default; System switches along with the system’s light and dark mode. The theme button in the title bar also changes this setting.
- **Appearance → Theme**: the left column has light themes and the right column dark themes; choose one in each. The light appearance uses the one chosen on the left, the dark appearance the one on the right; with System they switch along with the system, and the same happens when you switch with the title bar’s theme button. By default only terminals take the chosen theme’s colors, and the interface stays a neutral gray (the dark one is an easy-on-the-eyes charcoal, not pure black). Turn on “Match interface to theme” at the top and the interface takes the theme’s colors too: the sidebar, tab bar, dialogs and so on use the theme’s background and text colors, and states such as success, warning and error use the theme’s green, yellow and red. Each card shows a sample in that theme’s colors, and the chosen one has a blue check mark at its top right. The defaults are ShellRS Light and ShellRS Dark, which match the interface. There are 10 built-in light themes and 10 dark ones: light ShellRS Light, Solarized Light, Tokyo Day, Catppuccin Latte, Flexoki Light, One Half Light, Gruvbox Light, Kanagawa Lotus, Rosé Pine Dawn, Everforest Light; dark ShellRS Dark, Solarized Dark, Tokyo Night, Catppuccin Mocha, Flexoki Dark, One Half Dark, Gruvbox Dark, Kanagawa Wave, Dracula, Nord. The light and dark versions of the same theme sit on the same row of the two columns. Changes apply at once to every open terminal, and the font and highlighting previews use the current theme too; when vim or another program asks the terminal for its background color, it gets the current theme’s. **Not implemented yet: custom themes, importing color schemes, and per-host themes.**
- **Terminal → Font**: Font family (lists only the monospaced fonts installed on this computer; the default is the system’s monospaced font, Menlo on macOS), Font size (10–28 pixels, default 13) and Line height (1.0–2.0 times the font size, default 1.54, the same as the earlier 20-pixel line height). Changes apply at once to every open terminal; in a terminal, ⌘= / ⌘- (Ctrl on Windows and Linux) also change the font size step by step, and ⌘0 goes back to the default. The preview below lays out letters, digits, easily confused characters, symbols, Chinese, tab stops and a command line the way the terminal does. If the font in the settings file isn’t installed on this computer, the default font is used.
- **Terminal → Interaction**: with “Copy on select” on, text you select with the mouse is copied to the clipboard when you let go, without pressing ⌘C; it’s off by default, and a click that selects nothing leaves the clipboard as it was. “Right-click” sets what right-clicking in a terminal does: Show menu (the default), Paste (pastes the clipboard, like ⌘V) or Do nothing. With either of the last two, the menu’s Copy, Paste, Find and Clear screen are still on their shortcuts, and Open SFTP and Reconnect are in the tab’s context menu. Changes apply at once to every open terminal.
- **Highlighting**: the main switch, the preview and the rules table, which color text in terminals by rule and alert you on matches; see “Terminal”.
- **Keyboard shortcuts**: the shortcuts of the main features, in four groups (General, Tabs, Terminal, Editor); each row is a feature and its keys. Click the keys to get “Press a key combination”, then press a new combination to use it; Esc or clicking elsewhere cancels. The two buttons at the end of the row are Disable (the row then shows “None”; click again to restore the default) and Restore default; the “Reset All” button at the top of the page restores them all. Changes take effect at once, and the keys shown in tooltips and context menus follow.
- A combination needs ⌘, ⌥ or ⌃ (Ctrl or Alt on Windows and Linux), or must be one of F1–F12, or Shift with Insert, Delete, Home, End, Page Up or Page Down (the six keys above the arrows, which do not type); for “Switch to tab 1…9”, press any digit with a modifier, and all nine change together.
- A combination already taken by another shortcut in the same place, or by text fields and other built-in features, is refused, and you’re told what uses it; to use it anyway, change or disable that one first. Terminal and editor shortcuts can share keys with general shortcuts, and in the terminal or editor they take precedence (in a terminal, for example, ⌘K clears the screen).
- Defaults (macOS / Windows and Linux): New host ⌘N / Ctrl+N, New group ⌘⇧N / Ctrl+Shift+N, Open settings ⌘, / Ctrl+,, Show or hide sidebar ⌘B / Ctrl+B, Show or hide right sidebar ⌘⌥B / Ctrl+Alt+B, Focus search ⌘K / Ctrl+K, Zoom in, Zoom out and Actual size ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0, Quit ⌘Q / Ctrl+Q; New local terminal ⌘T / Ctrl+T, Close tab ⌘W / Ctrl+W, Next tab ⌘⇧] / Ctrl+Tab, Previous tab ⌘⇧[ / Ctrl+Shift+Tab, Switch to tab 1…9 ⌘1…⌘9 / Alt+1…9 (9 is the last tab); in the terminal, Copy, Paste, Find, Find next, Find previous and Clear screen ⌘C ⌘V ⌘F ⌘G ⌘⇧G ⌘K / Ctrl+Shift+C, Ctrl+Shift+V, Ctrl+Shift+F, F3, Shift+F3, Ctrl+Shift+K, and Bigger text, Smaller text and Default text size ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0; in the editor, Save ⌘S / Ctrl+S. Switching tabs stays within the group of tabs the current tab is in, wrapping around at either end. In a terminal, ⌘= / ⌘- / ⌘0 change the terminal font size (1 pixel at a time, within the same 10–28 range as “Terminal › Font size”; saved in the settings and kept for the next launch); outside a terminal they zoom the whole interface (five steps, back to the default after a restart).
- Shortcuts in the SFTP file list and the image preview, and fixed keys such as Enter, the arrow keys, Tab and Esc, aren’t changed here.
- **External CLI**: see the next section.
- **General → Window**: “Remember window size” restores the last window size and whether it was maximized, and “Remember window position” puts the window back on the screen and in the place it last was. Both are on by default and take effect at the next launch. If that screen is gone, or the window would be mostly off screen, it opens in the middle of the screen; a window larger than the screen shrinks to fit. Full screen isn’t remembered: the window comes back at its size from before full screen. The window’s size and position are recorded in `window.json` in the data folder as it moves.
- **About**: the “Updates” group, with the current version and checking for updates, the update channel and automatic updates (see “Download and updates”); “Send anonymous usage statistics” in the “Privacy” group, on by default (see “Privacy”); and “Website” in the “ShellRS” group, which opens shellrs.com in your browser.
## External CLI
The `shellrs` command lets AI agents such as Codex, Claude Code and OpenCode run remote commands and transfer and sync files on the hosts saved in ShellRS, and also view, create, change and delete hosts and credentials. The command itself reads no passwords or private keys, and no database: it hands the request to the running ShellRS, which connects with the saved settings and the keychain. Requests travel over local interprocess communication: the socket `cli.sock` in the data folder on macOS and Linux, and the named pipe `\\.\pipe\shellrs-cli-<hash of the data folder>` on Windows.
In Settings → External CLI:
- **Enable external CLI**: off by default. While it’s off, ShellRS still listens but refuses every request, and the command tells you to turn it on here. Once it’s on, any program running as the current user on this computer can use the saved hosts this way, and can also change hosts and credentials (there’s no separate switch). Only the current user can read and write the socket, and ShellRS also checks the identity of the user connecting. On Windows, the pipe likewise admits only the current user and only local connections, and before sending a request the command checks that the pipe belongs to the current user (or an administrator), so nobody else can grab a pipe with the same name.
- **Command**: puts `shellrs` on the PATH.
- macOS: links it to `/usr/local/bin/shellrs`; if that folder isn’t writable, the system asks for authorization.
- Linux: links it to `~/.local/bin/shellrs`.
- On macOS and Linux, “Remove” deletes only a link that points to shellrs; other files with the same name are left alone.
- Windows: a GUI app has no console, so the command is a separate console program, `shellrs-cli.exe` (next to `shellrs.exe`). “Install” copies it to `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe` and adds that folder to the current user’s PATH (no administrator needed); terminals and agents find it once they’re reopened. After ShellRS updates, it refreshes this copy when it starts; when it can’t, “Update” appears. The copy can be updated even while it’s in use. “Remove” deletes the copy and takes the folder off the PATH.
- **Agent Skills**: writes the skill that teaches agents to use `shellrs` to the standard folder (`~/.agents/skills`) or to the skills folder of Codex, Claude Code, OpenCode or WorkBuddy; when the content differs from the current version, “Update” appears. “Copy skills” copies the whole skill to the clipboard.
Commands (`shellrs --help` has the full details):
```sh
shellrs hosts list [-q <keyword>] [--json] # list hosts; JSON when the output isn't a terminal
shellrs hosts show <ID> [--json] # one host's full settings
shellrs exec <ID> "<command>" # or --stdin to read the command from stdin
shellrs exec --json # read {"host", "command"} from stdin, print JSON
shellrs upload <ID> <local-path> <remote-path>
shellrs download <ID> <remote-path> <local-path>
shellrs sync <ID> <local-folder> <remote-folder> [--delete]
shellrs hosts create | update <ID> | delete <ID> [--force]
shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
```
- `<ID>` is the 16-character host ID: the `id` in `shellrs hosts list`, which is also what “Copy ID” on a host’s context menu copies. The list also includes quick connections and external connections whose tabs are open (`"temporary": true`); see “Quick connect” and “Opening from a bastion host (external connections)”.
- `exec` opens a new connection each time and disconnects when it’s done. The remote command gets no stdin, and the exit code is the remote command’s; if a signal killed it, the exit code is 128 plus the signal number.
- Uploads and downloads use the SFTP tab’s transfer engine (recursive, `.filepart`, reconnecting after a drop), and destination paths follow scp’s rules: if the destination is an existing folder, the item goes into it under its own name; otherwise the destination is the path of the copy itself, and its parent folder must exist. Existing files are overwritten; new files keep the source’s executable permission. `~` means the login folder.
- `sync` goes only from this computer to the host: it syncs the contents of the local folder into the remote folder. If the remote folder doesn’t exist, it’s created (its parent must exist); if it’s a file, that’s an error; and it can’t be the root folder `/`. Files whose size and modification time haven’t changed, and links whose target hasn’t changed, are skipped and counted as “unchanged” in the summary, so syncing again sends only what changed; other files are overwritten, and new files keep their executable permission. `--delete` first deletes items in the remote folder that aren’t in the local one, or are of a different type (without following links), but never the `.filepart` and `.shellrs-….backup` files that transfers in progress leave behind, and it doesn’t clean up folders that can’t be read locally; items it can’t delete count as failures.
- `hosts` and `credentials` manage the saved hosts and credentials. `create` and `update` read a JSON object from stdin (`update` changes only the fields given); the fields are in `shellrs hosts create --help` and `shellrs credentials create --help`. What `show --json` prints can be edited and passed straight back to `update`. The checks are the same as in the dialogs, and errors use the dialogs’ wording:
- Hosts: `name`, `host` (the address), `port` (default 22), `user` (default root), `group` (a group path such as `Production/Databases`, created level by level if it doesn’t exist; an error if more than one group has that path), `auth` (`password` / `credential` / `no_password`), `credential` (a credential ID), `password`, `route` (direct, jump hosts listed by ID, or an HTTP / SOCKS5 proxy) and `notes`. Jump hosts must be saved hosts, and not the host itself. If you change the address, port or user without giving a password, the saved password moves along, as in the dialog.
- Credentials: `name`, `kind` (`password` / `key` / `agent`), `user`, `password`, `key_path` (a private key file on this computer) or `private_key` (the full text of a private key, like “Paste” in the dialog, kept in `keys/` in the data folder), and `passphrase`. A credential’s ID is a 16-character random string, shown by `credentials list`.
- Passwords, passphrases and private keys are write-only: they go into the system keychain or `keys/`, no command ever prints them, and `show` only says whether one is saved; for a private key ShellRS keeps, it doesn’t even show the path.
- Quick connections and external connections can be shown with `show`, but not changed or deleted.
- `hosts delete` doesn’t ask for confirmation and deletes the host’s port forwarding rules along with it; if the host has tabs open in ShellRS, it refuses (`host_in_use`), and only with `--force` does it close the tabs and delete the host. Hosts that jump through it are left with a “Deleted host” hop. `credentials delete` doesn’t ask either; hosts that use the credential switch to logging in on their own (as in the app).
- ShellRS makes changes one at a time: a change that hasn’t had its turn within 10 seconds is withdrawn and not made; once it has started, the command waits for it to finish.
- On failure, the command prints `shellrs: [code] message` to stderr and exits with 255. The error codes are `not_running` (ShellRS isn’t running), `not_enabled` (the external CLI isn’t enabled), `host_not_found`, `credential_not_found`, `host_key_unknown` (you haven’t connected to this host in ShellRS yet), `host_key_changed`, `missing_credential` (no saved password or passphrase), `connect_failed`, `transfer_failed`, `bad_request`, `host_in_use`, `save_failed` (the change was made but couldn’t all be written to the database or keychain) and `version_mismatch` (the command and the running ShellRS are different versions, or ShellRS is too old to know the command). The CLI never prompts: an unknown host or a missing password fails right away, so connect once in ShellRS first. When a transfer finishes but some items failed, the exit code is 1.
- `exec --json` reads `{"host": "<ID>", "command": "<command>"}` from stdin and, once the command ends, prints `{"exit_code", "stdout", "stderr"}`, or `{"error": {"code", "message"}}` on an error, with the same exit codes as without `--json`. The output is ASCII only: Chinese and other characters are escaped as `\uXXXX`, and output that isn’t UTF-8 becomes U+FFFD. The command doesn’t go through the local shell’s quoting, and the console code page can’t garble the output, which suits Windows PowerShell and agents that need to read stdout and stderr separately.
- macOS, Linux and Windows are supported. In Windows PowerShell, prefer `exec --json` for running commands (`@{ host = '<ID>'; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`). To watch the output while the command runs (long builds, lots of logs), pass the command as a here-string through `--stdin` instead (`@'…'@ | shellrs exec <ID> --stdin`), and, as for `list`, `upload` and `download`, first run `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`, or Chinese and other non-ASCII text turns into question marks or garbage. Any BOM at the start of the command text is removed and CRLF becomes LF, so the remote shell sees no stray carriage returns.
## Opening from a bastion host (external connections)
The local client of a bastion host such as JumpServer can launch ShellRS to open a terminal the way it launches Xshell, or to open SFTP the way it launches WinSCP: set ShellRS as the SSH client or the SFTP client, with the arguments written as for Xshell or WinSCP. When ShellRS gets an `ssh://` link, it opens a terminal tab and connects; when it gets an `sftp://` link, it opens an SFTP tab:
```sh
ShellRS ssh://user[:password]@address[:port]
ShellRS -url ssh://user[:password]@address[:port] -newtab tab-name
ShellRS sftp://user[:password]@address[:port]
ShellRS /sessionname=tab-name sftp://user[:password]@address[:port]
```
- ShellRS understands Xshell’s `-url` (the link), `-newtab` (the tab name; the address if it isn’t given) and `-newwin` (ignored: ShellRS has only one window), and WinSCP’s `/sessionname=tab-name` (which can also be written `-sessionname=`), in any letter case; other options such as `/newinstance` and `/ini=nul` are ignored. Only `ssh://` and `sftp://` are supported, and the port defaults to 22. Special characters in the username and password can be escaped by URL rules (`@` as `%40`); a username that itself contains `@` also works, since only what follows the last `@` is the address. The path in an `sftp://` link (which WinSCP uses to choose the folder to open) isn’t read yet; the remote side opens the home folder as usual.
- **The tab fills the window**: when an external connection opens, the host list on the left collapses automatically (as with ⌘B); press ⌘B (Ctrl+B on Windows and Linux) or double-click the tab to show it again.
- **External connection, not saved**: it stays out of the host list, the start page’s Recent list and the database, and is gone once its terminal and SFTP tabs are all closed. Its tab menu has no “Edit host…”; everything else (“Connect in new tab”, “Open SFTP”, “Reconnect”) works as for a saved host.
- **One channel only**: many bastion hosts end the whole session when a second channel opens on the same connection. An SFTP tab opens only one anyway; a terminal doesn’t detect the host’s system (its tab shows the first letter of the name), and the right sidebar has only Snippets: History, Docker, Services, Processes, Network and Monitor all run commands over another channel, so they aren’t offered here.
- The password in the link stays only in memory, never in the keychain, and is reused when reconnecting; if the link has no password and the server wants one, you’re asked. The first time you connect to a bastion host, you trust its host key, which is recorded in ShellRS’s own `known_hosts` and not asked about again.
- When ShellRS is already running, the link goes to the running ShellRS, its window comes to the front, and no second ShellRS starts. If the link has a problem (not `ssh://` or `sftp://`, no username or address, a wrong port), ShellRS shows a notification saying why.
- The program for the bastion host to launch:
- Windows: `shellrs.exe` in the installation folder (by default `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`). Don’t use the `shellrs.exe` on the PATH: that’s the external CLI’s copy.
- macOS: `/Applications/ShellRS.app/Contents/MacOS/shellrs`. Don’t use `open -a ShellRS --args …`: when ShellRS is already running, the system drops the arguments.
- Linux: the AppImage itself.
- The external CLI’s `shellrs hosts list` lists external connections too (`"temporary": true` in the JSON, “(not saved)” in the table’s group column). While the tabs are open, an agent can use the connection’s ID to run commands and transfer files, and the ID stays the same all that time. Once the tabs close, the ID stops working; the next time the bastion host launches ShellRS (even for the same asset), it’s a new external connection with a new ID. As with saved hosts, each command logs in again with the username and password from the link; if the bastion host passed a one-time token, that login is refused.
- A password in command-line arguments is visible in this computer’s process list; that comes with how the bastion host passes arguments.
## Tabs
Remote terminal tabs and SFTP tabs use the host’s system badge as their icon (the same one as in the host list). A remote terminal tab’s context menu has “Rename tab…” (for this run only; leave the name empty to go back to the host’s name; handy for telling several connections to the same host apart), “Connect in new tab”, “Open SFTP”, “Copy IP address” / “Copy hostname”, “Reconnect”, “Edit host…”, and “Close”, “Close to the left”, “Close to the right”, “Close others” and “Close all”. An SFTP tab’s menu has “Rename tab…” (leave the name empty to go back to “host name · SFTP”), “Open SFTP”, “Copy IP address” / “Copy hostname”, “Reconnect” and the close commands, and the right of its tab bar has the same “SFTP” and “Reconnect” buttons as a remote terminal’s; a local terminal tab’s menu has “Restart” and the close commands. The “…” button at the right of the tab bar has the same menu as the current tab’s context menu. Double-click any tab’s title to hide or show the left sidebar (like ⌘B and the title bar button). While a remote terminal is running, or an SFTP tab is connected, the right of the tab bar shows the round-trip latency of that SSH connection, updated every 5 seconds: green below 100 ms, yellow at 100–200 ms, red above 200 ms or on a timeout. It’s measured during SFTP transfers too, and the reading rises when a transfer takes up the bandwidth. When closing tabs in bulk, an SFTP tab that’s uploading still asks first.
## Terminal
The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected. Settings › Terminal › Interaction can make right-click paste or do nothing instead, and can turn on “Copy on select”.
- **Scrollback**: each terminal keeps the last 10,000 lines. Scroll up with the wheel or trackpad and a scrollbar appears on the right (as in the app’s other lists, following the system’s “Show scroll bars” setting); drag it, or click the track to jump there. Full-screen programs such as vim, less and top have no scrollback and show no scrollbar.
- **Find** (⌘F, or Ctrl+Shift+F on Windows and Linux): opens the find bar at the top right of the terminal and searches the screen and the scrollback for the literal text. An all-lowercase query ignores case; one with capital letters matches case. All matches are highlighted, the current one darker, and the find bar shows “current/total”. Enter or ⌘G (F3) goes to the next match (newer output), Shift+Enter or ⌘⇧G (Shift+F3) to the previous one (older output), wrapping around at the ends. Esc closes it. If text is selected when it opens, that text becomes the query.
- **Clear screen** (⌘K, or Ctrl+Shift+K on Windows and Linux; when the terminal doesn’t have focus, ⌘K still focuses the sidebar’s search box): clears the screen and the scrollback, leaving only the prompt line the cursor is on, including anything typed but not yet entered. Clearing happens only locally; nothing is sent to the server. It isn’t available while a full-screen program such as vim or top is running.
- **Links**: `http://` and `https://` addresses in the output, and links that programs mark explicitly (such as `ls --hyperlink` and gcc’s error messages), are shown in blue and underlined. Hold ⌘ (Ctrl on Windows and Linux) and click to open one in the system browser; a plain click still selects text. Rest the pointer on a link for a moment to see its real address: for a link a program marks, the text shown can differ from the address. A trailing period, comma or unmatched closing parenthesis isn’t part of the address, and a long address wrapped over two lines is still one link. Other schemes (such as `file://`) aren’t treated as links.
- **Mouse**: when programs such as vim (`:set mouse=a`), htop, tmux (`set -g mouse on`) and mc ask for the mouse, clicks, drags and the scroll wheel all go to the program. Dragging with Shift held still selects text, and scrolling with Shift held still scrolls through the scrollback; right-clicks never go to the program and do what “Right-click” in the settings says.
- **Notifications**: programs can ask for a notification (OSC 9, or OSC 777’s `notify;title;body`), and a terminal bell alerts you too. When ShellRS isn’t in front, you get a system notification (macOS asks for permission the first time), and clicking it takes you back to that terminal; when ShellRS is in front but the terminal is in another tab, a notice appears at the top right of the window, and clicking it switches there. Notifications that programs ask for appear even when the terminal is right in front of you; the bell alerts you only when you can’t see that terminal (Tab completion and the like ring it too). The notification title is the tab name, so you can tell which host it came from. Each terminal alerts at most once every 2 seconds for program notifications and once every 10 seconds for the bell. Settings › Terminal › Notifications can turn each one off. To be told when a command finishes, append `; printf '\e]9;Done\a'` or `; tput bel` to it; command-line tools such as Claude Code alert you the same way when they need you.
- **Highlighting**: the rules in Settings › Highlighting show matching text in the rule’s color in every terminal, scrollback included. Only the display changes: the server’s output and copied text stay the same; matching keeps working after the window is resized and long lines wrap, and a word wrapped over two lines is colored in both parts. The feature is off by default and works only once you turn on “Highlight keywords” at the top of the page; turning it off disables all the rules at once. “Preview” shows sample output in the current terminal’s font and colors, and rule changes show up at once in the preview and in open terminals. The rules table has one rule per row, edited right in the table: “On”, “Regular expression” (case-sensitive; start it with `(?i)` to ignore case), “Note”, a color (click the swatch for a color picker, or type `#rrggbb` next to it) and “Notify”; the trash can at the end of the row deletes the rule. A mistake in a regular expression is pointed out below the field, and the rule doesn’t apply until it’s fixed. Rules apply from top to bottom, and where they overlap, the earlier rule wins; drag the handle at the start of a row to reorder. Three sample rules come to start with: `ERROR` in red, `WARN` in amber and IPv4 addresses in blue, all on and not notifying, so turning on the main switch shows the effect right away; you can change or delete them. A rule with “Notify” checked alerts you when you can’t see the terminal, the same way as the bell: a system notification when ShellRS isn’t in front, and a notice at the top right of the window when the terminal is in another tab. The title is “tab name: note” (the regular expression when the note is empty), and the body is the matching line. Only new output is checked, each line once it ends; each terminal alerts at most once every 10 seconds, and the same line redrawn over and over at most once every 5 minutes. Nothing is highlighted or alerted in full-screen programs such as vim, less, top and tmux. **Not implemented yet: per-host rules, text styles such as bold, and reordering with the keyboard.**
- **Remote copy**: what remote programs copy with OSC 52 (such as tmux with `set -g set-clipboard on`, or vim’s OSC 52 plugins) goes straight to this computer’s clipboard. Remote programs can’t read this computer’s clipboard.
- **Paste image**: in a local terminal or a remote terminal on a saved host, when the clipboard holds a screenshot (and no text), ⌘V (Ctrl+Shift+V on Windows and Linux) or Paste on the context menu writes the image to a file and pastes that path, for an agent in the terminal to read. Locally it goes in the system temp folder; on a saved host it is written over SFTP to `/tmp` on the host. Text on the clipboard is still pasted as text. External connections and quick connections don’t do this.
## Right sidebar
When the current tab is a remote terminal, a column of tool buttons runs down the right edge of the window: Snippets, History, Docker, Services, Processes, Network and Monitor. Click one to open that tool on the right, and click it again to collapse it; ⌘⌥B (Ctrl+Alt+B on Windows and Linux) shows or hides the right sidebar. Drag the right sidebar’s left edge to widen it; it can’t get narrower than it was when it opened. The right sidebar appears only with remote terminals: switching to SFTP, a local terminal or Settings hides it together with its buttons, and going back to any remote terminal brings it back as it was, now for the host of the current terminal. With tabs dragged into a split, the right sidebar stays: it works on the remote terminal you switched to or clicked into last, stays put when you click into SFTP or a local terminal in the other half, and hides only when no remote terminal is on screen. Its title names the host it works on (such as “Monitor · web-01”), and the status bar follows the half you clicked into too. Hosts known not to run Linux (macOS, Windows, BSD) don’t show the Services, Processes, Network and Monitor icons; History and Docker are there for every host except Windows ones (on a Mac, Docker works too if Docker Desktop is installed).
**Snippets** keeps the commands you use often, shared by every host. Click the plus at the top to create a snippet: a name, a category and a command, which can span several lines. Snippets are grouped by category, with the ones without a category last, under “Uncategorized”; click a category heading to collapse or expand it, and right-click it to create a snippet in that category, rename the category or delete it (its snippets are deleted with it, and the confirmation says how many). The folder button at the top creates a category. You can search by name or command; with several words, snippets that contain all of them are listed.
Clicking a snippet runs its command in the terminal (snippets are set to “Run on click” by default). When the pointer is on a snippet, an icon at its right shows what a click will do: a lightning bolt means a click runs it, ▶ means a click only types it in. Uncheck “Run on click” in the snippet dialog, and clicking the snippet only puts the command on the terminal’s input line (replacing anything already typed), for you to edit and press Enter. Clicking the lightning bolt or ▶ at the right always runs it right away; the context menu also has “Insert into terminal”, “Copy command”, “Edit…” and “Delete…”. Nothing is typed in while a full-screen program such as vim or less is running in the terminal. On Windows hosts, what’s already typed isn’t replaced; the command is typed after it.
**History** lists the remote host’s bash history (`~/.bash_history`), most recent first, each command once, with how many times it ran below it; when bash recorded times (`HISTTIMEFORMAT` is set), it also says when it last ran, and multi-line commands are recognized. You can search; with several words, commands that contain all of them are listed, ignoring case. Click a command to put it on the terminal’s input line (replacing anything already typed, without running it), for you to edit and press Enter; the “Run” button at the right runs it right away, and the context menu can also copy it. Nothing is typed in while a full-screen program such as vim or less is running in the terminal.
bash writes a session’s commands to the history file only when the shell exits, so commands you’ve just run in the current terminal show up only after it exits. The list is read when you open History or switch terminals, and after that only when you click the refresh button; when the history file is long, only its last 512 KB is read.
**Docker** shows the Docker and Compose versions at the top, and below them four tabs with counts: “Containers”, “Volumes”, “Images” and “Networks”. On “Containers”, the containers of the same compose project are grouped under a project card (project name, running / total, project folder); click the project’s row to collapse or expand it. A project with a single container that’s running starts expanded, and a project with several containers starts collapsed; containers that belong to no project are under “Standalone containers”. Each project and container has “Stop” (or “Start”) and “Restart” at the right, and a project’s buttons act on all its containers. Click a container or its “…” to open its details: the “Details” tab has the name, ID, image, creation time, entrypoint, command, ports, mounts, environment and labels, and the “Logs” tab the container’s last 200 lines of output; at the bottom you can start, stop and restart it, and delete it once it’s stopped. The “Volumes”, “Images” and “Networks” tabs list each item’s name and basic information and mark whether a container uses it; those no container uses can be deleted with the delete button at the right (Docker’s own bridge, host and none networks can’t be deleted). Click a card to open its details: for a volume, the driver, mountpoint, creation time, the containers using it, options and labels; for an image, the full ID, tags, digests, creation time, size, platform, number of layers, the containers using it, configuration (entrypoint, command, working directory, user, exposed ports), environment and labels; for a network, the driver, scope, IPv6, subnets and gateways, the containers attached with their addresses, options and labels. Items can be deleted from the bottom of the details too.
Stopping, restarting and deleting ask first; starting happens right away. The result appears as a notification, and the list is read again. When you’re not logged in as root and not in the docker group, passwordless sudo is used. The list is read when you open Docker or switch terminals, and after that only when you click the refresh button. If Docker isn’t installed or isn’t running on the host, it says so.
**Services** lists the systemd services on the remote host (not templates) in two groups: “Custom services” (unit files in `/etc/systemd/system`, the ones an administrator added) and “System services”. The top shows the systemd version and the overall state of the machine (“Degraded” when a service has failed); there are four tabs with counts, “All”, “Active”, “Inactive” and “Failed”, and you can search by service name or description. Each service has a card: a status dot, the name, description, active state and whether it starts at boot. A running service has “Stop” and “Restart” at the right, one that isn’t running has “Start”, and “…” or clicking the card opens its details. The details’ “Status” tab has the load state, active state, at boot, main PID, memory, tasks, restarts, exit status, start and stop times, and unit file; the “Journal” tab has the service’s last 200 lines in the journal. At the bottom of the details and in the context menu you can likewise start, stop and restart it, and enable or disable it at boot.
Stopping and restarting ask first (with a special warning when you stop the SSH service); starting and the at-boot setting happen right away. The result appears as a notification, and the list is read again. These actions need root: when you’re not logged in as root, they run through passwordless sudo, and if sudo needs a password, you’re told that root is needed. The list is read when you open Services or switch terminals, and after that only when you click the refresh button. For hosts that don’t use systemd, it says they’re not supported yet.
**Processes** lists the processes on a remote Linux host (without kernel threads), each on a card: a status dot (green running, gray sleeping, yellow waiting for I/O or stopped, red zombie; hover over it to see the state), the process name, PID, user, start time (in this computer’s time zone), memory use with its share of physical memory, and CPU usage. CPU usage is computed the way `top` does it, per core: two full cores is 200%. It takes two readings to compute, so right after the tool opens it shows “—”, and the figure appears 2 seconds later. Processes are sorted by memory, most first, by default; click “CPU” to sort by CPU instead, and click the same button again to reverse the order. You can search by process name, PID or user, and the number at the right is how many are listed. The list refreshes every 15 seconds, reading only while Processes is showing; click the refresh button at the top right to read it at once.
Click a process to open its details: PID, parent, user, state (with the `ps` state letters in parentheses, such as `Ssl`), start time, elapsed time, TTY, priority, nice, CPU usage, CPU time, resident memory, virtual memory, number of threads, of direct children and of all descendants, and below them the full command line (read when the details open; “Copy command” copies it). At the bottom are “Copy PID”, “End process” and “Kill”.
Right-click a process for “View details”, “End process…” (sends SIGTERM, so the process can clean up before it exits) or “Kill process…” (sends SIGKILL, ending it immediately); ending it from the details works the same way, and both ask first. The result appears as a notification, and the list is read again 1 second later. When you’re not logged in as root, you can end only your own processes. With more than 2500 processes, only the first 2500 are listed, and the top of the list says so.
**Network** lists every TCP and UDP connection and listening port on a remote Linux host, each on a card: protocol, state, local and remote addresses, the process using it with its PID, and its user. The top right of each card says whether it’s a “Listening port”, “Inbound” (connected to a port this host listens on) or “Outbound”. The top of the panel shows the total, the number of listening ports and the number established; you can search by address, port, state, PID, process name or user, and filter by protocol and state. Listening sockets come first, then established ones, with TIME_WAIT and other closing ones last; when several processes listen on the same port together (such as nginx’s workers), they’re merged into one card.
The list is read once when you open Network or switch terminals and doesn’t change by itself after that; click the refresh button at the top right to read it again. The data is read over this terminal’s existing SSH connection, with no second login, using the host’s `ss` command; on hosts without `ss` (such as BusyBox) it’s read from `/proc/net` instead, and then processes don’t show; when you’re not logged in as root, you see only your own processes. In both cases the top of the list says so. With more than 3000 connections, only the first 3000 are listed (listening ports are always among them), and the total stays accurate.
**Monitor** shows, for a remote Linux host:
- **System**: host name, architecture, distribution and uptime (such as “32 days 23:40:26”).
- **CPU**: model, number of cores and average usage; with several cores, a small bar for each core below, and hovering over a bar shows that core’s number and usage. Only one row shows by default; when there are too many cores for one row, click “N cores” at the top right to show them all.
- **Memory**: physical memory usage and the amount used; a second row when there’s swap.
- **Network**: upload and download rates of the main interface (the one with the default route); with several interfaces, click “N interfaces” at the top right to show them all (loopback interfaces and interfaces that have never sent or received data aren’t listed).
- **Disks**: the usage of each local mount point (without in-memory file systems such as tmpfs, or mounts under system folders).
The data is read over this terminal’s existing SSH connection, with no second login and nothing to install; it refreshes every 2 seconds (disks every 30 seconds), reading only while Monitor is showing. Each refresh reads only a few system files, and starts no processes on the server besides the shell that runs it. Usage of 70% or more shows in yellow, 90% or more in red. When the terminal isn’t connected, it says so; when a host whose system wasn’t known yet turns out not to be Linux, it says it isn’t supported yet.
## SFTP
Open a host’s “SFTP” tab to browse local and remote folders. As with terminals, each “Open SFTP” opens a new tab, so one host can have several, each with its own connection, each showing its own folders. It works like WinSCP’s Commander interface. SFTP uses a separate SSH connection that asks only for the SFTP subsystem, so the server doesn’t need to provide a shell; closing the terminal doesn’t stop transfers.
### Toolbars
Each pane has two rows of toolbar, with the path label below them. When a pane is too narrow, buttons hide whole, from right to left, rather than wrapping (their commands are still in the context menus and shortcuts). A new SFTP tab splits evenly between the local and remote sides; wherever you drag the dividers between the sides and the queue, they stay there until the tab closes, even when you switch to another tab and back:
- First row (navigation):
- The folder list: lists every level from the root to the current folder, plus Home, Desktop, Documents and Downloads on the local side, and on Windows every drive (such as “C: Local disk” and “D: Local disk”); choose one to go there.
- Bookmarks: the button opens the “Open folder” dialog (see below); the drop-down arrow next to it lists this host’s bookmarks for this side, which you click to go there, and can also add or remove the current path. Bookmarks are saved in the local database and deleted along with the host.
- Parent folder, Root folder, Home folder, Refresh, Back, Forward.
- Show / hide hidden files: files and folders whose names start with `.`. An open eye means they’re shown, a crossed-out eye that they’re hidden. Local and remote each have their own switch, independent of each other; each side’s choice applies to every SFTP tab and is saved in the settings; both are hidden by default. While they’re hidden, the bottom of the pane says “N hidden”; while they’re shown, hidden files’ text is lighter.
- Second row (files):
- “Download…” on the remote side and “Upload…” on the local side, whose drop-down also has “Choose files to upload…”.
- Delete, Rename…, Properties…, New (Folder… / File…).
### Path label
Works like WinSCP’s path label:
- It shows the full path of the current folder, such as `/home/tester/`.
- Every level can be clicked: hovering highlights the path from the root to that level, and clicking goes there.
- When the path doesn’t fit, the root and the last few levels stay, and the middle folds into `…`; clicking `…` goes to the last folded level.
- Clicking the current folder, or double-clicking the empty space to the right of the path, opens the “Open folder” dialog.
- Context menu: Go to, Refresh, Add path to bookmarks, Copy path, Open folder or bookmark….
### Open folder or bookmark
Works like WinSCP’s Open directory dialog. Open it from the path label, the bookmarks button, ⌘O (Ctrl+O on Windows and Linux; also ⌘⇧G on macOS) or the path label’s context menu:
- “Folder” is filled in with the current folder, already selected, so typing replaces it. `~` means the home folder, and relative paths start from the current folder. The local side also has “Browse…”.
- Below are this host’s bookmarks for this side. A bookmark that matches the folder is selected; clicking a bookmark puts it in the folder field, and double-clicking opens it directly.
- “Add” adds the folder to the bookmarks, “Remove” (or Delete in the list) removes the selected bookmark and selects the one next to it, and “Up” and “Down” change the order.
- Bookmark changes are saved at once and aren’t undone by “Cancel”, the same as in WinSCP.
- Enter or “Open” goes to the folder; Esc or “Cancel” closes the dialog.
The status bar below the list normally shows the number of items and how many are selected; while connecting, or when reading a folder takes longer than 0.3 seconds, it shows “Connecting to SFTP…” or “Reading folder…”. When a folder can’t be read or the connection drops, the reason appears in red at the far bottom left of the window (where “Connected to” and the host’s name normally are). A dropped connection is noticed even while idle, without waiting for a refresh, as soon as the same host’s terminal would notice. To reconnect, use the “Reconnect” button in the tab bar or the tab menu. While disconnected, any action in the remote list (refreshing, opening a folder, deleting, uploading, downloading and so on) brings up a “Lost the SFTP connection to …” dialog that explains why and can “Reconnect” right away. None of this makes the list move.
The columns are the same as in WinSCP: Name, Size, Type and Modified on the local side; Name, Size, Modified, Permissions and Owner on the remote side. Click a column header (or the arrow at its right) to sort by that column: descending, then ascending, then back to by name; folders always come before files. The order and any column widths you dragged stay when you open another folder or refresh.
- Sizes are shown in KB by default (`4,008,960 KB`, rounded up; only empty files are 0 KB), as in WinSCP. Right-click the “Size” column header to switch to “Bytes” (`4,105,175,040 B`), “Kilobytes (KB)” or “Short (B, KB, MB, GB)”, which uses B, KB, MB or GB depending on the size, such as `3.8 GB`; the choice applies to every SFTP tab and is saved in the settings. Sorting by size always uses the exact number of bytes.
- Times look like `2026/4/22 12:44:53`.
- Permissions look like `rwxr-xr-x`.
- The owner comes from the server’s `ls -l` line.
- A symbolic link to a folder shows as a folder with an arrow, and you can double-click it to go in.
### Selection and context menus
- As in WinSCP with “Full row select” turned off, the “Name” cell is the item: on hover only the name cell turns gray; clicks, double-clicks and right-clicks all act on the name cell; and a selected item’s whole name cell is highlighted. The row’s other columns and the area below the list count as empty space.
- Mouse: click a name cell to select it (deselecting everything else), ⌘-click (Ctrl-click on Windows and Linux) to add or remove items, Shift-click to select a range, double-click to open. Clicking empty space deselects everything (unless ⌘ or Shift is held).
- Box selection: press and drag in the empty space to the right of the names, in the size, type, time and other columns, or below the list to draw a selection box; the rows inside it are selected. Start dragging with ⌘ or Shift held to add to the current selection. Dragging to the top or bottom edge of the list scrolls it. Dragging a file name is what drags files to the other side for transfer: pressing on an unselected file name selects only it (deselecting everything else), and only it is dragged; pressing on a selected file name drags everything selected. While dragging, “Upload N items” or “Download N items” appears only over the other side’s list; over your own list or anywhere else it can’t be dropped, the pointer shows that it isn’t allowed.
- Keyboard: ↑↓, Home, End, PageUp and PageDown move, with Shift to extend the selection; Space or Insert toggles the current row; ⌘A selects all; Tab switches to the other side.
- Right-clicking an unselected name cell selects it first; right-clicking empty space clears the selection and opens the empty-space menu.
- The menu on file names: Edit (for files; images, SVG and Markdown also have Preview, and images other than SVG have only Preview), Upload… / Download…, Copy path (the full paths of the selected items, one per line), Delete, Rename…, Properties…. Double-clicking a folder goes into it, so the menu has no “Open”.
- The empty-space menu: Go to (Parent folder / Root folder / Home folder / Back / Forward), Refresh, Show hidden files (checked means this side shows them), Add path to bookmarks, New.
### Shortcuts
| Command | Shortcut |
|---|---|
| Upload / Download (depending on the pane) | F5 |
| Rename | F2 |
| New folder | F7 |
| Delete | F8, Delete; also ⌘⌫ on macOS |
| Properties | F9; also ⌘I on macOS |
| Edit file | F4 |
| Refresh | ⌘R / Ctrl+R |
| Parent folder | Backspace; also ⌘↑ on macOS |
| Root folder | ⌘\ / Ctrl+\ |
| Home folder | ⌘⇧H on macOS, Ctrl+H on Windows and Linux |
| Back / Forward | ⌥← / ⌥→; also ⌘[ / ⌘] on macOS |
| Add path to bookmarks | ⌘D / Ctrl+D |
| Open folder / Edit file | Enter; also ⌘↓ on macOS |
| Open folder or bookmark | ⌘O / Ctrl+O; also ⌘⇧G on macOS |
| Show / hide hidden files (depending on the pane) | ⌘⇧. on macOS, Ctrl+Alt+H on Windows and Linux |
### File operations
- Deleting asks first, saying what will be deleted and what happens:
- Local items move to the Trash (the Recycle Bin on Windows).
- Remote items are deleted permanently, a folder together with everything in it.
- Deleting a symbolic link deletes only the link itself.
- Renaming and creating:
- The name is checked against the current list: it can’t be empty, can’t be `.` or `..`, and can’t be the same as an existing item’s.
- Renaming never overwrites an existing item.
- Properties:
- Shows the location, size and modification time, plus the owner and group for remote items.
- Permissions are edited with a 3×3 grid of checkboxes and an octal number, kept in sync.
- With several items selected, permission bits that differ between them stay as they are unless you change them.
- When folders are included, the change can be applied recursively; a recursive change checks “Add execute permission to folders (X)” by default, so the folders can still be opened.
- Symbolic links are left out of permission changes.
### Editing files
Text files can be edited right in ShellRS, remote and local alike (like WinSCP’s internal editor):
- Opening: double-click a file, select it and press Enter or F4, or right-click it and choose “Edit”. A file made with “New › File…” opens right away too. Files open in an editor tab in the center area, which can be dragged into a split like terminals and SFTP; each file has only one tab, and opening it again switches to that tab.
- The editor has line numbers, find and replace (⌘F / Ctrl+F; replace ⌘⇧F / Ctrl+H), and coloring by file name for Shell, YAML, TOML, JSON, Python, JavaScript, HTML, CSS, Markdown, SQL, PHP, Go, Lua, Makefile and Diff; other files are plain text. In Makefiles and files indented with tabs, the Tab key types a tab character.
- Saving: ⌘S / Ctrl+S or “Save” above the tab. A remote file is written straight back to the original file (in place) over the connection of the SFTP tab that opened it, so its owner, permissions and the links pointing to it stay the same. Before saving, ShellRS checks the file’s size and modification time: if someone else changed or deleted it after you opened it, you’re asked whether to “Overwrite” first, rather than having it overwritten silently. If the connection drops partway through writing, the file on the server may be only partly written; the editor still has your text, so reconnect and save again.
- “Reload” reads the file again from the server or the disk, asking first if there are unsaved changes.
- A dot on the tab means unsaved changes. Closing such a tab asks whether to “Save”, “Discard changes” or “Cancel”; closing several tabs at once, closing an SFTP tab (which also closes the remote files it opened) or quitting ShellRS with unsaved files asks once first. On macOS, quitting from the Dock and logging out or shutting down don’t ask.
- The bottom left of the window shows the SFTP connection the file goes through (“Local file” for local files), and the bottom right the cursor position, the encoding and the line endings (such as “UTF-8 · LF”).
- Limits: only UTF-8 text files of 5 MB or less open (with or without a BOM; the BOM and CRLF line endings are kept on save). Larger files, binary files and files in other encodings such as GBK are refused with the reason; remote ones can be downloaded with “Download…” instead.
### Preview
Images (PNG, JPG, GIF, WebP, SVG, BMP, ICO, TIFF) and Markdown can be previewed, remote and local alike:
- Opening: double-click an image. Markdown and SVG are text, so double-clicking them opens the editor; to preview them, choose “Preview” in the context menu.
- The preview is a dialog that covers most of the window; Esc, the close button at the top right or the “Close” button closes it. Markdown is shown formatted: you can scroll, select and copy, and click links to open them in the browser.
- Images start at “Fit to window”: scaled down in proportion until they fit (small images aren’t enlarged) and centered, with the size in pixels, the file size and the current scale above. The “Zoom in” and “Zoom out” buttons (or ⌘= / ⌘-, Ctrl on Windows and Linux) step through 10%–800%, and scrolling the wheel or trackpad with ⌘ (Ctrl) held zooms smoothly; “Actual size” (⌘0) shows one image pixel per point, and “Fit to window” (⌘9) goes back to the size that fits; double-click the image to switch between the two. When the image is larger than the preview area, you can scroll in every direction, and zooming keeps the point in the middle of the view in place.
- At the bottom, “Download…” downloads a remote file to this computer (opening the download confirmation), and for Markdown, “Edit” opens the editor (or switches to it if it’s already open).
- The preview shows the file on the server or the disk; changes not yet saved in the editor don’t appear in it.
- Limits: images up to 20 MB, Markdown up to 5 MB and in UTF-8; larger files are refused with the reason, and remote ones can be downloaded with “Download…” instead.
### Uploading and downloading
- Ways to start:
- Upload: “Upload…”, F5 in the local list, “Choose files to upload…”, dragging local items by their file names to the remote list, or dragging files from Finder to the remote list.
- Download: “Download…”, F5 in the remote list, or dragging remote items by their file names to the local list.
- Dropping on a folder’s row puts the items in that folder; dropping anywhere else puts them in the current folder.
- Every way shows a confirmation where you can change the destination folder; for downloads, “Browse…” chooses a folder on this computer. The confirmation says how many items of which kinds (such as “Upload 2 files to …”), shows the source folder once, and lists each item on its own line, with the full path on hover.
- Once confirmed, the source, endpoints and destination are fixed. Each SFTP tab runs one batch at a time (uploads and downloads share it), handling the batch’s items one after another; while a transfer runs, you can start more uploads or downloads, and each new batch waits in Transfers and starts automatically when the one before it ends. Other SFTP tabs, hosts and terminals stay usable.
- Recursive transfers include empty folders. Symbolic links are recreated as links, not followed. Transfers are binary by default, and regular files keep their modification time.
- New items get the destination side’s default permissions, and overwriting keeps the destination’s existing basic permissions. Owners, ACLs and extended attributes aren’t copied.
- Folders with the same name are merged. When a file or link conflicts, you’re asked whether to overwrite, skip or cancel, and can apply the answer to the rest of the batch’s conflicts; a folder conflicting with something that isn’t a folder is never deleted automatically.
- On an error, you can retry, skip or cancel, and the summary at the end lists what succeeded, what was skipped and what failed.
### Transfers
Works like WinSCP’s queue panel: it appears below the two panes when there are transfers, its height can be dragged, and it collapses once all finished batches are cleared.
- One row per batch: Direction (upload / download), Source (the item’s path for a single item, the folder they’re in for several), Destination, Transferred (in the same units as the “Size” column), Time (time left), Speed (over the last few seconds) and Progress.
- A batch in progress shows an overall progress bar, with a second line below for the file being transferred and its own progress bar, like the two progress bars in WinSCP’s progress dialog.
- The Progress column shows: Waiting, Scanning, a percentage, Waiting for an answer, a note about reconnecting, Stopped, Done, N items failed, Removed.
- Click the arrow at the start of a row to expand the batch’s results item by item (Done, Skipped, or Failed with the reason), newest first.
- Toolbar: Resume (a stopped batch), Stop (a batch in progress), Remove from queue (the selected waiting or finished batches, or a stopped batch), Discard partial transfer, Clear finished.
- A stopped batch stays at the front of the queue; the batches after it start only once you resume it, discard its partial transfer or remove it from the queue. Removing it from the queue doesn’t delete its resume record, so transferring the same files again still asks whether to resume.
- The queue lasts only for this run and is cleared when the SFTP tab closes; closing the tab with unfinished batches asks first.
## Resuming and replacing
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and once every write has been acknowledged and the file has closed successfully, its modification time (and, over an existing file, that file’s permissions) is set by path before the real file is published. Over an existing file, the old file is first moved to `.shellrs-<UUID>.backup`, then the new file is published and the backup deleted; this replacement isn’t atomic, as the destination is briefly missing between the two renames. Like WinSCP, transfers use no SFTP extensions (`fsync@openssh.com`, `posix-rename@openssh.com` and so on) and set no attributes on an open file: some bastion hosts refuse these requests, or even end the whole session over them. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
Resume records are kept in `upload-resume/` in the app’s data folder, as versioned JSON written atomically; they never contain passwords, passphrases or private keys, and they don’t change the database schema. A record ties together the endpoint, host fingerprint, source, destination and safe-replace stage. Resuming works as in WinSCP: ShellRS reads the current size of the remote `.filepart`, skips that much of the local file and carries on; it’s up to you to make sure the source is still the same version. Uploads never read the remote file back to verify it.
Downloads likewise write first to `<destination file name>.filepart` on this computer; when done, ShellRS sets the remote modification time, runs fsync, and renames it to the real file within the same file system (an atomic replace). Concurrent read requests may come back out of order or short, but the temporary file is written strictly in order, because resuming trusts only its length. Download resume records are kept in `download-resume/` and tie together the endpoint, host fingerprint, remote source with its size and modification time, and local destination; if the remote file changes, the download can’t resume and can only start over or be skipped.
On network failures, each batch reconnects automatically up to three times, after 1, 3 and 10 seconds. After that, resume the batch with “Resume” in Transfers; resuming by hand resets the count. Authentication failures, a changed host key and a deliberate cancel need you to deal with them. Reconnecting uses the original connection settings, so editing the host doesn’t change the destination of a transfer in progress.
Cancelling, closing the tab, disconnecting or quitting keeps the resume records; closing a tab with a transfer in progress asks first. After a restart, transfers don’t continue by themselves; choosing the same source and destination again asks whether to resume. While a batch is stopped, “Discard partial transfer” cleans up its managed temporary files; if there’s a recovery backup that can’t be judged safely, the files are kept and the reason is given. Items published successfully have their records and backups cleaned up automatically.
When a file name can’t be represented exactly, ShellRS reports an error. The remote string decoding of the current `russh-sftp 3.0.0` replaces invalid UTF-8, so the browser refuses remote names that contain the replacement character `U+FFFD`, to avoid acting on the wrong path. Symbolic links use OpenSSH’s SFTP v3 argument order.
**Not implemented yet: folder sync in SFTP tabs (the external CLI’s `shellrs sync` can sync), a foreground transfer progress dialog, reordering the queue, filtering, finding files, a folder tree, and keeping the Dock layout.**
## Download and updates
Packages are on [GitHub Releases](https://github.com/since2006/shell-rs/releases):
| System | First install | Automatic updates |
|---|---|---|
| macOS (one package for Apple Silicon and Intel) | DMG; drag ShellRS into Applications | Yes |
| Windows x64 | Setup program, which installs for the current user into `%LOCALAPPDATA%\Programs\ShellRS`, with no administrator rights needed | Yes |
| Linux x64 | AppImage | Yes |
ShellRS checks for a new version 30 seconds after it starts, then every 6 hours. When there is one, it downloads and verifies it in the background, and then a green update button appears at the top right of the title bar. Click it: “What’s new” opens the changelog on the website, and “Restart to update” switches to the new version and reopens. Restarting closes open connections and stops transfers (keeping their progress for resuming) and port forwards; the dialog lists exactly which.
“Updates” in “Settings › About” has just three rows:
- **Current version**: below it, the current state (up to date, downloading, downloaded and so on); at the right, the version number and a button, normally “Check for updates”, which becomes “Restart to update…” once an update is downloaded. The build number and platform are in the version number’s tooltip.
- **Update channel**: Stable is for everyday use; Beta gets test versions early, and stable releases too when they come out. After you switch, ShellRS checks the new channel right away (with automatic updates off, it waits until you check by hand).
- **Automatic updates**: when this is off, ShellRS goes online only when you check by hand, and when it finds a new version, you decide whether to download it.
- **Verification**: the update manifest is signed with ShellRS’s release key, and the package is checked against the size and SHA-256 in the manifest; if any step doesn’t match, nothing is installed. On macOS, the new version’s developer signature is checked as well.
- **Privacy**: checking for updates sends only ShellRS’s version number, the operating system and the CPU architecture, nothing that could identify this computer or you. For anonymous usage statistics, see “Privacy”.
- **When automatic updates aren’t possible**: when ShellRS runs straight from the DMG or outside the Applications folder, you can’t write to where it’s installed, on Linux it isn’t run as the AppImage, or it’s a development build you compiled yourself. The About page then says why, and offers “Open download page” when a new version is out.
- **Going back to an older version**: before the database is upgraded to a new schema, it’s backed up as `shellrs.db.v<old version>.bak`. An older version refuses to open a database with a newer schema, so as not to mess up data a newer version saved; to go back, rename the backup to `shellrs.db`.
## Privacy
ShellRS never sends hosts, credentials, session content, commands, file names or paths, or any identifier that could identify this computer or you. Only these two things go online (besides connecting to your hosts):
- **Checking for updates**: sends only ShellRS’s version number, the operating system and the CPU architecture; see “Download and updates”.
- **Anonymous usage statistics**: sent to [Aptabase](https://aptabase.com) to learn how many installs there are, how many devices use ShellRS each day, how the versions are spread, and which features are used most. “Send anonymous usage statistics” in “Settings › About › Privacy” is on by default; turn it off and nothing more is sent, and the counts collected on this computer are cleared too.
Every statistics event carries ShellRS’s version number and the operating system’s name and version (such as macOS 15.1, Windows 10.0.22631, Ubuntu 24.04). There are only four events:
| Event | When it’s sent |
|---|---|
| `app_installed` | Once, on the first launch. The data folder records that it was reported; a different data folder, or deleting it, reports it again |
| `app_started` | Every launch |
| `app_active` | The first time each day (UTC) the window comes to the front; not again on the day ShellRS started. On macOS, a closed window with ShellRS left in the background doesn’t count |
| `usage` | How many times each feature was used: once 4 hours have passed since the first use, summed up into one event at the next launch, activation or use |
The first three carry these settings and counts, each with a fixed set of possible values: CPU architecture, appearance, interface language, light and dark themes (names of built-in themes), whether the interface matches the theme, whether highlighting, the external CLI and automatic updates are on, the update channel, whether any shortcut was changed, and how many hosts, credentials, port forwards and snippets are saved (only as a band: 0, 1-5, 6-20, 21-100, 100+).
`usage` has only counts, no content. It counts: SSH terminals connected (also counted separately for jump hosts, proxies, credentials, no password, quick connections and external connections), SFTP connected (also counted for quick connections and external connections), local terminals, port forwards started (local, remote, dynamic), upload and download batches, the editor opening local and remote files, previews of images and Markdown, opening each of the seven right sidebar tools, snippets and history commands sent to the terminal, terminal find, notifications (program, bell, keyword), the external CLI’s `exec`, `upload`, `download`, `sync`, `hosts` and `credentials` commands, and turning highlighting and the external CLI on and off.
- Counts are first kept in `analytics.json` in the data folder, so quitting doesn’t lose them, and they’re sent later. Being offline or on an intranet doesn’t get in the way: when sending fails, ShellRS waits 1 minute, then doubles the wait each time up to 6 hours, and retries only while you’re using ShellRS, sending no requests while it’s idle.
- Aptabase infers the country and region from the request’s IP address but doesn’t store the IP. It tells devices apart by a hash of the IP and User-Agent with a random salt that changes every day, so it can’t follow the same device across days.
- Development builds you compile yourself don’t send statistics, and neither does the external CLI (the `shellrs` command) itself.
+455
View File
@@ -0,0 +1,455 @@
# ShellRS 使用手册
[返回 README](../README.zh-CN.md)
基于 GPUI Kit 的中文 SSH 主机管理工具,提供持久化的主机与分组、可在多台主机间共用的登录凭据、本地终端、SSH 多终端、对标 WinSCP 的 SFTP 双栏浏览器和 SSH 端口转发。
标题栏「ShellRS」后面的三个图标切换左侧栏显示的内容:主机列表、端口转发列表或凭据列表。侧栏隐藏时点其中一个会把它重新展开。
ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已有的窗口调到前面,新启动的那个随即退出。两个 ShellRS 各自把主机读进内存再写回同一个数据库,会互相覆盖对方的改动。(用 `SHELLRS_DATA_DIR` 指到不同数据目录的算不同的 ShellRS,可以同时运行。)要并排看两个终端或 SFTP,把标签拖成分栏即可。
在 macOS 上,点窗口左上角的关闭按钮只是把 ShellRS 隐藏到后台:已打开的连接、SFTP 传输、端口转发和外部 CLI 照常运行,点 Dock 图标恢复窗口,原来的标签都在。要真正退出用 ⌘Q,或者右键 Dock 图标选「退出」。Windows 和 Linux 上关闭窗口就是退出。
## 主机列表
左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。
主机对话框的「认证方式」有三种:
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。服务器只在键盘交互式认证里收密码时(如 ESXi),保存的密码会自动回答它唯一的隐藏问题;还有验证码等别的问题时照样询问。
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。「测试连接」时选了「密码」却没填,就像「无密码」那样试一遍(免认证、SSH Agent、默认私钥),连不上就说明原因。
主机对话框「分组」下面的「连接方式」决定怎么连到这台主机,终端、SFTP、端口转发、外部 CLI 和「测试连接」都照它走:
- **直接连接**:默认,本机直接连主机的地址和端口。
- **SSH 跳板**:经过别的已保存主机连过去,支持多级。框里一排标签写出整条路(「本机 → 阿里云99 → 禅道 → 当前主机」),下面每台跳板一行,「×」移除,「添加跳板主机」可按名称或地址搜索;要调顺序就移除后重新添加。连接时先登录第 1 台,由它连第 2 台,依此类推,最后一台连到主机。每台跳板都用它自己的地址、端口、认证方式(密码、凭据或无密码)和主机信任记录,首次连接各问一次主机密钥,询问框里写明是哪台跳板主机;跳板主机自己的「连接方式」在这里不生效,要多级就在这里按顺序列出。主机的地址按跳板主机那边的网络来填,例如内网地址。
- **代理连接**:经由 HTTP 代理(`CONNECT`)或 SOCKS5 代理连接,在下拉框里选。填代理的地址和端口(例如本机的 `127.0.0.1:7890`);代理需要认证时再填用户名和密码,密码保存在系统钥匙串,经由同一个代理、同一个用户名的主机共用。主机的地址交给代理去解析。代理要求认证却没有密码、或者拒绝了用户名和密码时,连接失败并说明原因,不会询问。
改了一台主机的地址或认证方式,经由它跳转的主机也会重新连接(只改名称不会)。删除一台被别的主机用作跳板的主机,确认框写明有几台主机在用它;删除后那些主机的跳板列表里留下一行「已删除的主机」,连接时直接报错,不会悄悄改成直连或少跳一级,编辑时移除或换一台即可。已经打开的连接不受影响。
**尚未实现:跳板和代理叠加使用、拖动调整跳板顺序、HTTPS 代理、在主机列表里标出连接方式。**
对话框最后是「备注」,可以写多行(回车换行),记下机器的用途、负责人、到期时间之类,只保存在本地数据库里,不影响连接。
鼠标停在主机上,行的右侧会显示 `用户@地址:端口`,主机有备注时下面接着显示备注(太长的只显示前 6 行)。右键主机可连接、打开 SFTP、编辑、复制、复制 IP 地址(地址是域名时叫「复制主机名」)、复制 ID 或删除。「复制 ID」复制的是主机的 16 位随机标识(形如 `Jwg5rHvXCxw89paM`),供其他工具标识这台主机:它在主机创建时生成,改名、改地址都不变,复制出来的主机得到新的 ID,删掉的 ID 不会再分配。没有打开任何标签时,中间区显示「最近连接」列表,双击或回车连接;右键其中的主机弹出同一个菜单,并选中该主机。
列表上方「新建主机…」左边的「快速连接」打开一个搜索框,列出所有保存的主机:最近连接过的在前,其余按名称排,每行写着分组和 `用户@地址:端口`。输入名称、地址、用户名、备注或分组里的字就能筛选,几个词用空格隔开时都要匹配。打开时不高亮任何一台;输入后高亮第一个匹配的,回车就连它;也可以 ↑↓ 移动,或者直接单击一台连接。
列表上方的搜索框(⌘K,其他平台 Ctrl+K)按名称、地址、用户名筛选主机,也按名称找分组:分组名匹配时列出这个分组和它下面的全部子分组、主机。结果都展开,匹配的主机连同它所在的各级分组一起显示。回车连接第一台匹配的主机(按列表里的顺序;只因分组匹配而列出的主机不算)。Esc 清空搜索。
拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。
## 临时连接
标题栏右上角的「临时连接…」连一台主机而不保存它,适合只登一次的机器。对话框和新建主机一样填地址、端口和认证方式(密码、使用凭据或无密码),没有分组、连接方式和备注,名称可以不填(用地址);可以先「测试连接」。点「连接」打开一个终端标签:
- **功能齐全**:在新标签页中连接、打开 SFTP、重新连接和右侧栏的全部工具都能用。连接方式是直接连接。
- **不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」。
- **密码只在内存里**:填的密码不进钥匙串,重新连接时照样用它;没填时服务器要密码就弹框问。选「使用凭据」时用凭据保存的密码或私钥。
- 外部 CLI 的 `shellrs hosts list` 也列出它(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时可以用它的 ID。
要保存的主机在主机列表标题栏的「+」、右键菜单或 ⌘N(其他平台 Ctrl+N)新建。
## 端口转发
点标题栏的端口转发图标,左侧栏换成转发列表。每条转发规则经由一台已保存的主机,启动时为自己建立一条 SSH 连接,不依赖该主机的终端或 SFTP 标签:关掉标签、对主机「断开连接」都不影响它,主机的连接状态也不把它算在内。有转发在运行时,标题栏的端口转发图标旁显示运行数量。
三种转发,都按「入口 → 出口 → 目标」理解,连接从入口进、经 SSH 隧道到出口、再由出口去连目标:
| 类型 | 入口(监听) | 出口 | 用途 |
| --- | --- | --- | --- |
| 本地转发(`ssh -L`) | 本机 | 服务器 | 在本机访问服务器那边的服务,例如用本机的客户端连只对服务器开放的数据库 |
| 远程转发(`ssh -R`) | 服务器 | 本机 | 让服务器那边访问本机的服务,例如把本机开发中的站点给对方看、接收 Webhook |
| 动态转发(`ssh -D`) | 本机(SOCKS 代理) | 服务器 | 把服务器当代理,目标由应用自己指定,例如让浏览器经由服务器上网 |
- **新建和编辑**:列表标题栏的「+」打开对话框。上方一行三张卡片选类型,中间的示意图随表单实时填入地址、标出入口在哪台机器上,下面一句话说明这条规则的效果(如「在本机连接 127.0.0.1:8080,就等于从 SSH 服务器连接 db.internal:3306。」)。示意图和这句话里的「SSH 服务器」就是「经由主机」选的那一台,图里按它扮演的角色称呼,不写主机的名称。示意图上有一个圆点沿连接方向缓缓移动:先过 SSH 隧道,再到目标,停一下,周而复始;切换类型时从新的入口重新出发。名称可以不填,列表里显示转发摘要(`8080 → db.internal:3306`、`9000 ← localhost:3000`、`SOCKS 1080`)。
- **启动和停止**:每行末尾的按钮,或双击该行、选中后回车;右键菜单里还有编辑和删除。「创建」只保存规则,不会自动启动。勾选「启动 ShellRS 时自动开启」的规则随应用启动。
- **状态**:行尾显示正在连接、运行中(及当前连接数)、正在重连或已停止的原因。某个连接转发不了(例如服务器连不上目标)只在该行提示,转发本身继续运行。
- **登录**:使用主机的配置、钥匙串里保存的密码和 ShellRS 自己的主机信任记录。首次连接的主机、需要输入的密码会弹出和终端一样的询问框,并注明是哪条转发在连接;取消询问等于停止这条转发。
- **断线重连**:连接中断后按 1、3、10 秒自动重连三次,重连期间本机监听端口保留,新进来的连接会被直接关闭。自动重连不弹任何询问:需要重新输入密码、确认主机密钥时停止并写明原因,手动启动即可。三次都失败也会停止,并弹出通知。
- **修改后**:编辑运行中规则的类型、主机、监听或目标,或编辑它所经由的主机的地址、认证方式和连接方式(包括这台主机所用凭据的用户名、类型或私钥文件,以及它的跳板主机),这条转发会按新设置重新启动;只改名称或自动开启不会。
- **删除主机**:删除主机(或包含它的分组)会连同经由它的转发规则一起删除,运行中的先停止,确认框里写明数量。
- **动态转发**支持 SOCKS5(无认证,`CONNECT`,IPv4、域名、IPv6)和 SOCKS4 / 4a。域名交给服务器解析。
- **监听地址**默认 `127.0.0.1`。填其他地址时,本地和动态转发会被同一网络中的其他设备使用;远程转发需要服务器的 `sshd_config` 设置 `GatewayPorts clientspecified` 或 `yes`,其他机器才连得上;默认是 `no`,这时转发照常运行、不报错,但只监听服务器自己的 `127.0.0.1`(和手写 `ssh -R` 一样)。对话框在填了非回环地址时会提示这一点。1024 以下的端口需要管理员(服务器上是 root)权限。不支持端口 0(由系统挑选端口)。
**尚未实现:通过外部 CLI 管理转发、规则排序。系统的「减少动态效果」设置目前不会关掉示意图的动画。**
## 凭据
点标题栏的凭据(钥匙)图标,左侧栏换成凭据列表。一条凭据是一套可以让多台主机共用的登录信息,包括名称、用户名和登录方式:
| 类型 | 保存什么 | 登录时 |
| --- | --- | --- |
| 密码 | 用户名和密码 | 用凭据保存的密码;没存或被拒绝时询问 |
| 密钥 | 用户名、私钥和口令 | 只用这把私钥;口令按私钥文件保存,用同一个文件的凭据共用 |
| SSH Agent | 用户名 | 只用 SSH Agent 中的密钥:macOS / Linux 上是 `SSH_AUTH_SOCK` 指向的 Agent,Windows 上是 OpenSSH Authentication Agent 服务 |
- **新建和编辑**:列表标题栏的「+」、空列表里的「新建凭据…」或空白处的右键菜单打开对话框;双击凭据、选中后回车或右键「编辑凭据…」编辑。密码和私钥口令存进系统钥匙串,数据库里只有名称、用户名、类型和私钥路径。
- **密钥从哪里来**:密钥凭据的「私钥」有三种来源。
- **本机文件**:选一个私钥文件,只保存它的路径;文件移动或删除后需要重新选择。
- **粘贴**:贴进 OpenSSH、PEM 或 PuTTY 格式的私钥。贴成公钥或认不出的内容时会说明原因。私钥加了口令时才显示口令栏。
- **生成新密钥**:选 Ed25519(默认)或 RSA 4096,选中就生成;对话框里显示公钥,旁边可以复制。口令可留空;填了就用它加密私钥,口令存进钥匙串。生成的密钥以凭据名称作为注释。
- 粘贴和生成的私钥由 ShellRS 写成数据目录下 `keys/` 里的文件(随机文件名,只有自己的账户能读),凭据引用这个文件,编辑时显示为「本机文件」和它的路径。之后再粘贴或生成会直接覆盖这个文件,已连接的主机不会因此重连;改成别的文件或别的类型、或删除凭据时,这个文件连同它的口令一起删除,对话框会先提示。用户自己的私钥文件 ShellRS 从不改动或删除。
- 空列表和空白处的右键菜单里另有「生成密钥…」,打开的是同一个对话框,已经选好「密钥 · 生成新密钥」。密钥凭据的右键菜单有「复制公钥」,用来把公钥加到服务器的 `~/.ssh/authorized_keys`。
- **主机跟着凭据变**:改凭据的用户名,所有使用它的主机的用户名一起变。改用户名、类型或私钥文件时,使用它的已连接主机会重新连接(只改名称不会);对话框里写明有几台主机在用。
- **删除**:右键「删除」。有主机在用时确认框写明数量和它们之后怎么登录:用密码凭据的主机改为「密码」,连接时询问;用密钥或 SSH Agent 凭据的主机改为「无密码」。用户名不变,密码和私钥不会复制过去。已经打开的连接不受影响,凭据的密码随凭据一起从钥匙串删除。
- **SSH Agent 凭据**连不上 Agent、Agent 里没有密钥或服务器不接受其中任何一把时,会直接说明原因,不会改为询问密码。「无密码」的主机也会尝试同一个 Agent,连不上时静默跳过,接着试默认私钥(Windows 上同样如此)。
**尚未实现:在主机对话框里直接新建凭据、凭据排序、自定义 SSH Agent 的套接字、把公钥自动装到主机上、导出 ShellRS 保存的私钥。**
## 设置
侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、关键字高亮、键盘快捷键、外部 CLI、应用、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。
- **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认跟随系统:系统语言是中文时用简体中文,其他语言用英文。改动立即生效,不用重启:窗口里的文字、已打开的标签和搜索框提示一起换成新的语言;已经写进终端的提示和已经弹出的通知保持原样。外部 CLI 的输出也用这里的语言。
- **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。
- **外观 → 主题**:左栏是浅色主题,右栏是深色主题,各选一套:应用外观为浅色时用左栏选中的那套,深色时用右栏的;跟随系统时随系统一起切换,标题栏的主题按钮切换时也一样。默认只有终端按所选主题配色,界面保持中性的灰色调(深色是不刺眼的炭灰,不是纯黑);打开上方的「界面跟随主题」,界面也按主题配色:侧栏、标签栏、对话框等用主题的底色和文字色,成功、警告、错误等状态用主题的绿、黄、红。每张卡片用该主题的颜色显示一段示例,选中的右上角有蓝色对勾。默认是 ShellRS Light 和 ShellRS Dark,与界面同色。内置浅色、深色各 10 套:浅色 ShellRS Light、Solarized Light、Tokyo Day、Catppuccin Latte、Flexoki Light、One Half Light、Gruvbox Light、Kanagawa Lotus、Rosé Pine Dawn、Everforest Light;深色 ShellRS Dark、Solarized Dark、Tokyo Night、Catppuccin Mocha、Flexoki Dark、One Half Dark、Gruvbox Dark、Kanagawa Wave、Dracula、Nord。同一主题的浅色版和深色版在两栏的同一行。改动立即作用于所有已打开的终端,字体和关键字高亮的预览也用当前主题;vim 等程序询问终端的背景色时,得到的是当前主题的颜色。**尚未实现:自定义主题、导入配色、按主机指定主题。**
- **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(10–28 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端;在终端里按 ⌘= / ⌘-(其他平台 Ctrl)也能逐级调字号,⌘0 回到默认。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
- **终端 → 终端交互**:「选中复制」打开后,用鼠标选中文字、松开时就复制到系统剪贴板,不用再按 ⌘C;默认关闭,单击没选中文字时剪贴板不变。「右键行为」决定在终端里按右键做什么:显示菜单(默认)、粘贴(直接粘贴剪贴板,同 ⌘V)、无动作。选后两种时,菜单里的复制、粘贴、查找、清屏仍可用快捷键,打开 SFTP、重新连接在标签的右键菜单里。改动立即作用于所有已打开的终端。
- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。
- **键盘快捷键**:主要功能的快捷键,分通用、标签、终端、编辑器四组,每行是功能名和它的按键。点按键进入「按下组合键」,按下新的组合就换上;Esc 或点别处放弃。行尾两个按钮:禁用(之后显示「无」,再点一下恢复默认)、恢复默认;页面上方的「重置」也能逐项恢复。改动立即生效,工具栏提示和右键菜单里显示的按键跟着变。
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12,或者 Shift 加 Insert、Delete、Home、End、Page Up、Page Down(方向键上方那六个,不挡打字);「切换到标签 1…9」按任意一个带修饰键的数字即可,九个一起换。
- 已被同一处的另一个快捷键、或输入框等内置功能占用的组合会被拒绝,并提示被谁占用;想换就先改或禁用那一个。终端、编辑器里的快捷键可以和通用快捷键同键,在终端、编辑器里以前者为准(如 ⌘K 在终端里是清屏)。
- 默认(macOS / 其他平台):新建主机 ⌘N / Ctrl+N,新建分组 ⌘⇧N / Ctrl+Shift+N,打开设置 ⌘, / Ctrl+,,显示或隐藏侧栏 ⌘B / Ctrl+B,显示或隐藏右侧栏 ⌘⌥B / Ctrl+Alt+B,聚焦搜索 ⌘K / Ctrl+K,放大、缩小界面和实际大小 ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0,退出 ⌘Q / Ctrl+Q;新建本地终端 ⌘T / Ctrl+T,关闭标签 ⌘W / Ctrl+W,下一个标签 ⌘⇧] / Ctrl+Tab,上一个标签 ⌘⇧[ / Ctrl+Shift+Tab,切换到标签 1…9 ⌘1…⌘9 / Alt+1…9(9 是最后一个);终端的复制、粘贴、查找、查找下一个、查找上一个、清屏 ⌘C ⌘V ⌘F ⌘G ⌘⇧G ⌘K / Ctrl+Shift+C、Ctrl+Shift+V、Ctrl+Shift+F、F3、Shift+F3、Ctrl+Shift+K,放大、缩小字号和默认字号 ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0;编辑器保存 ⌘S / Ctrl+S。标签切换在当前标签所在的那组标签里进行,首尾循环。⌘= / ⌘- / ⌘0 在终端里调终端字号(每次 1 像素,范围同「终端 › 字号」的 10–28,写进设置,下次启动保留),在终端外缩放整个界面(五档,重启后回到默认)。
- SFTP 文件列表、图片预览的快捷键和 Enter、方向键、Tab、Esc 这些固定键不在这里改。
- **外部 CLI**:见下一节。
- **应用 → 窗口**:「记住窗口大小」恢复上次的窗口大小和最大化状态,「记住窗口位置」把窗口放回上次所在的屏幕和位置,默认都打开,下次启动时生效。那块屏幕不在了、或窗口大半在屏幕外时,放在屏幕中间;窗口比屏幕大时缩到能放下。全屏不记,按退出全屏后的大小恢复。窗口的大小和位置随移动随时记在数据目录的 `window.json` 里。
- **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」;「隐私」一组的「发送匿名使用统计」,默认打开,见「隐私」;「ShellRS」一组的「官网」用浏览器打开 shellrs.com。
## 外部 CLI
`shellrs` 命令让 Codex、Claude Code、OpenCode 等 AI Agent 在 ShellRS 里已保存的主机上执行远程命令、传输和同步文件,也能查看、新建、修改和删除主机与凭据。命令本身不读密码和私钥,也不读数据库:它把请求交给正在运行的 ShellRS,由 ShellRS 用保存的配置和钥匙串去连接。请求走本机的进程间通信:macOS、Linux 上是数据目录下的套接字 `cli.sock`,Windows 上是命名管道 `\\.\pipe\shellrs-cli-<数据目录的散列>`。
在 设置 → 外部 CLI 中:
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据,还能在已打开的远程终端里输入命令(`exec --terminal`,包括已切到 root 的终端),都没有另外的开关。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
- **CLI 二进制**:把 `shellrs` 放进 PATH。
- macOS:链接到 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框。
- Linux:链接到 `~/.local/bin/shellrs`。
- macOS、Linux 上「移除」只删除指向 shellrs 的链接,同名的其他文件不会被改动。
- Windows:图形程序没有控制台,命令是单独的控制台程序 `shellrs-cli.exe`(与 `shellrs.exe` 放在一起)。「安装」把它复制成 `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe`,并把这个文件夹加入当前用户的 PATH(不需要管理员),重新打开的终端和 Agent 才能找到它。ShellRS 升级后启动时会自动更新这份副本;更新不了时显示「更新」。副本正被使用时也能更新。「移除」删掉副本并把文件夹移出 PATH。
- **Agent Skills**:把教 Agent 使用 `shellrs` 的 skill 写到通用目录(`~/.agents/skills`)或 Codex、Claude Code、OpenCode、WorkBuddy 各自的 skills 目录;内容和当前版本不同时显示「更新」。「复制 skills」把 skill 全文复制到剪贴板。
命令(`shellrs --help` 查看完整说明):
```sh
shellrs hosts list [-q <关键词>] [--json] # 列出主机;输出不是终端时自动用 JSON
shellrs hosts show <ID> [--json] # 一台主机的全部配置
shellrs exec <ID> "<命令>" # 或 --stdin 从标准输入读命令
shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON
shellrs exec <ID> --terminal "<命令>" # 在 ShellRS 已打开的终端里执行,见下文
shellrs upload <ID> <本地路径> <远程路径>
shellrs download <ID> <远程路径> <本地路径>
shellrs sync <ID> <本地目录> <远程目录> [--delete]
shellrs hosts create | update <ID> | delete <ID> [--force]
shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
```
- `<ID>` 是 16 位的主机 ID,即 `shellrs hosts list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
- `exec --terminal`(`exec --json` 里写 `"terminal": true`)不重新登录,而是把命令打进这台主机在 ShellRS 里已打开的远程终端,给只允许登录一次的堡垒机用(见「从堡垒机打开(外部连接)」)。它先发 Ctrl-C,打断终端里正在运行的程序、清掉输入行里没提交的内容,再输入一行探测和一行命令;这两行和命令的输出都显示在终端里。命令在终端当前的用户、目录和机器上执行(`su`、`sudo -i`、嵌套 `ssh` 之后也是),`--json` 的结果多一项 `context`(`user`、`host`、`cwd`、`shell`、`interp`)写明在哪里。命令的输出经管道,程序看不到终端:没有颜色和分页器,stderr 并进 stdout;命令没有标准输入,要密码的程序会停在终端里等。用哪个终端:右侧栏正对着的终端是这台主机的就用它,否则用这台主机最新的、已连接的终端。
- 只支持 Linux 上的 bash、dash 和 busybox ash(否则报 `unsupported_shell`)。编码后的命令行,bash 和 dash 最多 4000 字节,busybox 最多 1000 字节(否则报 `too_long`)。
- 以下情况报 `terminal_busy`,命令没有执行:全屏程序(vim、less、top、tmux 等)占着终端;上一条 `--terminal` 命令还没结束;5 秒内 shell 没有回应(前台程序不理 Ctrl-C,如 python、mysql);15 秒内 shell 没有开始执行(可能被堡垒机的命令过滤或复核拦下,这时会再发一次 Ctrl-C)。终端没打开或没连接报 `no_terminal`。
- 执行过程中没有时限;命令行工具中途被结束时(如 Agent 超时),终端里的命令收到 Ctrl-C。
- 那两行行首有空格,bash 设了 `ignorespace` 时不记进历史,否则会记下。停在不带提示的 `read -s`、或 vi 编辑模式的命令模式时认不出,会报「没有回应」。
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
- `sync` 只从本机到主机:把本地目录里的内容同步进远程目录。远程目录不存在就新建(父目录必须存在),是文件就报错,不能是根目录 `/`。大小和修改时间都没变的文件、指向不变的链接跳过,摘要里计为「未变」,所以再同步一次只传改过的;其余文件直接覆盖,新文件保留可执行权限。`--delete` 先删掉远程目录里本地没有的、或类型不同的项(不跟随链接),但不删传输进行中留下的 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录也不清理;删不掉的项计为失败。
- `hosts` 和 `credentials` 管理保存的主机和凭据。`create`、`update` 从标准输入读一个 JSON 对象(`update` 只改给出的字段),字段见 `shellrs hosts create --help` 和 `shellrs credentials create --help`;`show --json` 的输出可以改了直接交回 `update`。校验和对话框一样,报错用对话框的说法:
- 主机:`name`、`host`(地址)、`port`(默认 22)、`user`(默认 root)、`group`(分组路径如 `生产/数据库`,不存在就逐级新建;同一路径有不止一个分组时报错)、`auth`(`password` / `credential` / `no_password`)、`credential`(凭据 ID)、`password`、`route`(直接连接、按 ID 列出的跳板主机,或 HTTP / SOCKS5 代理)、`notes`。跳板只能是保存的主机,不能是自己。改了地址、端口或用户而没给密码时,保存的密码跟着搬过去,同对话框。
- 凭据:`name`、`kind`(`password` / `key` / `agent`)、`user`、`password`、`key_path`(本机私钥文件)或 `private_key`(私钥全文,同对话框的「粘贴」,存进数据目录的 `keys/`)、`passphrase`。凭据的 ID 是 16 位随机串,`credentials list` 里有。
- 密码、口令和私钥只写不读:写进系统钥匙串或 `keys/`,任何命令都不输出,`show` 只说有没有保存;ShellRS 保存的私钥连路径也不显示。
- 临时连接和外部连接能 `show`,不能修改或删除。
- `hosts delete` 不确认,连同主机的端口转发规则一起删;主机在 ShellRS 里有打开的标签时拒绝(`host_in_use`),加 `--force` 才关掉标签再删。经它跳转的主机,那一跳变成「已删除的主机」。`credentials delete` 也不确认,用它的主机改为自己登录(同界面)。
- 改动由 ShellRS 一条一条执行:10 秒内没轮到就撤回、不做;开始执行后会等到完成。
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令),以及 `exec --terminal` 才有的 `no_terminal`、`terminal_busy`、`unsupported_shell`、`too_long`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
- `exec --json` 从标准输入读 `{"host": "<ID>", "command": "<命令>"}`,命令结束后输出 `{"exit_code", "stdout", "stderr"}`,出错时输出 `{"error": {"code", "message"}}`,退出码和不加 `--json` 时一样。输出只含 ASCII,中文等字符都转成 `\uXXXX`;不是 UTF-8 的输出换成 U+FFFD。命令不经过本机 shell 的引号处理,控制台的代码页也不会把输出弄乱,适合 Windows PowerShell 和需要分开读标准输出、标准错误的 Agent。
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里执行命令优先用 `exec --json`(`@{ host = '<ID>'; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`);要边执行边看输出时(长时间构建、大量日志),改用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`),并和 `list`、`upload`、`download` 一样,先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`,否则中文会变成问号或乱码。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
## 从堡垒机打开(外部连接)
JumpServer 等堡垒机的本地客户端可以像调用 Xshell 一样调用 ShellRS 打开终端,也可以像调用 WinSCP 一样调用它打开 SFTP:把 ShellRS 设成 SSH 客户端或 SFTP 客户端,参数照 Xshell 或 WinSCP 的写法填。ShellRS 收到 `ssh://` 链接就打开一个终端标签连过去,收到 `sftp://` 链接就打开一个 SFTP 标签:
```sh
ShellRS ssh://用户[:密码]@地址[:端口]
ShellRS -url ssh://用户[:密码]@地址[:端口] -newtab 标签名
ShellRS sftp://用户[:密码]@地址[:端口]
ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
```
- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),以及 WinSCP 的 `/sessionname=标签名`(也可以写成 `-sessionname=`),不分大小写;`/newinstance`、`/ini=nul` 等别的选项忽略。只支持 `ssh://` 和 `sftp://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。`sftp://` 链接里的路径(WinSCP 用它指定打开哪个目录)目前不认,远程一侧照常打开主目录。
- **标签占满窗口**:打开外部连接时,左侧的主机列表自动收起(同 ⌘B),按 ⌘B(其他平台 Ctrl+B)或双击标签即可展开。
- **外部连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。
- **只用一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话。SFTP 标签本来就只开一个;终端则不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。
- 链接里的密码只留在内存里,不进钥匙串,重新连接时照样用它;没带密码时,服务器要密码就弹框问。第一次连到某台堡垒机要信任它的主机密钥,记在 ShellRS 自己的 `known_hosts` 里,下次不再问。
- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://` 或 `sftp://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。
- 堡垒机要调用的程序:
- Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。
- macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。
- Linux:AppImage 本身。
- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝,这时改用 `shellrs exec --terminal`,在外部连接已打开的终端里执行命令(见「外部 CLI」)。
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
## 标签页
远程终端标签和 SFTP 标签都以主机系统徽章为图标(与主机列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为主机的名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑主机,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「主机的名称 · SFTP」)、打开 SFTP、复制 IP 地址 / 主机名、重新连接加关闭命令,标签栏右侧和远程终端一样有「SFTP」和「重新连接」按钮;本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。双击任一标签的标题可隐藏或显示左侧的侧栏(同 ⌘B 和标题栏的按钮)。远程终端运行时和 SFTP 标签已连接时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。SFTP 传输进行中也照常测量,读数会随传输占满带宽而升高。批量关闭时,正在上传的 SFTP 标签仍会先确认。
## 终端
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。「设置 › 终端 › 终端交互」可以把右键改成直接粘贴或不做任何事,也可以打开「选中复制」。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
- **回滚历史**:每个终端保留最近 10000 行。用滚轮或触控板往上翻看,这时右侧出现滚动条(和应用里其他列表一样,按系统的「显示滚动条」设置),可以拖动,点轨道直接跳到那个位置。vim、less、top 这类全屏程序没有回滚历史,不显示滚动条。
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。
- **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键从不交给程序,按「右键行为」的设置处理。
- **通知**:程序可以请求通知(OSC 9,或 OSC 777 的 `notify;标题;正文`),终端响铃时也会提醒。ShellRS 不在前台时发系统通知(macOS 第一次会询问是否允许),点通知回到那个终端;ShellRS 在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。程序请求的通知即使终端就在眼前也会提示;响铃只在看不见那个终端时提醒(Tab 补全等也会响铃)。通知标题是标签名,看得出是哪台主机发的。同一终端的程序通知 2 秒内、响铃 10 秒内只提醒一次。设置 › 终端 › 通知可以分别关掉。命令跑完想被提醒,可以在后面加上 `; printf '\e]9;完成\a'` 或 `; tput bel`;Claude Code 等命令行工具需要你处理时也会这样提醒。
- **关键字高亮**:设置 › 关键字高亮 里的规则把匹配的文字按规则的颜色显示在所有终端里,回滚历史里的也一样。只改显示:服务器的输出不变,复制出的文字也不变;窗口缩放、长行折行后照样匹配,被折成两行的词两段都上色。这个功能默认关闭,打开页面上方的「启用关键字高亮」后才生效,关掉它就一次停用全部规则;「预览」用当前终端的字体和配色显示一段示例输出,规则改动立即反映在预览和已打开的终端里。规则表一行一条,直接在表里改:「启用」、「正则表达式」(区分大小写,开头加 `(?i)` 不区分)、「备注」、颜色(点色块用取色器,或在旁边填 `#rrggbb`)、「通知」,行尾的垃圾桶删除这一条。正则写错时在框下方提示,这条规则暂不生效。规则从上到下应用,重叠处前面的规则优先;按住行首的把手拖动可调整顺序。初始有三条示例:`ERROR` 红色、`WARN` 琥珀色、IPv4 地址蓝色,都已启用、不通知,打开总开关就能看到效果,可以修改或删除。勾选「通知」的规则在看不到那个终端时提醒,和响铃一样:ShellRS 不在前台时发系统通知,终端在别的标签时在窗口右上角提示;标题是「标签名:备注」(没填备注时用正则),正文是匹配的那一行。只检查换了行的新输出,同一终端 10 秒内只提醒一次,反复重画的同一行 5 分钟内只提醒一次。vim、less、top、tmux 这类全屏程序里不高亮,也不提醒。**尚未实现:按主机区分规则、加粗等文字样式、用键盘调整顺序。**
- **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
- **粘贴图片**:本地终端和已保存主机的远程终端里,剪贴板是截图(没有文字)时,⌘V(其他平台 Ctrl+Shift+V)或右键粘贴会把图存成文件,再把路径粘进输入行,给终端里的 Agent 去读。本地写到系统临时目录;已保存的主机经 SFTP 写到远端 `/tmp`。剪贴板里有文字时仍粘文字。外部连接和临时连接不支持。
## 右侧栏
当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。把标签拖成分屏后,右侧栏不隐藏:它对应最近一次切到或点进去的远程终端,点进另一半的 SFTP 或本地终端时不变,屏幕上一个远程终端都没有时才隐藏;标题里写着它对应哪台主机(如「系统监控 · web-01」),状态栏也跟着点进去的那一半。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统服务、进程管理、网络连接和系统监控的图标;历史命令和 Docker 除了 Windows 主机都有(Mac 上装了 Docker Desktop 也能用)。
**命令片段**保存常用的命令,所有主机共用。点顶部的加号新建片段:名称、分类和命令,命令可以有多行。片段按分类分组显示,没有分类的放在最后的「未分类」下;点分类标题折叠或展开,右键分类标题可以在这个分类里新建片段、重命名或删除分类(分类里的片段会一起删除,确认时写明个数);顶部的文件夹按钮新建分类。可以按名称或命令搜索,输入几个词时列出同时包含这些词的片段。
点一个片段就在终端里执行它的命令(片段默认「点击时自动执行」)。鼠标移到片段上时,右边会显示点它会怎样:闪电表示点击就执行,▶ 表示点击只输入。在片段对话框里取消勾选「点击时自动执行」,点它就只把命令放进终端的输入行(替换掉已经输入的内容),可以修改后回车。点右边的闪电或 ▶ 总是直接执行;右键还可以输入到终端、复制命令、编辑和删除。终端里正在运行 vim、less 这类全屏程序时不会输入。Windows 主机上不会替换已经输入的内容,命令接在后面输入。
**历史命令**列出远程主机上 bash 的历史命令(`~/.bash_history`),最近执行的在最前,同一条命令只列一次,下面写着执行过几次;bash 记录了时间(设置了 `HISTTIMEFORMAT`)时还写明最近一次是什么时候,多行的命令也能认出来。可以搜索,输入几个词时列出同时包含这些词的命令,不分大小写。点一条命令把它放进终端的输入行(替换掉已经输入的内容,不执行),可以修改后回车;点右边的执行按钮直接执行;右键还可以复制。终端里正在运行 vim、less 这类全屏程序时不会输入。
bash 在 shell 退出时才把这次执行的命令写进历史文件,所以当前终端里刚执行的命令要等它退出后才会出现。列表在打开历史命令或切换终端时读取,之后点刷新按钮才重新读取;历史文件很长时只读最后 512 KB。
**Docker** 顶部是 Docker 和 Compose 的版本,下面「容器」「卷」「镜像」「网络」四个标签页带数量。「容器」页里,同一个 compose 项目的容器归在项目卡片下(项目名、运行中 / 总数、项目目录),点项目那一行折叠或展开,只有一个容器、而且它在运行的项目默认展开,有多个容器的项目默认折叠;不属于任何项目的在「独立容器」下。每个项目和容器右边有「停止」(或「启动」)和「重启」,项目的按钮作用于它所有的容器;点容器或它的「…」打开详情:「详情」页是名称、ID、镜像、创建时间、入口、命令、端口映射、挂载、环境变量和标签,「日志」页是容器最近 200 行输出;底部可以启动、停止、重启,已停止的容器还可以删除。「卷」「镜像」「网络」页列出各自的名称和基本信息,标出是否有容器在用;没有容器在用的可以点右边的删除按钮删除(Docker 自带的 bridge、host、none 网络不能删)。点卡片打开详情:卷是驱动、挂载点、创建时间、使用它的容器、选项和标签;镜像是完整 ID、标签、摘要、创建时间、大小、平台、层数、使用它的容器、运行配置(入口、命令、工作目录、用户、暴露端口)、环境变量和标签;网络是驱动、范围、IPv6、子网和网关、连在上面的容器及其地址、选项和标签。详情底部也可以删除。
停止、重启和删除都会先确认,启动直接执行,结果以通知显示,之后列表重新读取。不是以 root 登录、也不在 docker 组里时,通过免密码的 sudo 执行。列表在打开 Docker 或切换终端时读取,之后点刷新按钮才重新读取。主机没有安装 Docker 或 Docker 没有运行时会写明。
**系统服务**列出远程主机上的 systemd 服务(不含模板),分成「自定义服务」(unit 文件在 `/etc/systemd/system`,也就是管理员自己加的)和「系统服务」两组。顶部是 systemd 版本和整机状态(有服务失败时是「降级运行」);「全部」「运行中」「已停止」「失败」四个标签页带数量,也可以按服务名或描述搜索。每个服务一张卡片:状态圆点、名称、描述、运行状态和开机启动状态;运行中的服务右边有「停止」「重启」,没运行的有「启动」,点「…」或卡片打开详情。详情的「状态」页是加载状态、运行状态、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动和停止时间、unit 文件;「日志」页是这个服务在 journal 里最近 200 行。详情底部和右键菜单里同样可以启动、停止、重启,以及启用或禁用开机启动。
停止和重启会先确认(停止 SSH 服务时会特别说明),启动和开机启动的设置直接执行,结果以通知显示,之后列表重新读取。这些操作需要 root 权限:不是以 root 登录时通过免密码的 sudo 执行,sudo 需要密码时会提示需要 root 权限。列表在打开系统服务或切换终端时读取,之后点刷新按钮才重新读取。没有使用 systemd 的主机会写明暂不支持。
**进程管理**列出远程 Linux 主机上的进程(不含内核线程),每个一张卡片:状态圆点(绿色运行中、灰色休眠、黄色等待 I/O 或已暂停、红色僵尸进程,鼠标停上去显示状态)、进程名、PID、用户、启动时间(按本机时区),以及内存用量和占物理内存的比例、CPU 占用。CPU 占用和 `top` 的算法一样,按一个核计:占满两个核是 200%;它要两次读数才算得出,所以刚打开时先显示「—」,2 秒后出现。默认按内存从多到少排列,点「CPU」改按 CPU 排列,再点一次同一个按钮反过来排;可以按进程名、PID 或用户搜索,右边的数字是当前列出的个数。每 15 秒刷新一次,只在进程管理显示时读取,点右上角的刷新按钮立即读取。
点一个进程打开它的详情:PID、父进程、用户、状态(括号里是 `ps` 的状态字母,如 `Ssl`)、启动时间、运行时长、终端、优先级、Nice、CPU 使用率、累计 CPU 时间、常驻内存、虚拟内存、线程数、直接子进程数和全部后代进程数,下面是完整的命令行(打开详情时读取,可「复制命令」)。底部有「复制 PID」「结束进程」「强制结束」。
右键一个进程可以「查看详情」「结束进程…」(发送 SIGTERM,进程可以先做完收尾再退出)或「强制结束进程…」(发送 SIGKILL,立即终止);从详情里结束也一样,都会先确认。结果以通知显示,1 秒后列表重新读取。不是以 root 登录时只能结束自己的进程。进程超过 2500 个时只列出前 2500 个,列表顶部会写明。
**网络连接**列出远程 Linux 主机上所有的 TCP、UDP 连接和监听端口,每条一张卡片:协议、状态、本地和远端地址,占用它的进程和 PID,以及所属用户。右上角写明这是「监听端口」「入站连接」(连到本机监听的端口)还是「出站连接」。顶部是总数、监听端口数和已连接数;可以按地址、端口、状态、PID、进程名或用户搜索,也可以按协议和状态筛选。监听的排在最前,然后是已连接的,TIME_WAIT 等正在关闭的排在最后;同一个端口被多个进程共同监听(如 nginx 的多个 worker)时合成一张卡片。
列表在打开网络连接或切换终端时读取一次,之后不会自己变化,点右上角的刷新按钮重新读取。数据通过这个终端已有的 SSH 连接读取,不需要再次登录,用的是主机上的 `ss` 命令;没有 `ss` 的主机(如 BusyBox)改为读取 `/proc/net`,这时看不到进程;不是以 root 登录时只能看到自己的进程。这两种情况列表顶部都会写明。连接超过 3000 条时只列出前 3000 条(监听端口总在其中),总数仍然准确。
**系统监控**显示远程 Linux 主机的:
- **系统**:主机名、架构、发行版、运行时长(如「32 天 23:40:26」)。
- **CPU**:型号、核数和平均使用率;多核时下面每核一根小竖条,鼠标停在竖条上显示这个核的编号和使用率。默认只显示一行,核多到一行放不下时点右上角的「N 核」展开全部。
- **内存**:物理内存的使用率和用量;有交换空间时另有一行。
- **网络**:主网卡(默认路由所在的网卡)的上传、下载速率;有多块网卡时点右上角的「N 个网卡」展开全部(不列回环网卡和从没收发过数据的网卡)。
- **磁盘**:每个本地挂载点的占用(不含 tmpfs 等内存文件系统和系统目录下的挂载)。
数据通过这个终端已有的 SSH 连接读取,不需要再次登录,也不安装任何东西;每 2 秒刷新一次(磁盘每 30 秒一次),只在系统监控显示时读取。每次刷新只读取几个系统文件,除了执行它的 shell 不在服务器上启动其他进程。使用率达到 70% 显示为黄色,达到 90% 显示为红色。终端未连接时显示提示;还没识别出系统的主机如果不是 Linux,会写明暂不支持。
## SFTP
打开主机的「SFTP」标签即可浏览本地与远程目录;和终端一样,每次「打开 SFTP」都新开一个标签,同一台主机可以开多个,各自独立连接、各看各的目录。操作方式对标 WinSCP Commander。SFTP 使用独立 SSH 连接,仅请求 SFTP 子系统,不要求服务器提供 Shell;关闭终端不会中止传输。
### 工具栏
每一侧面板都有两行工具栏,下方是路径标签。面板太窄放不下时,按钮从右往左整个隐藏,不会换行(对应的命令在右键菜单和快捷键里仍然可用)。新开的 SFTP 标签本地、远程两侧各占一半;两侧和队列之间的分隔线拖到哪里,在这个标签关闭之前都会记住,切到别的标签再回来也不变:
- 第一行(导航):
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,Windows 上还有各个驱动器(如「C: 本地磁盘」「D: 本地磁盘」),选中即前往。
- 书签:点按钮打开「打开目录/书签」(见下);旁边的下拉箭头列出这台主机这一侧的书签,点击即前往,也可添加或移除当前路径。书签保存在本地数据库,删除主机时一并删除。
- 上级目录、根目录、主目录、刷新、后退、前进。
- 显示 / 不显示隐藏文件:名称以 `.` 开头的文件和目录。睁开的眼睛表示正在显示,划掉的眼睛表示不显示。本地和远程各自一个开关,互不影响;每一侧的选择对所有 SFTP 标签生效,保存在设置里,默认都不显示。不显示时面板底部写明「隐藏 N 项」;显示时隐藏文件的文字颜色浅一些。
- 第二行(文件):
- 远程是「下载…」,本地是「上传…」,下拉里还有「选择文件上传…」。
- 删除、重命名…、属性…、新建(文件夹… / 文件…)。
### 路径标签
与 WinSCP 的路径标签相同:
- 显示当前目录的完整路径,例如 `/home/tester/`。
- 每一级都可以点:悬停时从根到该级高亮,单击即前往。
- 路径放不下时保留根目录和末尾几级,中间折成 `…`,点 `…` 前往被折叠的最后一级。
- 单击当前目录、双击路径右侧空白处打开「打开目录/书签」。
- 右键:前往、刷新、添加路径到书签、复制路径、打开目录/书签…。
### 打开目录/书签
与 WinSCP 的「打开目录」对话框相同,可以从路径标签、书签按钮、⌘O(其他平台 Ctrl+O;macOS 另有 ⌘⇧G)或路径标签的右键菜单打开:
- 「目录」预填当前目录并已选中,直接输入即可替换。`~` 表示主目录,相对路径从当前目录算起。本地侧另有「浏览…」。
- 下方是这台主机这一侧的书签。与目录相同的书签处于选中状态;单击书签把它填入目录,双击直接打开。
- 「添加」把目录加入书签,「删除」(或在列表中按 Delete)删除选中的书签并选中相邻的一个,「上移」「下移」调整顺序。
- 书签的改动立即保存,按「取消」也不会撤销,与 WinSCP 相同。
- 回车或「打开」前往目录;Esc 或「取消」关闭。
列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 主机的名称」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单;断开时在远程列表里做任何操作(刷新、打开目录、删除、上传下载等)会弹出「SFTP 连接已断开」对话框,说明原因,并可直接「重新连接」。这些都不会让列表移动。
列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。点列标题(或它右边的箭头)按这一列排序,依次是降序、升序、恢复按名称;目录始终排在文件前面。换目录、刷新后排序和拖动过的列宽都保持不变。
- 大小默认以 KB 显示(`4,008,960 KB`,向上取整,只有空文件是 0 KB),与 WinSCP 相同。右键「大小」列标题可切换为「字节」(`4,105,175,040 B`)、「千字节(KB)」或「简短格式」(按大小用 B、KB、MB、GB,如 `3.8 GB`),对所有 SFTP 标签生效并保存在设置里。按大小排序始终按实际字节数。
- 时间形如 `2026/4/22 12:44:53`。
- 权限形如 `rwxr-xr-x`。
- 所有者来自服务器的 `ls -l` 行。
- 链接到目录的符号链接显示为带箭头的文件夹,可以双击进入。
### 选择与右键菜单
- 与 WinSCP 关闭「整行选择」时相同,「名称」格才是项目:鼠标移上去时只有名称格变灰,单击、双击、右键都作用在名称格上,选中时整个名称格高亮;行里的其他列和列表下方都算空白处。
- 鼠标:单击名称格选中(其他选择取消),⌘ 单击(其他平台 Ctrl 单击)增减,Shift 单击选取范围,双击打开。单击空白处取消全部选择(按住 ⌘ 或 Shift 时不取消)。
- 框选:在名称右边的空白、大小、类型、时间等列或列表下方按下拖动,画出选框,框住的行被选中;按住 ⌘ 或 Shift 开始拖动则加到已有选择上。拖到列表上下边缘时自动滚动。拖动文件名才是把文件拖到另一侧传输:按下未选中的文件名时只选中它(其他选择取消),拖走的就是它一个;按下已选中的文件名则拖走全部已选。拖动时只有到了另一侧列表上才显示「上传 / 下载 N 个项目」,在自己的列表或其他不能放下的地方,指针显示为禁止。
- 键盘:↑↓、Home、End、PageUp、PageDown 移动,加 Shift 扩展;空格或 Insert 切换当前行;⌘A 全选;Tab 切到另一侧。
- 右键未选中的名称格会先选中它;右键空白处取消选择并弹出空白处菜单。
- 文件名上的菜单:编辑(文件;图片、SVG 和 Markdown 另有预览,SVG 以外的图片只有预览)、上传… / 下载…、复制路径(选中项目的完整路径,多个时每行一个)、删除、重命名…、属性…。目录双击进入,菜单里没有「打开」。
- 空白处的菜单:前往(上级 / 根 / 主目录 / 后退 / 前进)、刷新、显示隐藏文件(勾选表示这一侧显示)、添加路径到书签、新建。
### 快捷键
| 命令 | 快捷键 |
|---|---|
| 上传 / 下载(按所在面板) | F5 |
| 重命名 | F2 |
| 新建文件夹 | F7 |
| 删除 | F8、Delete;macOS 另有 ⌘⌫ |
| 属性 | F9;macOS 另有 ⌘I |
| 编辑文件 | F4 |
| 刷新 | ⌘R / Ctrl+R |
| 上级目录 | Backspace;macOS 另有 ⌘↑ |
| 根目录 | ⌘\ / Ctrl+\ |
| 主目录 | macOS ⌘⇧H,其他平台 Ctrl+H |
| 后退 / 前进 | ⌥← / ⌥→;macOS 另有 ⌘[ / ⌘] |
| 添加路径到书签 | ⌘D / Ctrl+D |
| 打开目录 / 编辑文件 | Enter;macOS 另有 ⌘↓ |
| 打开目录/书签 | ⌘O / Ctrl+O;macOS 另有 ⌘⇧G |
| 显示 / 不显示隐藏文件(按所在面板) | macOS ⌘⇧.,其他平台 Ctrl+Alt+H |
### 文件操作
- 删除前会确认,并写明对象和后果:
- 本地项目移到废纸篓。
- 远程项目永久删除,目录连同其中全部内容。
- 删除符号链接只删除链接本身。
- 重命名和新建:
- 按当前列表检查名称;不能为空,不能是 `.` 或 `..`,不能与已有项目同名。
- 重命名不会覆盖已有项目。
- 属性:
- 显示位置、大小、修改时间,远程另有所有者和组。
- 权限用 3×3 复选框和八进制数字编辑,两者同步。
- 多选时,项目之间不一致的权限位保持原样,除非你改动它。
- 含目录时可以递归应用;递归时默认「为目录添加执行权限(X)」,避免目录无法进入。
- 符号链接不参与修改权限。
### 编辑文件
文本文件可以直接在 ShellRS 里改,远程和本地的都行(与 WinSCP 的内置编辑器相同):
- 打开:双击文件、选中后回车或按 F4、右键「编辑」。「新建 › 文件…」建好后也直接打开。文件在中间区的「编辑器」标签里打开,和终端、SFTP 一样可以拖成分栏;同一个文件只开一个标签,再打开就切到它。
- 编辑器有行号、查找替换(⌘F / Ctrl+F,替换 ⌘⇧F / Ctrl+H),按文件名为 Shell、YAML、TOML、JSON、Python、JavaScript、HTML、CSS、Markdown、SQL、PHP、Go、Lua、Makefile、Diff 着色,其他文件是纯文本。Makefile 和用 Tab 缩进的文件按 Tab 输入制表符。
- 保存:⌘S / Ctrl+S 或标签上方的「保存」。远程文件经打开它的 SFTP 标签的连接直接写回原文件(原地改写),所有者、权限和指向它的链接都不变。保存前会核对文件的大小和修改时间:打开后被别人改过或删掉了,会先问是否「覆盖」,不会悄悄覆盖。写到一半连接断开时,服务器上的文件可能只写了一部分,编辑器里的内容还在,重新连接后再保存一次即可。
- 「重新加载」从服务器或磁盘重新读一遍;有没保存的修改时先确认。
- 标签上的圆点表示有没保存的修改。关闭这样的标签会问「保存」「放弃修改」还是「取消」;一次关闭多个标签、关闭 SFTP 标签(它打开的远程文件会一起关掉)或退出 ShellRS 时,有没保存的文件会先问一次。macOS 上从程序坞退出和注销关机不会询问。
- 窗口左下角是这个文件所走的 SFTP 连接(本地文件写「本机文件」),右下角是光标位置和编码、换行方式(如「UTF-8 · LF」)。
- 限制:只打开 5 MB 以内的 UTF-8 文本文件(带不带 BOM 都可以,保存时原样保留 BOM 和 CRLF 换行)。更大的文件、二进制文件和 GBK 等其他编码的文件会说明原因、不打开,远程文件可以改用「下载…」。
### 预览
图片(PNG、JPG、GIF、WebP、SVG、BMP、ICO、TIFF)和 Markdown 可以预览,远程和本地的都行:
- 打开:双击图片;Markdown 和 SVG 是文本,双击是编辑,预览用右键「预览」。
- 预览是一个占窗口大部分的对话框,Esc、右上角的关闭或「关闭」按钮关掉。Markdown 排版后显示,可以滚动、选中复制,点链接用浏览器打开。
- 图片默认「适合窗口」:按原比例缩小到放得下(小图不放大),居中显示;上方写像素尺寸、文件大小和当前比例。放大、缩小按钮(或 ⌘= / ⌘-,其他平台 Ctrl)按 10%–800% 的档位缩放,按住 ⌘(Ctrl)滚动滚轮或触控板可以连续缩放;「原图」(⌘0)按一个图片像素一个点显示,「适合窗口」(⌘9)回到放得下的大小;双击图片在两者之间切换。图片比预览区大时可以上下左右滚动查看,缩放时画面中间的位置保持不动。
- 下方的「下载…」把远程文件下载到本机(打开下载确认框),Markdown 的「编辑」打开编辑器(已打开就切过去)。
- 预览的是服务器或磁盘上的文件,编辑器里没保存的修改不会出现在预览里。
- 限制:图片 20 MB 以内,Markdown 5 MB 以内且是 UTF-8;更大的会说明原因,远程文件可以改用「下载…」。
### 上传与下载
- 入口:
- 上传:「上传…」、本地列表中的 F5、「选择文件上传…」,或按住本地项目的文件名拖到远程列表,或把 Finder 中的文件拖到远程列表。
- 下载:「下载…」、远程列表中的 F5,或按住远程项目的文件名拖到本地列表。
- 拖到目录行时放进该目录,拖到其余区域时放进当前目录。
- 所有入口都显示确认框,可以修改目标目录;下载可以用「浏览…」选择本机目录。确认框写明数量和种类(如「上传 2 个文件到 …」),来源的所在目录只写一次,每个项目占一行,悬停可看完整路径。
- 确认后来源、端点和目标即固定。每个 SFTP 标签同时运行一个批次(上传和下载共用),批内依次处理;传输进行中还可以继续上传或下载,新的批次排进传输队列,前一批结束后自动开始。其他 SFTP 标签、主机和终端可以继续操作。
- 递归传输包含空目录。符号链接原样创建,不跟随。默认二进制传输,保留普通文件的修改时间。
- 新建项使用目标一侧的默认权限,覆盖时保留目标原有的基本权限。不复制所有者、ACL 或扩展属性。
- 同名目录合并。文件或链接冲突时询问覆盖、跳过或取消,可以应用于本批剩余冲突;目录与非目录冲突不会自动删除。
- 出错时可以重试、跳过或取消,完成后的汇总分别列出成功、跳过和失败。
### 传输队列
与 WinSCP 的队列面板相同,有传输时出现在双栏下方,高度可拖动,清除全部已结束的批次后收起。
- 每一批一行:操作(上传 / 下载)、来源(一个项目时是它的路径,多个项目时是所在目录)、目标、已传输(单位与「大小」列相同)、时间(剩余时间)、速度(最近几秒)和进度。
- 进行中的批次显示总进度条,下面一行是正在传的文件和它自己的进度条,对应 WinSCP 进度对话框里的两个进度条。
- 进度列:等待中、正在扫描、百分比、等待回答、重连说明、已停止、已完成、部分失败(N 项)、已移出。
- 点行首的箭头展开这一批的逐项结果(完成、已跳过、失败及原因),最新的在最上面。
- 工具栏:继续(停止的批次)、停止(进行中的批次)、移出队列(选中的排队中或已结束的批次,或停止的批次)、丢弃续传进度、清除已完成。
- 停止的批次留在队首,等你继续、丢弃续传进度或移出队列之后,后面的批次才会开始;移出队列不删除续传记录,下次传同样的文件仍会询问是否续传。
- 队列只在本次运行中保留,关闭 SFTP 标签时一起清除;有未完成的批次时关闭标签会先确认。
## 断点续传和替换
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后,按路径设置修改时间(覆盖时还有原文件的权限),再发布正式文件。覆盖已有文件时,先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,最后删除备份;这样替换不是原子的,两次改名之间目标短暂不存在。和 WinSCP 一样,传输不用任何 SFTP 扩展(`fsync@openssh.com`、`posix-rename@openssh.com` 等),也不对打开的文件设置属性:有的堡垒机拒绝这些请求,甚至因此断开整个会话。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
续传记录保存在应用数据目录的 `upload-resume/`,采用版本化 JSON 和原子写入,不写入密码、口令或私钥,不改变数据库结构。记录用于绑定端点、主机指纹、来源、目标和安全替换阶段。续传方式与 WinSCP 相同:读取远端 `.filepart` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。
下载同样先写入本机的 `<目标文件名>.filepart`,完成后设置远程的修改时间、fsync,再在同一文件系统内重命名为正式文件(原子替换)。并发的读取请求可能乱序、短读返回,但临时文件严格按顺序写入,因为续传只信它的长度。下载续传记录保存在 `download-resume/`,绑定端点、主机指纹、远程来源及其大小与修改时间、本地目标;远程文件变化后不能续传,只能重新下载或跳过。
网络故障每批最多自动重连三次,间隔 1、3、10 秒。耗尽后使用「继续上传」或「继续下载」,手动恢复会重置次数。认证失败、主机密钥变化和主动取消需要用户处理。重连沿用原连接配置,编辑主机不会改变正在传输的目标。
取消、关闭标签、断开连接或退出时保留续传记录;关闭正在传输的标签会先确认。重启后不会自动继续,再次选择相同来源和目标时会询问续传。停止状态下可使用「丢弃续传进度」清理该批受管理的临时文件;存在无法判断的恢复备份时会保留文件并说明原因。成功发布的项目自动清理记录和备份。
文件名不能准确表示时会明确报错。当前 `russh-sftp 3.0.0` 的远端字符串解码会替换无效 UTF-8,浏览器因此拒绝包含替换字符 `U+FFFD` 的远端名称,避免访问错误路径。符号链接沿用 OpenSSH 的 SFTP v3 参数顺序。
**尚未实现:SFTP 标签里的目录同步(外部 CLI 的 `shellrs sync` 可以同步)、前台传输进度对话框、队列排序、过滤、查找文件、目录树和 Dock 布局持久化。**
## 下载与更新
安装包在 [GitHub Releases](https://github.com/since2006/shell-rs/releases):
| 系统 | 首次安装 | 自动更新 |
|---|---|---|
| macOS(Apple Silicon 和 Intel 共用一个包) | DMG,把 ShellRS 拖进「应用程序」 | 支持 |
| Windows x64 | 安装程序,按当前用户安装到 `%LOCALAPPDATA%\Programs\ShellRS`,不需要管理员 | 支持 |
| Linux x64 | AppImage | 支持 |
ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现绿色的更新按钮。点开后,「查看更新内容」打开官网的更新日志,「重启并安装」换成新版本并重新打开。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。
「设置 › 关于」里的「应用更新」只有三行:
- **当前版本**:下面写着现在的状态(已是最新、正在下载、已下载等),右边是版本号和一个按钮:平时是「检查更新」,下载好之后变成「重启并安装…」。构建号和平台在版本号的悬停提示里。
- **更新渠道**:稳定版适合日常使用;Beta 可以提前收到测试版本,稳定版发布时也会收到。切换后马上按新渠道检查(关掉自动升级时等你手动检查)。
- **自动升级**:关掉之后只在手动检查时才联网,发现新版本时由你决定是否下载。
- **校验**:更新清单由 ShellRS 的发布密钥签名,安装包按清单里的大小和 SHA-256 核对,任何一步对不上都不会安装。macOS 上还会核对新版本的开发者签名。
- **隐私**:检查更新只发送 ShellRS 的版本号、操作系统和 CPU 架构,不发送任何能识别这台电脑或用户的信息。匿名使用统计见「隐私」一节。
- **不能自动更新的情况**:从 DMG 里直接运行、没有放进「应用程序」文件夹、对安装位置没有写权限、Linux 上不是用 AppImage 运行,或者是自己编译的开发构建。这时「关于」页会说明原因,有新版本时给出「前往下载页」。
- **退回旧版本**:数据库在升级到新结构之前会备份成 `shellrs.db.v<旧版本>.bak`。旧版本打开新结构的数据库时会拒绝打开,以免把新版本保存的数据弄乱;需要退回时,把备份改名回 `shellrs.db`。
## 隐私
ShellRS 不发送主机、凭据、会话内容、命令、文件名或路径,也不发送任何能识别这台电脑或用户的标识。联网的只有这两件事(连接主机本身除外):
- **检查更新**:只发送 ShellRS 的版本号、操作系统和 CPU 架构,见「下载与更新」。
- **匿名使用统计**:发给 [Aptabase](https://aptabase.com),用来了解安装量、每天有多少台设备在用、各版本的分布,以及哪些功能用得多。「设置 › 关于 › 隐私」里的「发送匿名使用统计」默认打开;关掉后不再发送,本机攒下的计数也一并清除。
每个统计事件都带 ShellRS 的版本号、操作系统的名称和版本(如 macOS 15.1、Windows 10.0.22631、Ubuntu 24.04)。事件只有四种:
| 事件 | 什么时候发 |
|---|---|
| `app_installed` | 第一次启动时发一次。数据目录里记着已经报过,换一个数据目录或删掉它会再报 |
| `app_started` | 每次启动 |
| `app_active` | 当天(UTC)第一次把窗口切到前台时;启动当天不再重复。macOS 上关了窗口、留在后台不算 |
| `usage` | 各功能的使用次数:从第一次使用算起满 4 小时,在下一次启动、切到前台或使用时汇总成一条 |
前三种附带这些设置和数量,取值都是固定的几种:CPU 架构、应用外观、界面语言、浅色和深色主题(内置主题的名字)、界面是否跟随主题、关键字高亮、外部 CLI 和自动升级是否打开、更新渠道、是否改过快捷键,以及保存的主机、凭据、端口转发、命令片段各有多少(只报档位:0、1-5、6-20、21-100、100+)。
`usage` 只有次数,没有内容,计的是:SSH 终端连上(另按跳板、代理、凭据、无密码、临时连接、外部连接分别计数)、SFTP 连上(另按临时连接、外部连接计数)、本地终端、端口转发开启(本地、远程、动态)、上传和下载批次、编辑器打开本地和远程文件、预览图片和 Markdown、打开右侧栏的七个工具、命令片段和历史命令送进终端、终端查找、通知(程序、响铃、关键字),外部 CLI 的 `exec`、`exec --terminal`、`upload`、`download`、`sync`、`hosts`、`credentials` 命令,以及打开和关闭关键字高亮、外部 CLI。
- 计数先存在数据目录的 `analytics.json` 里,退出不会丢,之后再补发。离线或在内网里不影响使用:发不出去时等 1 分钟,之后每次加倍、最长 6 小时再试,而且只在使用 ShellRS 时顺带重试,空闲时不发任何请求。
- Aptabase 按请求的 IP 地址推断国家和地区,但不保存 IP。它区分设备用的是 IP 和 User-Agent 加上每天更换的随机盐算出的散列,所以看不到同一台设备跨天的行为。
- 自己编译的开发构建不发送统计;外部 CLI(`shellrs` 命令)本身也不发送。
+23
View File
@@ -0,0 +1,23 @@
# 发布
[返回 README](../README.zh-CN.md)
在 `CHANGELOG.md` 里写好 `## [x.y.z]` 一节,把 `Cargo.toml` 的版本改成 `x.y.z`,提交后推送 tag `vx.y.z`。`.github/workflows/release.yml` 依次:
1. 核对 tag 与 `Cargo.toml` 一致,截出更新说明,确认代码里有发布公钥;
2. 构建并打包:macOS 按 `aarch64`(Apple Silicon)和 `x86_64`(Intel)分别构建、签名、公证、staple,各自产出升级用的 `.app.zip` 和首次安装用的 `.dmg`;Windows 用 Inno Setup 打安装程序;Linux 打 AppImage;
3. 在 `release` 环境里人工批准后:给每个包签名,上传到 R2(`dl.shellrs.com/releases/<版本>/`),建 GitHub Release,**最后**才替换更新清单 `dl.shellrs.com/update/v1/<通道>.json` 并清 CDN 缓存,然后调用官网的部署挂钩重新构建 shellrs.com(官网的下载页和更新日志在构建时读这份清单)。清单里每个包有两个下载地址:先 R2,下载失败时客户端接着试 GitHub Release(仓库是公开的,下载不用登录)。
版本号带 `-` 的(如 `0.3.0-beta.1`)只发到 beta 通道;正式版同时成为 beta 通道的最新版。在 Actions 里手动运行这个 workflow 只构建打包、不发布,用来演练。客户端只接受比自己新的版本,所以发错的版本撤不回来:把清单改回上一版能阻止更多人升级,修复要发新的补丁版。
一次性准备:
- **更新签名密钥**:`minisign -G -p shellrs-update.pub -s shellrs-update.key` 生成两对(一对日常用,一对离线保存备用),把两个公钥(`.pub` 文件的第二行)填进 `src/update/build_info.rs` 的 `TRUSTED_KEYS`。私钥和口令只放进 GitHub 的 `release` 环境,另外离线备份;丢了私钥,已发出去的 ShellRS 就再也收不到更新。换钥匙时先发一版带上新公钥,再换签名用的私钥。
- **Cloudflare**:shellrs.com 的 DNS 托管在 Cloudflare;建 R2 bucket,绑定自定义域 `dl.shellrs.com`,关闭 `r2.dev` 地址;Cache Rules 让 `/releases/*` 和 `/update/*` 按源站的 Cache-Control 缓存(默认不缓存 `.json`、`.AppImage`);一个只能写这个 bucket 的 R2 API Token,一个只能清这个 zone 缓存的 API Token;官网项目(独立的私有仓库,由 Cloudflare 连着仓库构建)在「设置 › 构建 › 部署挂钩」里建一个构建 `main` 分支的挂钩。
- **Apple**:Developer ID Application 证书(导出 .p12)和 notarytool 用的 App Store Connect API Key。`packaging/macos/Info.plist.in` 里的 bundle id `com.shellrs.ShellRS` 和签名的 Team ID 一经发布就不能再改:钥匙串按它们授权,改了之后每条保存的密码都要重新允许。
- **图标**:`assets/logo/` 里的 `shellrs.icns`(macOS)、`shellrs.png`(1024×1024,Linux,macOS 没有 icns 时也由它生成)和 `shellrs.ico`(Windows 安装程序)。
- **GitHub secrets**:仓库级的 `APPLE_CERTIFICATE_P12`(base64)、`APPLE_CERTIFICATE_PASSWORD`、`APPLE_SIGNING_IDENTITY`、`APPLE_TEAM_ID`、`APPLE_API_KEY_P8`(base64)、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`;`release` 环境(设为需要审批)里的 `MINISIGN_SECRET_KEY`、`MINISIGN_PASSWORD`、`R2_ACCOUNT_ID`、`R2_ACCESS_KEY_ID`、`R2_SECRET_ACCESS_KEY`、`R2_BUCKET`、`CF_ZONE_ID`、`CF_API_TOKEN`、`SITE_DEPLOY_HOOK`(官网部署挂钩的地址,拿到它的人能触发官网重新构建,泄露了就删掉重建)。
清单格式见 `packaging/manifest.example.json`,由 `packaging/make-manifest.sh` 生成:一个文件里是清单原文和它的 minisign 签名,签名的 trusted comment 写明通道和版本。格式只增加字段、不改已有的;已发出去的 ShellRS 一直按 `/update/v1/` 这个地址检查。
macOS 产物名分别为 `ShellRS-<版本>-macos-aarch64.{app.zip,dmg}` 和 `ShellRS-<版本>-macos-x86_64.{app.zip,dmg}`。更新清单保留原有的两个平台键,分别记录对应架构包的地址、大小和 SHA-256;已有 universal 客户端按当前运行的架构升级。Apple Silicon 上通过 Rosetta 运行 Intel 版时仍会收到 Intel 包,需要手动安装 ARM 版才能切换。官网在独立仓库中,下载页也需提供 Apple Silicon / Intel 两个入口,读取清单中各自的 installer 地址。
Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

+114
View File
@@ -0,0 +1,114 @@
_version: 2
app.database_unavailable:
zh-CN: 无法打开本地数据库,本次运行的改动不会被保存:%{error}
en: "Couldn’t open the local database, so changes last only until ShellRS quits: %{error}"
# 设置 › 键盘快捷键: each shortcut by its id, in the order the page lists them.
app.shortcut.new_host:
zh-CN: 新建主机
en: New host
app.shortcut.new_group:
zh-CN: 新建分组
en: New group
app.shortcut.open_settings:
zh-CN: 打开设置
en: Open settings
app.shortcut.toggle_sidebar:
zh-CN: 显示或隐藏侧栏
en: Show or hide sidebar
app.shortcut.toggle_tool_sidebar:
zh-CN: 显示或隐藏右侧栏
en: Show or hide right sidebar
app.shortcut.focus_search:
zh-CN: 聚焦搜索
en: Focus search
app.shortcut.zoom_in:
zh-CN: 放大界面
en: Zoom in
app.shortcut.zoom_out:
zh-CN: 缩小界面
en: Zoom out
app.shortcut.zoom_reset:
zh-CN: 界面实际大小
en: Actual size
app.shortcut.quit:
zh-CN: 退出
en: Quit
app.shortcut.new_local_terminal:
zh-CN: 新建本地终端
en: New local terminal
app.shortcut.close_tab:
zh-CN: 关闭标签
en: Close tab
app.shortcut.next_tab:
zh-CN: 下一个标签
en: Next tab
app.shortcut.previous_tab:
zh-CN: 上一个标签
en: Previous tab
app.shortcut.terminal_copy:
zh-CN: 复制
en: Copy
app.shortcut.terminal_paste:
zh-CN: 粘贴
en: Paste
app.shortcut.terminal_find:
zh-CN: 查找
en: Find
app.shortcut.terminal_find_next:
zh-CN: 查找下一个
en: Find next
app.shortcut.terminal_find_previous:
zh-CN: 查找上一个
en: Find previous
app.shortcut.terminal_clear:
zh-CN: 清屏
en: Clear screen
app.shortcut.terminal_zoom_in:
zh-CN: 放大字号
en: Bigger text
app.shortcut.terminal_zoom_out:
zh-CN: 缩小字号
en: Smaller text
app.shortcut.terminal_zoom_reset:
zh-CN: 默认字号
en: Default text size
app.shortcut.editor_save:
zh-CN: 保存
en: Save
app.shortcut.switch_to_tab:
zh-CN: 切换到标签 1…9
en: Switch to tab 1…9
app.shortcut_group.general:
zh-CN: 通用
en: General
app.shortcut_group.tabs:
zh-CN: 标签
en: Tabs
app.shortcut_group.terminal:
zh-CN: 终端
en: Terminal
app.shortcut_group.editor:
zh-CN: 编辑器
en: Editor
app.shortcut_refusal.needs_modifier_mac:
zh-CN: 快捷键要带 ⌘、⌥ 或 ⌃,或者使用 F1–F12,或者 Shift 加 Insert、Delete、Home、End、Page Up、Page Down。
en: A shortcut needs ⌘, ⌥ or ⌃, or one of F1–F12, or Shift with Insert, Delete, Home, End, Page Up or Page Down.
app.shortcut_refusal.needs_modifier:
zh-CN: 快捷键要带 Ctrl 或 Alt,或者使用 F1–F12,或者 Shift 加 Insert、Delete、Home、End、Page Up、Page Down。
en: A shortcut needs Ctrl or Alt, or one of F1–F12, or Shift with Insert, Delete, Home, End, Page Up or Page Down.
app.shortcut_refusal.needs_digit_mac:
zh-CN: 这一项要按带修饰键的数字 1,例如 ⌘1。
en: Press the digit 1 with a modifier, such as ⌘1.
app.shortcut_refusal.needs_digit:
zh-CN: 这一项要按带修饰键的数字 1,例如 Alt+1。
en: Press the digit 1 with a modifier, such as Alt+1.
app.shortcut_refusal.used_by:
zh-CN: "%{keys} 已用于「%{label}」。"
en: "%{keys} is already used by “%{label}”."
app.shortcut_refusal.reserved:
zh-CN: "%{keys} 已被输入框等内置功能使用。"
en: "%{keys} is used by text fields and other built-in features."
+432
View File
@@ -0,0 +1,432 @@
_version: 2
# 外部 CLI: what the `shellrs` command prints, and the messages the app
# sends back to it. CLI output uses plain ASCII quotes and apostrophes, so
# it copies into a shell unchanged; the texts shown in the app's window
# (the settings page, notifications) use “ ” and ’ like the rest of the UI.
# The command could not reach the app.
cli.client.not_running:
zh-CN: ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」
en: ShellRS isn't running. Open ShellRS and turn on Enable external CLI in Settings → External CLI.
cli.client.broken:
zh-CN: 与 ShellRS 的连接意外中断:%{error}
en: "The connection to ShellRS broke off: %{error}"
cli.client.no_permission:
zh-CN: 没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信
en: No permission to connect to ShellRS. The agent may be running in a sandbox; allow it local interprocess communication.
cli.client.foreign_pipe:
zh-CN: 外部 CLI 的管道不属于当前用户,已拒绝连接
en: The external CLI's pipe belongs to another user, so the connection was refused.
cli.client.older_app:
zh-CN: 正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它
en: The running ShellRS doesn't know this command. Update ShellRS to the latest version, then restart it.
# Also the app's notification when the server cannot start.
cli.unsupported:
zh-CN: 此系统暂不支持外部 CLI
en: The external CLI is not supported on this system.
# Reading the command line and stdin.
cli.exec.empty:
zh-CN: 命令不能为空
en: The command is empty.
cli.exec.bad_json:
zh-CN: 标准输入不是 %{form} 形式的 JSON:%{error}
en: "The input on stdin isn't JSON of the form %{form}: %{error}"
cli.json.from_terminal:
zh-CN: 请从标准输入传入 JSON,例如 %{example}
en: "Pass the JSON on stdin, for example: %{example}"
cli.json.not_json:
zh-CN: 标准输入不是 JSON:%{error}
en: "The input on stdin isn't JSON: %{error}"
cli.json.invalid:
zh-CN: JSON 有误:%{error}
en: "The JSON is invalid: %{error}"
cli.stdin.unreadable:
zh-CN: 无法读取标准输入:%{error}
en: "Couldn't read stdin: %{error}"
cli.path.unresolvable:
zh-CN: 无法解析路径 %{path}:%{error}
en: "Couldn't resolve the path %{path}: %{error}"
# `hosts list` and `credentials list`: the column headers, upper case in
# English as command line tables have them.
cli.column.id:
zh-CN: ID
en: ID
cli.column.name:
zh-CN: 名称
en: NAME
cli.column.group:
zh-CN: 分组
en: GROUP
cli.column.address:
zh-CN: 地址
en: ADDRESS
cli.column.os:
zh-CN: 系统
en: SYSTEM
cli.column.kind:
zh-CN: 类型
en: TYPE
cli.column.user:
zh-CN: 用户名
en: USERNAME
cli.column.hosts:
zh-CN: 主机数
en: HOSTS
cli.hosts.none:
zh-CN: 没有匹配的主机
en: No matching hosts
cli.credentials.none:
zh-CN: 没有匹配的凭据
en: No matching credentials
# The group of a host connected to without saving.
cli.group.not_saved:
zh-CN: (未保存)
en: (not saved)
# `hosts show` and `credentials show`: the labels, then the values.
cli.field.id:
zh-CN: ID
en: ID
cli.field.name:
zh-CN: 名称
en: Name
cli.field.group:
zh-CN: 分组
en: Group
cli.field.address:
zh-CN: 地址
en: Address
cli.field.auth:
zh-CN: 认证
en: Authentication
cli.field.route:
zh-CN: 连接方式
en: Connection
cli.field.os:
zh-CN: 系统
en: System
cli.field.notes:
zh-CN: 备注
en: Notes
cli.field.kind:
zh-CN: 类型
en: Type
cli.field.user:
zh-CN: 用户名
en: Username
cli.field.private_key:
zh-CN: 私钥
en: Private key
cli.field.passphrase:
zh-CN: 口令
en: Passphrase
cli.field.hosts:
zh-CN: 主机
en: Hosts
cli.show.saved:
zh-CN: "%{label}(已保存)"
en: "%{label} (saved)"
cli.show.not_saved:
zh-CN: "%{label}(未保存)"
en: "%{label} (not saved)"
cli.auth.password:
zh-CN: 密码
en: Password
cli.auth.credential:
zh-CN: 凭据 %{id}
en: Credential %{id}
cli.auth.no_password:
zh-CN: 无密码
en: No password
cli.route.direct:
zh-CN: 直接连接
en: Direct
cli.route.jump:
zh-CN: SSH 跳板 %{hosts}
en: Jump hosts %{hosts}
cli.route.deleted_host:
zh-CN: (已删除的主机)
en: (deleted host)
cli.route.http:
zh-CN: HTTP 代理 %{address}
en: HTTP proxy %{address}
cli.route.socks5:
zh-CN: SOCKS5 代理 %{address}
en: SOCKS5 proxy %{address}
cli.route.password_saved:
zh-CN: "%{proxy}(密码已保存)"
en: "%{proxy} (password saved)"
cli.route.password_not_saved:
zh-CN: "%{proxy}(密码未保存)"
en: "%{proxy} (password not saved)"
cli.kind.password:
zh-CN: 密码
en: Password
cli.kind.key:
zh-CN: 密钥
en: Key
cli.kind.agent:
zh-CN: SSH Agent
en: SSH Agent
cli.credential.kept_key:
zh-CN: 由 ShellRS 保存
en: Kept by ShellRS
cli.credential.passphrase_saved:
zh-CN: 已保存
en: Saved
cli.credential.passphrase_not_saved:
zh-CN: 未保存
en: Not saved
# What `create`, `update` and `delete` did.
cli.host.created:
zh-CN: 已创建主机「%{name}」,ID %{id}
en: 'Created host "%{name}", ID %{id}'
cli.host.saved:
zh-CN: 已保存主机「%{name}」(ID %{id})
en: 'Saved host "%{name}" (ID %{id})'
cli.host.deleted:
zh-CN: 已删除主机「%{name}」
en: 'Deleted host "%{name}"'
cli.host.deleted_with_forwards.one:
en: 'Deleted host "%{name}" and its port forwarding rule'
cli.host.deleted_with_forwards.other:
zh-CN: 已删除主机「%{name}」,连同它的 %{count} 条端口转发规则
en: 'Deleted host "%{name}" and its %{count} port forwarding rules'
cli.host.deleted_jump_users.one:
en: "%{deleted}; 1 host jumped through it and now has a deleted jump host"
cli.host.deleted_jump_users.other:
zh-CN: "%{deleted};%{count} 台主机经它跳转,现在那一跳是「已删除的主机」"
en: "%{deleted}; %{count} hosts jumped through it and now have a deleted jump host"
cli.credential.created:
zh-CN: 已创建凭据「%{name}」,ID %{id}
en: 'Created credential "%{name}", ID %{id}'
cli.credential.saved:
zh-CN: 已保存凭据「%{name}」(ID %{id})
en: 'Saved credential "%{name}" (ID %{id})'
cli.credential.deleted:
zh-CN: 已删除凭据「%{name}」
en: 'Deleted credential "%{name}"'
cli.credential.deleted_released.one:
en: "%{deleted}; 1 host now logs in on its own: %{hosts}"
cli.credential.deleted_released.other:
zh-CN: "%{deleted};%{count} 台主机改为自己登录:%{hosts}"
en: "%{deleted}; %{count} hosts now log in on their own: %{hosts}"
# `upload`, `download` and `sync`: the progress line and the summary.
cli.progress:
zh-CN: "%{files}/%{total_files} 个文件,%{bytes} / %{total_bytes}"
en: "%{files}/%{total_files} files, %{bytes} / %{total_bytes}"
cli.summary.transferred.one:
en: Transferred 1 file (%{size})
cli.summary.transferred.other:
zh-CN: 已传输 %{count} 个文件,共 %{size}
en: Transferred %{count} files (%{size})
cli.summary.unchanged:
zh-CN: "%{summary},未变 %{count} 个"
en: "%{summary}, %{count} unchanged"
cli.summary.skipped:
zh-CN: "%{summary},跳过 %{count} 个"
en: "%{summary}, %{count} skipped"
cli.summary.deleted:
zh-CN: "%{summary},删除 %{count} 个"
en: "%{summary}, %{count} deleted"
cli.summary.failed:
zh-CN: "%{summary},失败 %{count} 个"
en: "%{summary}, %{count} failed"
# What the app answers when it will not take the request.
cli.server.version_mismatch:
zh-CN: shellrs 命令与正在运行的 ShellRS 版本不同:请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI
en: The shellrs command and the running ShellRS are different versions. Restart ShellRS, or update the CLI in Settings → External CLI.
cli.server.not_enabled:
zh-CN: ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」
en: The external CLI is turned off. Turn on Enable external CLI in ShellRS under Settings → External CLI.
cli.server.closed_during_change:
zh-CN: ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成
en: ShellRS closed while making the change. Open ShellRS again and check with shellrs hosts list whether it was made.
cli.server.change_timed_out:
zh-CN: ShellRS 没有及时处理这项改动,改动没有做:请稍后再试
en: ShellRS didn't get to the change in time, so it wasn't made. Try again later.
cli.server.not_absolute:
zh-CN: 本地路径必须是绝对路径:%{path}
en: "The local path must be absolute: %{path}"
# The app's notification when a second ShellRS starts.
cli.server.another_app:
zh-CN: 另一个 ShellRS 已在提供外部 CLI
en: Another ShellRS is already serving the external CLI.
cli.error.host_not_found:
zh-CN: 没有 ID 为 %{id} 的主机:请用 shellrs hosts list 查看
en: No host has the ID %{id}. List the hosts with shellrs hosts list.
cli.error.credential_not_found:
zh-CN: 没有 ID 为 %{id} 的凭据:请用 shellrs credentials list 查看
en: No credential has the ID %{id}. List the credentials with shellrs credentials list.
# Checking what `hosts create` / `update` ask for.
cli.host.temporary:
zh-CN: 「%{name}」是没有保存的连接(临时连接或外部连接),不能修改或删除:关闭它的标签即可
en: '"%{name}" isn''t saved (it came from Quick connect or an external connection), so it can''t be changed or deleted. Closing its tab is enough.'
cli.host.credential_with_own_auth:
zh-CN: auth 为 password 或 no_password 时不能同时指定 credential
en: With auth password or no_password, credential can't be given.
cli.host.no_credential:
zh-CN: 请选择凭据
en: Choose a credential.
cli.host.credential_user:
zh-CN: 使用凭据的主机以凭据的用户名 %{user} 登录:去掉 user,或改凭据的用户名
en: A host using a credential logs in as the credential's user, %{user}. Leave out user, or change the credential's username.
cli.host.password_without_auth:
zh-CN: 只有 auth 为 password 的主机才保存自己的密码
en: Only a host with auth password keeps a password of its own.
cli.host.proxy_password_without_user:
zh-CN: 填写代理密码时请同时填写用户名
en: Enter a username along with the proxy password.
cli.host.jump_temporary:
zh-CN: 跳板只能是保存的主机:%{id} 是没有保存的连接
en: "Jump hosts must be saved hosts: %{id} isn't saved."
cli.host.jump_self:
zh-CN: 跳板主机不能是这台主机自己
en: A host can't be its own jump host.
cli.host.jump_repeated:
zh-CN: 跳板主机 %{id} 重复了
en: Jump host %{id} is listed twice.
cli.group.empty_level:
zh-CN: 分组路径「%{path}」里有空的一级
en: 'The group path "%{path}" has an empty level.'
cli.group.ambiguous:
zh-CN: 有 %{count} 个分组的路径都是「%{path}」:请先在 ShellRS 里给它们改名
en: '%{count} groups have the path "%{path}". Rename them in ShellRS first.'
# Checking what `credentials create` / `update` ask for.
cli.credential.key_without_kind:
zh-CN: 只有密钥凭据(kind 为 key)才有私钥
en: Only a key credential (kind key) has a private key.
cli.credential.password_without_kind:
zh-CN: 只有密码凭据(kind 为 password)才保存密码
en: Only a password credential (kind password) keeps a password.
cli.credential.passphrase_without_kind:
zh-CN: 只有密钥凭据(kind 为 key)才有口令
en: Only a key credential (kind key) has a passphrase.
cli.credential.two_keys:
zh-CN: key_path 和 private_key 只能给一个
en: Give key_path or private_key, not both.
cli.credential.key_not_absolute:
zh-CN: 私钥文件必须是绝对路径:%{path}
en: "The private key file must be an absolute path: %{path}"
cli.credential.key_missing:
zh-CN: 私钥文件不存在:%{path}
en: "The private key file doesn't exist: %{path}"
cli.credential.no_key_dir:
zh-CN: 没有可以保存私钥的目录
en: There's no folder to keep the private key in.
# Making the change in the app.
cli.change.host_gone:
zh-CN: 主机保存后不见了
en: The host was gone after saving.
cli.change.host_missing:
zh-CN: 主机已不存在
en: The host no longer exists.
cli.change.host_in_use:
zh-CN: 主机「%{name}」有打开的标签:先关闭它们,或加 --force 连同标签一起关闭
en: 'Host "%{name}" has tabs open. Close them first, or add --force to close them along with it.'
cli.change.credential_gone:
zh-CN: 凭据保存后不见了
en: The credential was gone after saving.
cli.change.key_not_saved:
zh-CN: 私钥未能保存:%{error}
en: "Couldn't save the private key: %{error}"
# One failure after another.
cli.change.failures:
zh-CN: "%{before};%{next}"
en: "%{before}; %{next}"
# Running commands and moving files.
cli.exec.try_terminal:
zh-CN: "%{message}(堡垒机可能只允许登录一次:改用 shellrs exec --terminal,在 ShellRS 已打开的终端里执行)"
en: "%{message} (The bastion host may allow one login only: use shellrs exec --terminal to run the command in the terminal open in ShellRS.)"
# `exec --terminal`: why the command could not be typed into the terminal,
# or did not finish there.
cli.terminal.timed_out:
zh-CN: ShellRS 没有及时处理这条命令,命令没有执行:请稍后再试
en: ShellRS didn't get to the command in time, so it didn't run. Try again later.
cli.terminal.no_terminal:
zh-CN: 这台主机在 ShellRS 里没有打开的终端:请先在 ShellRS 里打开它的终端
en: This host has no terminal open in ShellRS. Open its terminal in ShellRS first.
cli.terminal.not_connected:
zh-CN: 这台主机在 ShellRS 里的终端没有连接:请先在 ShellRS 里重新连接
en: This host's terminal in ShellRS isn't connected. Reconnect it in ShellRS first.
cli.terminal.unknown_shell:
zh-CN: 认不出终端里的 shell:只支持 Linux 上的 bash、dash 和 busybox ash
en: Couldn't tell which shell the terminal runs. Only bash, dash and busybox ash on Linux are supported.
cli.terminal.unsupported_shell:
zh-CN: 终端里的 shell 是 %{shell}:只支持 bash、dash 和 busybox ash
en: The terminal runs %{shell}. Only bash, dash and busybox ash are supported.
cli.terminal.too_long:
zh-CN: 命令太长:这个终端里,编码后的命令行最多 %{limit} 字节。请拆成几条
en: The command is too long. In this terminal the encoded command line takes %{limit} bytes at most; split it up.
cli.terminal.full_screen:
zh-CN: 终端里正在运行全屏程序(如 vim、less、top、tmux):请先在 ShellRS 里退出它
en: A full-screen program (such as vim, less, top or tmux) has the terminal. Quit it in ShellRS first.
cli.terminal.another_run:
zh-CN: 这个终端里上一条 shellrs exec --terminal 命令还在执行
en: The previous shellrs exec --terminal command is still running in this terminal.
cli.terminal.no_answer:
zh-CN: 终端里的 shell 没有回应:前台程序可能不响应 Ctrl-C(如 python、mysql),或者终端里不是 Linux 上的 bash、dash 或 busybox ash
en: The shell in the terminal didn't answer. The program in the foreground may ignore Ctrl-C (such as python or mysql), or the terminal doesn't run bash, dash or busybox ash on Linux.
cli.terminal.not_started:
zh-CN: shell 没有开始执行这条命令,可能被堡垒机的命令过滤或命令复核拦下了;已发送 Ctrl-C
en: The shell didn't start the command; the bastion host's command filter or review may have held it back. Ctrl-C was sent.
cli.terminal.disconnected:
zh-CN: 命令执行过程中终端断开了,命令可能已执行了一部分
en: The terminal disconnected while the command ran; it may have partly run.
cli.exec.signal:
zh-CN: 远程命令被信号 %{signal} 终止
en: The remote command was killed by signal %{signal}.
cli.transfer.sftp_ended:
zh-CN: SFTP 连接意外结束
en: The SFTP connection ended unexpectedly.
cli.transfer.no_local_folder:
zh-CN: 本地目录不存在:%{path}
en: "The local folder doesn't exist: %{path}"
cli.transfer.host_key_unknown:
zh-CN: 尚未信任这台主机的密钥:请先在 ShellRS 中连接一次这台主机
en: This host's key isn't trusted yet. Connect to the host once in ShellRS first.
cli.transfer.host_key_changed:
zh-CN: 主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份
en: The host key doesn't match the saved one, so the connection was refused. Check the server's identity first.
cli.transfer.missing_credential:
zh-CN: 这台主机没有保存可用的密码或口令:请先在 ShellRS 中连接一次这台主机并保存密码
en: No usable password or passphrase is saved for this host. Connect to it once in ShellRS and save the password.
cli.transfer.unfinished:
zh-CN: 传输未完成
en: The transfer didn't finish.
cli.transfer.empty_remote_path:
zh-CN: 远程路径不能为空
en: The remote path is empty.
# 设置 › 外部 CLI: the agents the skill installs for, and installing the
# command.
cli.agent.generic:
zh-CN: 通用 Agent
en: Any agent
cli.agent.generic_description:
zh-CN: 安装到 Agent Skills 标准目录,供支持该目录的 Agent 使用。
en: Installs to the standard Agent Skills folder, for agents that read it.
cli.install.unsupported:
zh-CN: 此系统暂不支持安装 shellrs 命令
en: Installing the shellrs command isn’t supported on this system.
cli.install.occupied:
zh-CN: "%{path} 已被其他程序占用,ShellRS 不会改动它"
en: "%{path} belongs to another program, so ShellRS leaves it alone"
cli.install.cancelled:
zh-CN: 已取消
en: Cancelled
cli.install.missing:
zh-CN: 找不到 %{path}
en: Can’t find %{path}
+28
View File
@@ -0,0 +1,28 @@
_version: 2
# 多语言: one key per text, its Chinese and English side by side. Keys
# name what the text is for; see `src/i18n.rs`.
# `join_list`: 「A、B 和 C」, "A, B and C".
common.list.separator:
zh-CN: 、
en: ", "
common.list.and:
zh-CN: 和
en: " and "
common.cancel:
zh-CN: 取消
en: Cancel
common.close:
zh-CN: 关闭
en: Close
common.save:
zh-CN: 保存
en: Save
common.delete:
zh-CN: 删除
en: Delete
common.delete_title:
zh-CN: 删除“%{name}”?
en: Delete “%{name}”?
+21
View File
@@ -0,0 +1,21 @@
_version: 2
# 连接: the questions a connection asks before it can go on.
connection.unknown_host.description:
zh-CN: "主机:%{host}:%{port}\n算法:%{algorithm}\nSHA-256 指纹:%{fingerprint}\n\n请先确认该指纹来自可信渠道。"
en: "Host: %{host}:%{port}\nAlgorithm: %{algorithm}\nSHA-256 fingerprint: %{fingerprint}\n\nMake sure the fingerprint matches one from a source you trust."
connection.unknown_host.jump_host_description:
zh-CN: "跳板主机:%{jump_host}\n主机:%{host}:%{port}\n算法:%{algorithm}\nSHA-256 指纹:%{fingerprint}\n\n请先确认该指纹来自可信渠道。"
en: "Jump host: %{jump_host}\nHost: %{host}:%{port}\nAlgorithm: %{algorithm}\nSHA-256 fingerprint: %{fingerprint}\n\nMake sure the fingerprint matches one from a source you trust."
connection.jump_host.instructions:
zh-CN: 跳板主机「%{name}」
en: Jump host “%{name}”
connection.jump_host.with_instructions:
zh-CN: 跳板主机「%{name}」:%{instructions}
en: "Jump host “%{name}”: %{instructions}"
connection.latency.timed_out:
zh-CN: 超时
en: Timed out
+156
View File
@@ -0,0 +1,156 @@
_version: 2
# 凭据: the credential list and the dialog that edits a credential.
credential.key_source.file:
zh-CN: 本机文件
en: Local file
credential.key_source.paste:
zh-CN: 粘贴
en: Paste
credential.key_source.generate:
zh-CN: 生成新密钥
en: Generate
credential.dialog.title_new:
zh-CN: 新建凭据
en: New credential
credential.dialog.title_edit:
zh-CN: 编辑凭据
en: Edit credential
credential.dialog.create:
zh-CN: 创建
en: Create
credential.dialog.kind:
zh-CN: 类型
en: Type
credential.dialog.name:
zh-CN: 名称
en: Name
credential.dialog.name_placeholder:
zh-CN: 例如 生产环境 root
en: e.g. Production root
credential.dialog.user:
zh-CN: 用户名
en: Username
credential.dialog.password:
zh-CN: 密码
en: Password
credential.dialog.private_key:
zh-CN: 私钥
en: Private key
credential.dialog.key_text_placeholder:
zh-CN: 粘贴私钥,以 -----BEGIN 开头
en: Paste the private key, starting with -----BEGIN
credential.dialog.no_key_dir:
zh-CN: 本地数据库没有打开,这次运行无法保存私钥
en: The local database isn’t open, so ShellRS can’t keep private keys until it restarts
credential.dialog.kept_key:
zh-CN: 这把私钥由 ShellRS 保存,删除凭据时会一起删除。
en: ShellRS keeps this private key and deletes it along with the credential.
credential.dialog.algorithm:
zh-CN: 算法
en: Algorithm
credential.dialog.rsa_note:
zh-CN: 只在服务器不支持 Ed25519 时选它(OpenSSH 6.5 以前)。
en: Choose it only for servers without Ed25519 support (OpenSSH before 6.5).
credential.dialog.public_key:
zh-CN: 公钥
en: Public key
credential.dialog.public_key_note:
zh-CN: 把公钥加到服务器的 ~/.ssh/authorized_keys,就能用这把密钥登录。
en: Add the public key to ~/.ssh/authorized_keys on a server to log in to it with this key.
credential.dialog.copy_public_key:
zh-CN: 复制公钥
en: Copy public key
credential.dialog.generating:
zh-CN: 正在生成密钥…
en: Generating the key…
credential.dialog.passphrase:
zh-CN: 私钥口令
en: Passphrase
credential.dialog.ask_for_passphrase:
zh-CN: 留空则每次连接都询问
en: Leave empty to be asked at each connection
credential.dialog.no_passphrase:
zh-CN: 留空则不加密
en: Leave empty to leave the key unencrypted
credential.dialog.agent_note:
zh-CN: 登录时使用 SSH Agent(SSH_AUTH_SOCK 所指)中的密钥。
en: Logs in with the keys in the SSH agent that SSH_AUTH_SOCK points to.
credential.dialog.agent_note_windows:
zh-CN: 登录时使用 Windows 的 OpenSSH Authentication Agent 服务中的密钥。
en: Logs in with the keys in the Windows OpenSSH Authentication Agent service.
credential.dialog.keychain_note:
zh-CN: 密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。
en: Passwords and passphrases are kept in the system keychain, never in ShellRS’s database.
credential.dialog.no_keychain_note:
zh-CN: 系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。
en: The system keychain isn’t available, so passwords can’t be saved on this computer and are asked for at each connection.
credential.dialog.drops_kept_key:
zh-CN: 保存后,ShellRS 保存的原私钥会被删除。
en: Saving deletes the private key ShellRS kept for this credential.
credential.dialog.usage.one:
en: "%{count} host uses this credential."
credential.dialog.usage.other:
zh-CN: 有 %{count} 台主机使用此凭据。
en: "%{count} hosts use this credential."
credential.dialog.usage_new_key:
zh-CN: 换成新私钥后,要把它的公钥加到这些主机上,它们才能再登录。
en: After switching to a new private key, add its public key to these hosts so they can log in again.
credential.dialog.usage_reconnects:
zh-CN: 修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。
en: Changing the username, type or private key file reconnects the ones that are connected.
credential.dialog.encrypting:
zh-CN: 正在用口令加密私钥…
en: Encrypting the private key with the passphrase…
credential.dialog.generate_failed:
zh-CN: 无法生成密钥:%{error}
en: "Couldn’t generate the key: %{error}"
credential.dialog.still_generating:
zh-CN: 密钥还在生成,请稍候
en: The key is still being generated. Try again in a moment.
credential.dialog.encode_failed:
zh-CN: 无法保存私钥:%{error}
en: "Couldn’t save the private key: %{error}"
credential.dialog.encrypt_failed:
zh-CN: 无法加密私钥:%{error}
en: "Couldn’t encrypt the private key: %{error}"
credential.dialog.key_not_saved:
zh-CN: 私钥未能保存:%{error}
en: "The private key wasn’t saved: %{error}"
credential.panel.title:
zh-CN: 凭据
en: Credentials
credential.panel.search:
zh-CN: 搜索凭据
en: Search credentials
credential.panel.detail.one:
en: "%{summary} · %{count} host"
credential.panel.detail.other:
zh-CN: "%{summary} · %{count} 台主机"
en: "%{summary} · %{count} hosts"
credential.panel.no_matches:
zh-CN: 没有匹配的凭据
en: No matching credentials
credential.panel.empty:
zh-CN: 还没有凭据
en: No credentials yet
credential.panel.empty_hint:
zh-CN: 保存一套用户名和密码、私钥或 SSH Agent,让多台主机共用。
en: Save a username with a password, a private key or the SSH agent for several hosts to share.
credential.menu.new:
zh-CN: 新建凭据…
en: New credential…
credential.menu.generate:
zh-CN: 生成密钥…
en: Generate key…
credential.menu.edit:
zh-CN: 编辑凭据…
en: Edit credential…
credential.menu.copy_public_key:
zh-CN: 复制公钥
en: Copy public key
+253
View File
@@ -0,0 +1,253 @@
_version: 2
# Docker: the right sidebar's tool for containers, volumes, images and
# networks. Docker's own terms stay as Docker says them.
docker.tab.containers:
zh-CN: 容器
en: Containers
docker.tab.volumes:
zh-CN: 卷
en: Volumes
docker.tab.images:
zh-CN: 镜像
en: Images
docker.tab.networks:
zh-CN: 网络
en: Networks
docker.panel.no_containers:
zh-CN: 没有容器
en: No containers
docker.panel.no_volumes:
zh-CN: 没有卷
en: No volumes
docker.panel.no_images:
zh-CN: 没有镜像
en: No images
docker.panel.no_networks:
zh-CN: 没有网络
en: No networks
docker.panel.standalone:
zh-CN: 独立容器
en: Standalone containers
docker.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机上的容器。
en: The terminal isn’t connected. Once it is, the host’s containers show here.
docker.panel.missing:
zh-CN: 这台主机没有安装 Docker。
en: Docker isn’t installed on this host.
docker.panel.unsupported:
zh-CN: 暂不支持这台主机上的 Docker。
en: Docker on this host isn’t supported yet.
docker.panel.unreachable:
zh-CN: 无法读取 Docker:%{error}
en: "Couldn’t read Docker: %{error}"
docker.project.running:
zh-CN: "%{running}/%{total} 运行中"
en: "%{running}/%{total} running"
# What a container is doing, as `docker ps` says.
docker.state.running:
zh-CN: 运行中
en: Running
docker.state.paused:
zh-CN: 已暂停
en: Paused
docker.state.restarting:
zh-CN: 正在重启
en: Restarting
docker.state.created:
zh-CN: 已创建
en: Created
docker.state.exited:
zh-CN: 已停止
en: Exited
docker.state.dead:
zh-CN: 异常
en: Dead
docker.state.removing:
zh-CN: 正在删除
en: Removing
docker.command.start:
zh-CN: 启动
en: Start
docker.command.stop:
zh-CN: 停止
en: Stop
docker.command.restart:
zh-CN: 重启
en: Restart
docker.object.container:
zh-CN: 容器
en: Container
docker.object.image:
zh-CN: 镜像
en: Image
docker.object.volume:
zh-CN: 卷
en: Volume
docker.object.network:
zh-CN: 网络
en: Network
docker.object.in_use:
zh-CN: 使用中
en: In use
docker.object.unused:
zh-CN: 未使用
en: Unused
docker.object.in_use_by_containers:
zh-CN: 有容器在用,不能删除
en: A container uses it, so it can’t be deleted
docker.image.unnamed:
zh-CN: 未命名镜像
en: Untagged image
docker.network.builtin:
zh-CN: 内置
en: Built-in
docker.network.builtin_kept:
zh-CN: Docker 自带的网络,不能删除
en: Docker’s own network can’t be deleted
docker.network.containers.one:
en: "%{count} container"
docker.network.containers.other:
zh-CN: "%{count} 个容器"
en: "%{count} containers"
docker.network.in_use:
zh-CN: 有容器连着,不能删除
en: Containers are attached, so it can’t be deleted
# Docker's complaints, where the user can do something about them.
docker.error.permission:
zh-CN: 没有权限:以 root 登录、把这个用户加入 docker 组,或给它配置免密码的 sudo
en: "Permission denied: log in as root, add this user to the docker group, or give it passwordless sudo"
docker.error.not_running:
zh-CN: Docker 没有运行
en: Docker isn’t running
docker.error.status:
zh-CN: docker 返回 %{status}
en: docker exited with status %{status}
# The details dialogs.
docker.details.tab_details:
zh-CN: 详情
en: Details
docker.details.tab_logs:
zh-CN: 日志
en: Logs
docker.details.no_output:
zh-CN: 没有输出
en: No output
docker.details.container_gone:
zh-CN: 读不到这个容器:它可能已经被删除
en: Couldn’t read this container. It may have been deleted.
docker.details.image_gone:
zh-CN: 读不到这个镜像:它可能已经被删除
en: Couldn’t read this image. It may have been deleted.
docker.details.volume_gone:
zh-CN: 读不到这个卷:它可能已经被删除
en: Couldn’t read this volume. It may have been deleted.
docker.details.network_gone:
zh-CN: 读不到这个网络:它可能已经被删除
en: Couldn’t read this network. It may have been deleted.
docker.section.basics:
zh-CN: 基础信息
en: Overview
docker.section.ports:
zh-CN: 端口映射
en: Ports
docker.section.mounts:
zh-CN: 挂载
en: Mounts
docker.section.environment:
zh-CN: 环境变量
en: Environment
docker.section.labels:
zh-CN: 标签
en: Labels
docker.section.users:
zh-CN: 使用它的容器
en: Used by
docker.section.options:
zh-CN: 选项
en: Options
docker.section.config:
zh-CN: 运行配置
en: Configuration
docker.section.subnets:
zh-CN: 子网
en: Subnets
docker.section.containers:
zh-CN: 容器
en: Containers
docker.field.name:
zh-CN: 名称
en: Name
docker.field.image:
zh-CN: 镜像
en: Image
docker.field.created:
zh-CN: 创建时间
en: Created
docker.field.entrypoint:
zh-CN: 入口
en: Entrypoint
docker.field.command:
zh-CN: 命令
en: Command
docker.field.driver:
zh-CN: 驱动
en: Driver
docker.field.mountpoint:
zh-CN: 挂载点
en: Mountpoint
docker.field.scope:
zh-CN: 范围
en: Scope
docker.field.tags:
zh-CN: 标签
en: Tags
docker.field.digests:
zh-CN: 摘要
en: Digests
docker.field.size:
zh-CN: 大小
en: Size
docker.field.platform:
zh-CN: 平台
en: Platform
docker.field.layers:
zh-CN: 层数
en: Layers
docker.field.author:
zh-CN: 作者
en: Author
docker.field.working_dir:
zh-CN: 工作目录
en: Working directory
docker.field.user:
zh-CN: 用户
en: User
docker.field.exposed_ports:
zh-CN: 暴露端口
en: Exposed ports
docker.field.internal:
zh-CN: 内部网络
en: Internal
docker.value.yes:
zh-CN: 是
en: "Yes"
docker.value.no:
zh-CN: 否
en: "No"
docker.mount.read_only:
zh-CN: "%{path}(只读)"
en: "%{path} (read-only)"
docker.network.gateway:
zh-CN: 网关 %{address}
en: Gateway %{address}
+71
View File
@@ -0,0 +1,71 @@
_version: 2
# The editor tab: its toolbar, status line text, and the questions about
# saving, reloading and closing.
editor.status.cursor:
zh-CN: 行 %{line},列 %{column}
en: Ln %{line}, Col %{column}
editor.state.saving:
zh-CN: 正在保存…
en: Saving…
editor.state.reloading:
zh-CN: 正在重新加载…
en: Reloading…
editor.state.modified:
zh-CN: 已修改
en: Modified
editor.command.reload:
zh-CN: 重新加载
en: Reload
editor.command.copy_path:
zh-CN: 复制路径
en: Copy path
editor.text_label:
zh-CN: 编辑 %{name}
en: Edit %{name}
editor.reload.title:
zh-CN: 放弃对“%{name}”的修改并重新加载?
en: Discard changes to “%{name}” and reload?
editor.reload.description:
zh-CN: 编辑器里没有保存的修改会丢失。
en: Changes not saved in the editor will be lost.
editor.reload.failed:
zh-CN: 无法重新加载“%{name}”
en: Couldn’t reload “%{name}”
editor.changed.title:
zh-CN: “%{name}”在打开后已被修改
en: “%{name}” changed after it was opened
editor.changed.remote:
zh-CN: 服务器上的这个文件在你打开或上次保存之后被改过或删除了。覆盖会用编辑器里的内容替换它。
en: This file on the server was changed or deleted after you opened or last saved it. Overwriting replaces it with what’s in the editor.
editor.changed.local:
zh-CN: 本机上的这个文件在你打开或上次保存之后被改过或删除了。覆盖会用编辑器里的内容替换它。
en: This file on this computer was changed or deleted after you opened or last saved it. Overwriting replaces it with what’s in the editor.
editor.changed.overwrite:
zh-CN: 覆盖
en: Overwrite
editor.save.interrupted:
zh-CN: "%{message}。文件可能只写了一部分,请重新连接后再保存一次。"
en: "%{message}. The file may be only partly written. Reconnect and save it again."
editor.save.interrupted_title:
zh-CN: 没有保存完“%{name}”
en: Couldn’t finish saving “%{name}”
editor.save.failed:
zh-CN: 无法保存“%{name}”
en: Couldn’t save “%{name}”
editor.close.title:
zh-CN: 保存对“%{name}”的修改?
en: Save changes to “%{name}”?
editor.close.description:
zh-CN: 不保存的话,这些修改会丢失。
en: Your changes will be lost if you don’t save them.
editor.close.discard:
zh-CN: 放弃修改
en: Discard changes
+706
View File
@@ -0,0 +1,706 @@
_version: 2
# The SFTP tab: the two file lists, their toolbars and menus, the dialogs
# they open, the transfer queue and the preview.
explorer.new.folder_title:
zh-CN: 新建文件夹
en: New folder
explorer.new.file_title:
zh-CN: 新建文件
en: New file
explorer.new.folder_name:
zh-CN: 新建文件夹
en: New folder
explorer.new.file_name:
zh-CN: 新建文件.txt
en: New file.txt
explorer.kind.folder:
zh-CN: 文件夹
en: Folder
explorer.kind.file:
zh-CN: 文件
en: File
explorer.kind.link:
zh-CN: 链接
en: Link
explorer.kind.parent:
zh-CN: 上级目录
en: Parent folder
explorer.kind.extension:
zh-CN: "%{ext} 文件"
en: "%{ext} file"
explorer.size_format.bytes:
zh-CN: 字节
en: Bytes
explorer.size_format.kilobytes:
zh-CN: 千字节(KB)
en: Kilobytes (KB)
explorer.size_format.short:
zh-CN: 简短格式(B、KB、MB、GB)
en: Short (B, KB, MB, GB)
explorer.column.name:
zh-CN: 名称
en: Name
explorer.column.size:
zh-CN: 大小
en: Size
explorer.column.type:
zh-CN: 类型
en: Type
explorer.column.modified:
zh-CN: 修改时间
en: Modified
explorer.column.rights:
zh-CN: 权限
en: Permissions
explorer.column.owner:
zh-CN: 所有者
en: Owner
explorer.list.connecting:
zh-CN: 正在连接 SFTP…
en: Connecting to SFTP…
explorer.list.reading:
zh-CN: 正在读取目录…
en: Reading folder…
explorer.list.only_hidden:
zh-CN: 只有隐藏文件
en: Only hidden files
explorer.list.empty:
zh-CN: 空目录
en: Empty folder
explorer.list.not_connected:
zh-CN: 未连接
en: Not connected
explorer.list.unreadable:
zh-CN: 无法读取目录
en: Couldn’t read the folder
explorer.list.read_failed:
zh-CN: 无法读取目录:%{error}
en: "Couldn’t read the folder: %{error}"
explorer.drag.upload.one:
en: Upload %{count} item
explorer.drag.upload.other:
zh-CN: 上传 %{count} 个项目
en: Upload %{count} items
explorer.drag.download.one:
en: Download %{count} item
explorer.drag.download.other:
zh-CN: 下载 %{count} 个项目
en: Download %{count} items
explorer.status.opening:
zh-CN: 正在打开 %{name}…
en: Opening %{name}…
explorer.status.items.one:
en: "%{count} item"
explorer.status.items.other:
zh-CN: "%{count} 个项目"
en: "%{count} items"
explorer.status.hidden.other:
zh-CN: 隐藏 %{count} 项
en: "%{count} hidden"
explorer.status.selected.other:
zh-CN: 已选择 %{count} 项
en: "%{count} selected"
explorer.path.local:
zh-CN: 本地路径
en: Local path
explorer.path.remote:
zh-CN: 远程路径
en: Remote path
explorer.directory_select.local:
zh-CN: 本地目录
en: Local folder
explorer.directory_select.remote:
zh-CN: 远程目录
en: Remote folder
explorer.directory_select.current:
zh-CN: 当前路径
en: Current path
explorer.directory_select.places:
zh-CN: 位置
en: Places
explorer.command.open_directory:
zh-CN: 打开目录/书签…
en: Open folder or bookmark…
explorer.command.up:
zh-CN: 上级目录
en: Parent folder
explorer.command.root:
zh-CN: 根目录
en: Root folder
explorer.command.home:
zh-CN: 主目录
en: Home folder
explorer.command.refresh:
zh-CN: 刷新
en: Refresh
explorer.command.back:
zh-CN: 后退
en: Back
explorer.command.forward:
zh-CN: 前进
en: Forward
explorer.command.show_hidden_files:
zh-CN: 显示隐藏文件
en: Show hidden files
explorer.toolbar.hide_hidden_files:
zh-CN: 不显示隐藏文件
en: Hide hidden files
explorer.command.upload:
zh-CN: 上传…
en: Upload…
explorer.toolbar.upload_tip:
zh-CN: 上传所选项目
en: Upload the selected items
explorer.command.download:
zh-CN: 下载…
en: Download…
explorer.toolbar.download_tip:
zh-CN: 下载所选项目
en: Download the selected items
explorer.toolbar.choose_files:
zh-CN: 选择文件上传…
en: Choose files to upload…
explorer.command.rename:
zh-CN: 重命名…
en: Rename…
explorer.command.properties:
zh-CN: 属性…
en: Properties…
explorer.command.new:
zh-CN: 新建
en: New
explorer.transfer.upload_title:
zh-CN: 上传文件
en: Upload
explorer.transfer.download_title:
zh-CN: 下载
en: Download
explorer.transfer.upload:
zh-CN: 上传
en: Upload
explorer.transfer.download:
zh-CN: 下载
en: Download
explorer.transfer.upload_summary:
zh-CN: 上传 %{items}到 %{endpoint}
en: Upload %{items} to %{endpoint}
explorer.transfer.download_summary:
zh-CN: 从 %{endpoint} 下载 %{items}
en: Download %{items} from %{endpoint}
explorer.transfer.files.one:
en: "%{count} file"
explorer.transfer.files.other:
zh-CN: "%{count} 个文件"
en: "%{count} files"
explorer.transfer.folders.one:
en: "%{count} folder"
explorer.transfer.folders.other:
zh-CN: "%{count} 个文件夹"
en: "%{count} folders"
explorer.transfer.items.other:
zh-CN: "%{count} 个项目(%{folders}、%{files})"
en: "%{count} items (%{folders}, %{files})"
explorer.transfer.in_folder:
zh-CN: 位于 %{folder}
en: In %{folder}
explorer.transfer.queued_note:
zh-CN: 当前有传输在进行,这一批会排在传输队列里,轮到时自动开始。
en: Another transfer is running. This one waits in Transfers and starts when its turn comes.
explorer.transfer.target:
zh-CN: 目标目录
en: Destination folder
explorer.transfer.download_to:
zh-CN: 下载到
en: Download to
explorer.transfer.choose_download_folder:
zh-CN: 选择下载到的目录
en: Choose where to download to
explorer.transfer.target_missing:
zh-CN: 请输入目标目录
en: Enter a destination folder
explorer.transfer.local_target_relative:
zh-CN: 请输入本机目录的完整路径
en: Enter the full path of a folder on this computer
explorer.question.conflict_title:
zh-CN: 同名文件已存在
en: A file with this name already exists
explorer.question.overwrite:
zh-CN: 覆盖
en: Overwrite
explorer.question.apply_to_all:
zh-CN: 应用于本批剩余冲突
en: Do the same for the rest of this batch
explorer.question.resume_upload_title:
zh-CN: 发现未完成的上传
en: Unfinished upload found
explorer.question.resume_download_title:
zh-CN: 发现未完成的下载
en: Unfinished download found
explorer.question.resume_upload:
zh-CN: 继续上传
en: Resume upload
explorer.question.resume_download:
zh-CN: 继续下载
en: Resume download
explorer.question.restart_upload:
zh-CN: 重新上传
en: Upload again
explorer.question.restart_download:
zh-CN: 重新下载
en: Download again
explorer.question.invalid_resume_title:
zh-CN: 无法继续此文件
en: Can’t resume this file
explorer.question.upload_failed_title:
zh-CN: 无法上传此项目
en: Couldn’t upload this item
explorer.question.download_failed_title:
zh-CN: 无法下载此项目
en: Couldn’t download this item
explorer.question.retry:
zh-CN: 重试
en: Retry
explorer.question.skip:
zh-CN: 跳过
en: Skip
explorer.question.cancel_upload:
zh-CN: 取消上传
en: Cancel upload
explorer.question.cancel_download:
zh-CN: 取消下载
en: Cancel download
explorer.close_transfer.upload_title:
zh-CN: 停止上传并关闭?
en: Stop the upload and close?
explorer.close_transfer.download_title:
zh-CN: 停止下载并关闭?
en: Stop the download and close?
explorer.close_transfer.upload_kept:
zh-CN: 上传进度会保留,下次选择相同来源和目标目录时可以继续上传。
en: "Progress is kept: choose the same source and destination folder later to resume the upload."
explorer.close_transfer.download_kept:
zh-CN: 下载进度会保留,下次选择相同来源和目标目录时可以继续下载。
en: "Progress is kept: choose the same source and destination folder later to resume the download."
explorer.close_transfer.two_sentences:
zh-CN: "%{first}%{second}"
en: "%{first} %{second}"
explorer.close_transfer.stop_upload:
zh-CN: 停止上传并关闭
en: Stop and close
explorer.close_transfer.stop_download:
zh-CN: 停止下载并关闭
en: Stop and close
explorer.close_transfer.keep_uploading:
zh-CN: 继续上传
en: Keep uploading
explorer.close_transfer.keep_downloading:
zh-CN: 继续下载
en: Keep downloading
explorer.queue.title:
zh-CN: 传输队列
en: Transfers
explorer.queue.title_count:
zh-CN: 传输队列 (%{unfinished})
en: Transfers (%{unfinished})
explorer.queue.resume:
zh-CN: 继续
en: Resume
explorer.queue.stop:
zh-CN: 停止
en: Stop
explorer.queue.remove:
zh-CN: 移出队列
en: Remove from queue
explorer.queue.discard:
zh-CN: 丢弃续传进度
en: Discard partial transfer
explorer.queue.clear_finished:
zh-CN: 清除已完成
en: Clear finished
explorer.queue.column.operation:
zh-CN: 操作
en: Direction
explorer.queue.column.source:
zh-CN: 来源
en: Source
explorer.queue.column.target:
zh-CN: 目标
en: Destination
explorer.queue.column.transferred:
zh-CN: 已传输
en: Transferred
explorer.queue.column.time:
zh-CN: 时间
en: Time
explorer.queue.column.speed:
zh-CN: 速度
en: Speed
explorer.queue.column.progress:
zh-CN: 进度
en: Progress
explorer.queue.hide_results:
zh-CN: 收起逐项结果
en: Hide item results
explorer.queue.show_results:
zh-CN: 展开逐项结果
en: Show item results
explorer.queue.not_started:
zh-CN: 还没有开始
en: Not started yet
explorer.queue.nothing_uploaded:
zh-CN: 还没有上传完的项目,每完成一项会列在这里
en: No items uploaded yet. Each one is listed here when it’s done.
explorer.queue.nothing_downloaded:
zh-CN: 还没有下载完的项目,每完成一项会列在这里
en: No items downloaded yet. Each one is listed here when it’s done.
explorer.queue.outcome.done:
zh-CN: 完成
en: Done
explorer.queue.outcome.skipped:
zh-CN: 已跳过
en: Skipped
explorer.queue.outcome.failed:
zh-CN: 失败
en: Failed
explorer.queue.several_places:
zh-CN: 多个位置
en: Several places
explorer.queue.status.pending:
zh-CN: 等待中
en: Waiting
explorer.queue.status.scanning:
zh-CN: 正在扫描
en: Scanning
explorer.queue.status.waiting:
zh-CN: 等待回答
en: Waiting for an answer
explorer.queue.status.reconnecting:
zh-CN: 正在重连
en: Reconnecting
explorer.queue.status.stopped:
zh-CN: 已停止
en: Stopped
explorer.queue.status.done:
zh-CN: 已完成
en: Done
explorer.queue.status.partly_failed.one:
en: "%{count} item failed"
explorer.queue.status.partly_failed.other:
zh-CN: 部分失败(%{count} 项)
en: "%{count} items failed"
explorer.queue.status.discarded:
zh-CN: 已丢弃续传进度
en: Partial transfer discarded
explorer.queue.status.not_started:
zh-CN: 无法开始:%{reason}
en: "Couldn’t start: %{reason}"
explorer.queue.status.removed:
zh-CN: 已移出
en: Removed
explorer.connection.start_failed:
zh-CN: 无法启动 SFTP:%{error}
en: "Couldn’t start SFTP: %{error}"
explorer.connection.server:
zh-CN: 服务器
en: the server
explorer.connection.lost:
zh-CN: SFTP 已断开
en: SFTP disconnected
explorer.connection.lost_title:
zh-CN: 与 %{name} 的 SFTP 连接已断开
en: Lost the SFTP connection to %{name}
explorer.connection.lost_reconnect:
zh-CN: "%{reason}。重新连接后再操作远程文件。"
en: "%{reason}. Reconnect to work with remote files."
explorer.connection.lost_stopped_batch:
zh-CN: "%{reason}。传输队列里有停止的批次,点它的「继续」会重新连接并接着传输。"
en: "%{reason}. A stopped batch is waiting in Transfers; its Resume reconnects and carries on."
explorer.connection.lost_reconnecting:
zh-CN: "%{reason}。正在自动重新连接,请稍候。"
en: "%{reason}. Reconnecting automatically."
explorer.connection.reconnect:
zh-CN: 重新连接
en: Reconnect
explorer.ok:
zh-CN: 知道了
en: OK
explorer.browse:
zh-CN: 浏览…
en: Browse…
explorer.choose_upload:
zh-CN: 选择要上传的文件或目录
en: Choose files or folders to upload
explorer.tab.open_sftp_tip:
zh-CN: 打开 SFTP 文件浏览
en: Browse files over SFTP
explorer.tab.rename:
zh-CN: 重命名标签…
en: Rename tab…
explorer.tab.open_sftp:
zh-CN: 打开 SFTP
en: Open SFTP
explorer.tab.copy_ip:
zh-CN: 复制 IP 地址
en: Copy IP address
explorer.tab.copy_host_name:
zh-CN: 复制主机名
en: Copy hostname
explorer.name.empty:
zh-CN: 名称不能为空
en: The name can’t be empty
explorer.name.dots:
zh-CN: 名称不能是「.」或「..」
en: The name can’t be “.” or “..”
explorer.name.slash:
zh-CN: 名称不能包含「/」
en: The name can’t contain “/”
explorer.name.unchanged:
zh-CN: 名称未改变
en: The name hasn’t changed
explorer.name.exists:
zh-CN: 已有名为「%{name}」的项目
en: An item named “%{name}” already exists
explorer.delete.title_one:
zh-CN: 删除「%{name}」?
en: Delete “%{name}”?
explorer.delete.title_several.other:
zh-CN: 删除 %{count} 个项目?
en: Delete %{count} items?
explorer.delete.remote:
zh-CN: 远程文件无法恢复,目录会连同其中全部内容一起删除。
en: Remote files can’t be recovered. A folder is deleted with everything in it.
explorer.delete.trash:
zh-CN: 项目会移到废纸篓。
en: The items move to the Trash.
explorer.delete.recycle_bin:
zh-CN: 项目会移到废纸篓。
en: The items move to the Recycle Bin.
explorer.rename.title:
zh-CN: 重命名「%{name}」
en: Rename “%{name}”
explorer.rename.commit:
zh-CN: 重命名
en: Rename
explorer.rename.label:
zh-CN: 新名称
en: New name
explorer.new.commit:
zh-CN: 创建
en: Create
explorer.new.label:
zh-CN: 名称
en: Name
explorer.file.connection_lost:
zh-CN: SFTP 连接已断开
en: The SFTP connection was lost
explorer.file.connecting:
zh-CN: 正在连接 SFTP,请稍候再试。
en: Connecting to SFTP. Try again in a moment.
explorer.file.edit_refused:
zh-CN: 无法在编辑器中打开“%{name}”
en: Can’t open “%{name}” in the editor
explorer.file.preview_refused:
zh-CN: 无法预览“%{name}”
en: Can’t preview “%{name}”
explorer.file.too_large_for_editor:
zh-CN: 文件有 %{size},编辑器只打开 %{limit} 以内的文件。
en: The file is %{size}. The editor opens files up to %{limit}.
explorer.file.too_large_for_preview:
zh-CN: 文件有 %{size},预览只打开 %{limit} 以内的文件。
en: The file is %{size}. Preview opens files up to %{limit}.
explorer.file.not_text:
zh-CN: 它不是 UTF-8 编码的文本文件:可能是二进制文件,或用了 GBK 等其他编码。
en: "It isn’t a UTF-8 text file: it may be binary, or in another encoding such as GBK."
explorer.file.not_file:
zh-CN: 它不是普通文件。
en: It isn’t a regular file.
explorer.file.open_failed:
zh-CN: 无法打开“%{name}”
en: Couldn’t open “%{name}”
explorer.file.download_instead:
zh-CN: "%{reason}可以下载到本机,用别的程序打开。"
en: "%{reason} You can download it and open it in another app."
explorer.file.download:
zh-CN: 下载…
en: Download…
explorer.preview.image_info:
zh-CN: "%{width} × %{height} 像素 · %{size}"
en: "%{width} × %{height} pixels · %{size}"
explorer.preview.zoom_out:
zh-CN: 缩小
en: Zoom out
explorer.preview.zoom_in:
zh-CN: 放大
en: Zoom in
explorer.preview.fit:
zh-CN: 适合窗口
en: Fit to window
explorer.preview.actual_size:
zh-CN: 原图
en: Actual size
explorer.preview.actual_size_tip:
zh-CN: 原图(100%)
en: Actual size (100%)
explorer.preview.undecodable:
zh-CN: 无法显示这张图片
en: Can’t show this image
explorer.preview.decoding:
zh-CN: 正在显示图片…
en: Loading image…
explorer.open_directory.title:
zh-CN: 打开目录
en: Open folder
explorer.open_directory.folder:
zh-CN: 目录
en: Folder
explorer.open_directory.bookmarks:
zh-CN: 书签
en: Bookmarks
explorer.open_directory.add:
zh-CN: 添加
en: Add
explorer.open_directory.remove:
zh-CN: 删除
en: Remove
explorer.open_directory.up:
zh-CN: 上移
en: Up
explorer.open_directory.down:
zh-CN: 下移
en: Down
explorer.open_directory.open:
zh-CN: 打开
en: Open
explorer.open_directory.missing:
zh-CN: 请输入要打开的目录
en: Enter a folder to open
explorer.open_directory.choose:
zh-CN: 选择要打开的目录
en: Choose a folder to open
explorer.bookmarks.none:
zh-CN: 暂无书签
en: No bookmarks
explorer.command.edit:
zh-CN: 编辑
en: Edit
explorer.command.preview:
zh-CN: 预览
en: Preview
explorer.command.copy_path:
zh-CN: 复制路径
en: Copy path
explorer.command.go:
zh-CN: 前往
en: Go to
explorer.command.add_bookmark:
zh-CN: 添加路径到书签
en: Add path to bookmarks
explorer.command.remove_bookmark:
zh-CN: 从书签中移除路径
en: Remove path from bookmarks
explorer.command.size_format:
zh-CN: 文件大小显示为
en: Show file sizes as
explorer.command.new_folder:
zh-CN: 文件夹…
en: Folder…
explorer.command.new_file:
zh-CN: 文件…
en: File…
explorer.operation.delete_failed:
zh-CN: 删除失败
en: Couldn’t delete
explorer.operation.rename_failed:
zh-CN: 重命名失败
en: Couldn’t rename
explorer.operation.create_failed:
zh-CN: 新建失败
en: Couldn’t create
explorer.operation.permissions_failed:
zh-CN: 修改权限失败
en: Couldn’t change permissions
explorer.properties.title_one:
zh-CN: 「%{name}」的属性
en: Properties of “%{name}”
explorer.properties.title_several.other:
zh-CN: "%{count} 个项目的属性"
en: Properties of %{count} items
explorer.properties.apply:
zh-CN: 应用
en: Apply
explorer.properties.location:
zh-CN: 位置
en: Location
explorer.properties.group:
zh-CN: 组
en: Group
explorer.properties.size.one:
en: "%{size} (%{count} byte)"
explorer.properties.size.other:
zh-CN: "%{size}(%{count} 字节)"
en: "%{size} (%{count} bytes)"
explorer.properties.mixed:
zh-CN: (不一致)
en: (mixed)
explorer.properties.class_owner:
zh-CN: 所有者
en: Owner
explorer.properties.class_group:
zh-CN: 组
en: Group
explorer.properties.class_others:
zh-CN: 其他
en: Others
explorer.properties.read:
zh-CN: 读
en: Read
explorer.properties.write:
zh-CN: 写
en: Write
explorer.properties.execute:
zh-CN: 执行
en: Execute
explorer.properties.octal:
zh-CN: 八进制
en: Octal
explorer.properties.mixed_note:
zh-CN: 部分项目权限不同,未改动的位保持原样。
en: The items’ permissions differ. Bits you leave alone stay as they are.
explorer.properties.links:
zh-CN: 符号链接没有独立的权限。
en: Symbolic links have no permissions of their own.
explorer.properties.unsupported:
zh-CN: 此系统不支持修改权限。
en: This system doesn’t support changing permissions.
explorer.properties.recursive:
zh-CN: 同时应用到其中的文件和子目录
en: Also apply to the files and subfolders inside
explorer.properties.dir_x:
zh-CN: 为目录添加执行权限(X)
en: Add execute permission to folders (X)
+286
View File
@@ -0,0 +1,286 @@
_version: 2
# 端口转发: the forward list, its dialog and the forwards running.
forward.diagram.local:
zh-CN: 本机
en: This computer
forward.diagram.server:
zh-CN: SSH 服务器
en: SSH server
forward.diagram.target_service:
zh-CN: 目标服务
en: Service
forward.diagram.anywhere:
zh-CN: 任意地址
en: Any address
forward.diagram.entrance:
zh-CN: 隧道入口
en: Tunnel entrance
forward.diagram.exit:
zh-CN: 隧道出口
en: Tunnel exit
forward.diagram.target:
zh-CN: 目标
en: Destination
forward.diagram.listens:
zh-CN: 监听 %{bind}
en: Listens on %{bind}
forward.diagram.connects_onward:
zh-CN: 代为连接目标
en: Connects onward
forward.diagram.app_chooses:
zh-CN: 由应用指定
en: Chosen by the app
forward.diagram.tunnel:
zh-CN: SSH 隧道
en: SSH tunnel
forward.diagram.plain:
zh-CN: 普通连接
en: Plain TCP
forward.explain.local:
zh-CN: 在本机连接 %{bind},就等于从 SSH 服务器连接 %{target}。
en: Connecting to %{bind} on this computer is the same as connecting to %{target} from the SSH server.
forward.explain.remote:
zh-CN: 在 SSH 服务器上连接 %{bind},就等于从本机连接 %{target}。
en: Connecting to %{bind} on the SSH server is the same as connecting to %{target} from this computer.
forward.explain.dynamic:
zh-CN: 把应用的 SOCKS5 代理设为 %{bind},它的连接都从 SSH 服务器发出。
en: Set an app’s SOCKS5 proxy to %{bind}, and its connections go out from the SSH server.
forward.purpose.local:
zh-CN: 在本机访问服务器那边的服务
en: Reach a service on the server’s side
forward.purpose.remote:
zh-CN: 让服务器那边访问本机的服务
en: Let the server’s side reach a service here
forward.purpose.dynamic:
zh-CN: 把服务器当作 SOCKS 代理
en: Use the server as a SOCKS proxy
forward.typical_use.local:
zh-CN: 常见用途:用本机的客户端连接只对服务器开放的数据库或内网后台。
en: "Typical use: connect a client here to a database or internal admin site that only the server can reach."
forward.typical_use.remote:
zh-CN: 常见用途:把本机正在开发的服务给服务器那边访问,或接收 Webhook。
en: "Typical use: let the server’s side reach a service you’re developing here, or receive webhooks."
forward.typical_use.dynamic:
zh-CN: 常见用途:让浏览器经由服务器上网,不必为每个地址单独建转发。
en: "Typical use: browse through the server without a forward for each address."
forward.dialog.title_new:
zh-CN: 新建端口转发
en: New port forward
forward.dialog.title_edit:
zh-CN: 编辑端口转发
en: Edit port forward
forward.dialog.create:
zh-CN: 创建
en: Create
forward.dialog.kind_card:
zh-CN: "%{kind}(%{flag})"
en: "%{kind} (%{flag})"
forward.dialog.name:
zh-CN: 名称
en: Name
forward.dialog.name_placeholder:
zh-CN: 可选,留空显示转发摘要
en: Optional; shows the forward’s summary if left empty
forward.dialog.host:
zh-CN: 经由主机
en: Via host
forward.dialog.choose_host:
zh-CN: 请选择主机
en: Choose a host
forward.dialog.host_option:
zh-CN: "%{name}(%{endpoint})"
en: "%{name} (%{endpoint})"
forward.dialog.bind_local:
zh-CN: 本机监听地址
en: Listen on this computer
forward.dialog.bind_server:
zh-CN: 服务器监听地址
en: Listen on the server
forward.dialog.bind_socks:
zh-CN: 本机监听地址(SOCKS 代理)
en: Listen on this computer (SOCKS proxy)
forward.dialog.target_from_server:
zh-CN: 目标地址(从服务器访问)
en: Destination (as the server reaches it)
forward.dialog.target_from_local:
zh-CN: 目标地址(从本机访问)
en: Destination (as this computer reaches it)
forward.dialog.port:
zh-CN: 端口
en: Port
forward.dialog.target_placeholder:
zh-CN: 例如 db.internal 或 127.0.0.1
en: e.g. db.internal or 127.0.0.1
forward.dialog.target_placeholder_remote:
zh-CN: 例如 localhost
en: e.g. localhost
forward.dialog.auto_start:
zh-CN: 启动 ShellRS 时自动开启
en: Start when ShellRS opens
forward.dialog.gateway_ports_note:
zh-CN: 服务器的 sshd_config 需设置 GatewayPorts clientspecified 或 yes,其他机器才连得上;默认是 no,这时转发照常运行,但只监听服务器自己的 127.0.0.1。
en: Other machines can connect only if the server’s sshd_config sets GatewayPorts to clientspecified or yes. With the default, no, the forward still runs but listens only on the server’s own 127.0.0.1.
forward.dialog.not_loopback_note:
zh-CN: 监听地址不是本机回环地址,同一网络中的其他设备也能使用这个转发。
en: The listening address isn’t a loopback address, so other devices on the same network can use this forward too.
forward.dialog.privileged_port_remote:
zh-CN: 服务器上 1024 以下的端口需要以 root 登录才能监听。
en: Listening on a port below 1024 on the server takes logging in as root.
forward.dialog.privileged_port:
zh-CN: 1024 以下的端口通常需要管理员权限才能监听。
en: Listening on a port below 1024 usually takes administrator rights.
forward.dialog.restart_note:
zh-CN: 这条转发正在运行,改动监听或目标后会按新设置重新启动。
en: This forward is running. Changing where it listens or connects restarts it with the new settings.
forward.dialog.choose_host_error:
zh-CN: 请选择端口转发经由的主机
en: Choose the host to forward through
forward.dialog.host_gone:
zh-CN: 所选主机已不存在,请重新选择
en: The chosen host no longer exists. Choose another.
forward.panel.title:
zh-CN: 端口转发
en: Port forwarding
forward.panel.search:
zh-CN: 搜索端口转发
en: Search port forwards
forward.panel.no_matches:
zh-CN: 没有匹配的端口转发
en: No matching port forwards
forward.panel.no_hosts:
zh-CN: 还没有主机
en: No hosts yet
forward.panel.no_hosts_hint:
zh-CN: 端口转发经由一台主机的 SSH 连接,先新建主机。
en: A port forward goes through a host’s SSH connection. Create a host first.
forward.panel.new_host:
zh-CN: 新建主机…
en: New host…
forward.panel.empty:
zh-CN: 还没有端口转发
en: No port forwards yet
forward.panel.empty_hint:
zh-CN: 把本机的端口和服务器那一侧的网络接起来。
en: Connect a port on this computer with the network on the server’s side.
forward.menu.new:
zh-CN: 新建端口转发…
en: New port forward…
forward.menu.edit:
zh-CN: 编辑端口转发…
en: Edit port forward…
forward.menu.start:
zh-CN: 启动
en: Start
forward.menu.stop:
zh-CN: 停止
en: Stop
forward.status.stopped:
zh-CN: 已停止
en: Stopped
forward.status.connecting:
zh-CN: 正在连接
en: Connecting
forward.status.running:
zh-CN: 运行中
en: Running
forward.status.running_with.one:
en: Running, %{count} connection
forward.status.running_with.other:
zh-CN: 运行中,%{count} 个连接
en: Running, %{count} connections
forward.status.reconnecting:
zh-CN: 连接中断,正在重连(%{attempt}/%{of})
en: Connection lost, reconnecting (%{attempt}/%{of})
forward.status.failed:
zh-CN: 已停止:%{reason}
en: "Stopped: %{reason}"
forward.error.start_failed:
zh-CN: 无法启动端口转发:%{error}
en: "Couldn’t start the port forward: %{error}"
forward.error.ended:
zh-CN: 端口转发意外中止
en: The port forward stopped unexpectedly
forward.error.reconnect_failed:
zh-CN: 连接中断,自动重连失败:%{reason}
en: "The connection was lost and couldn’t be restored: %{reason}"
forward.error.connection_lost:
zh-CN: 与服务器的连接已中断
en: The connection to the server was lost
forward.error.reconnects_used.one:
en: The connection was lost, and %{count} attempt to reconnect failed
forward.error.reconnects_used.other:
zh-CN: 连接中断,重连 %{count} 次均未成功
en: The connection was lost, and all %{count} attempts to reconnect failed
forward.error.remote_listen:
zh-CN: 服务器拒绝在 %{bind} 上监听:端口可能已被占用、1024 以下的端口需要 root,或服务器禁用了端口转发(AllowTcpForwarding)
en: "The server refused to listen on %{bind}: the port may be in use, a port below 1024 needs root, or the server has port forwarding turned off (AllowTcpForwarding)"
forward.error.key_changed:
zh-CN: 主机密钥与已保存的不一致,已阻止连接。请先核实服务器身份
en: The host key doesn’t match the saved one, so the connection was blocked. Verify the server’s identity first.
forward.error.unknown_key:
zh-CN: 尚未信任该主机的密钥,请手动启动以确认
en: The host’s key isn’t trusted yet. Start the forward yourself to confirm it.
forward.error.needs_password:
zh-CN: 需要输入密码,请手动启动
en: A password is needed. Start the forward yourself to enter it.
forward.error.needs_passphrase:
zh-CN: 需要输入私钥口令,请手动启动
en: A passphrase is needed. Start the forward yourself to enter it.
forward.error.needs_interactive:
zh-CN: 服务器要求交互式认证,请手动启动
en: The server asks for interactive authentication. Start the forward yourself to answer it.
forward.error.unresolved_bind:
zh-CN: 无法解析监听地址 %{host}
en: Couldn’t resolve the listening address %{host}
forward.error.port_in_use:
zh-CN: 本机端口 %{port} 已被占用
en: Port %{port} on this computer is already in use
forward.error.port_denied:
zh-CN: 没有权限监听端口 %{port}(1024 以下的端口需要管理员权限)
en: No permission to listen on port %{port} (ports below 1024 need administrator rights)
forward.error.no_such_address:
zh-CN: 本机没有地址 %{host},无法监听
en: This computer has no address %{host} to listen on
forward.error.bind_failed:
zh-CN: 无法监听 %{bind}:%{error}
en: "Couldn’t listen on %{bind}: %{error}"
forward.error.not_socks:
zh-CN: 收到的不是 SOCKS 请求,请把应用的代理类型设为 SOCKS5
en: This isn’t a SOCKS request. Set the app’s proxy type to SOCKS5.
forward.error.local_refused:
zh-CN: 连接被拒绝,该端口上没有服务在监听
en: connection refused, as nothing is listening on that port
forward.error.local_timed_out:
zh-CN: 连接超时
en: the connection timed out
forward.error.local_target_failed:
zh-CN: 无法连接本机目标 %{target}:%{reason}
en: "Couldn’t connect to %{target} on this computer: %{reason}"
forward.error.server_timed_out:
zh-CN: 服务器连接 %{target} 超时
en: The server timed out connecting to %{target}
forward.error.prohibited:
zh-CN: 服务器禁止转发到 %{target}(AllowTcpForwarding 或 PermitOpen 限制)
en: The server doesn’t allow forwarding to %{target} (AllowTcpForwarding or PermitOpen)
forward.error.server_connect_failed:
zh-CN: 服务器无法连接 %{target}
en: The server couldn’t connect to %{target}
forward.error.unsupported:
zh-CN: 服务器不支持端口转发
en: The server doesn’t support port forwarding
forward.error.resource_shortage:
zh-CN: 服务器资源不足,无法建立转发连接
en: The server is short of resources and couldn’t open the connection
forward.error.refused:
zh-CN: 服务器拒绝了到 %{target} 的连接
en: The server refused the connection to %{target}
+61
View File
@@ -0,0 +1,61 @@
_version: 2
# 历史命令: the commands bash kept in the host's ~/.bash_history.
history.panel.search:
zh-CN: 搜索命令
en: Search commands
history.panel.count.one:
en: "%{count} command"
history.panel.count.other:
zh-CN: 共 %{count} 条
en: "%{count} commands"
history.panel.truncated:
zh-CN: 历史文件太长,只读了最后 %{size} KB。
en: The history file is long, so only its last %{size} KB were read.
history.panel.note:
zh-CN: 这个终端里执行的命令,bash 退出后才会出现在这里。
en: Commands run in this terminal show up here once bash exits.
history.panel.empty:
zh-CN: 没有历史命令
en: No history
history.panel.no_match:
zh-CN: 没有符合条件的命令
en: No matching commands
history.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机上 bash 的历史命令。
en: The terminal isn’t connected. Once it is, the host’s bash history shows here.
history.panel.missing:
zh-CN: 这台主机上还没有 bash 的历史命令(~/.bash_history)。bash 退出时才会写入它。
en: This host has no bash history yet (~/.bash_history). bash writes it when it exits.
history.panel.unsupported:
zh-CN: 暂不支持读取这台主机的历史命令,只支持 bash 的 ~/.bash_history。
en: This host’s history can’t be read yet. Only bash’s ~/.bash_history is supported.
history.menu.copy:
zh-CN: 复制
en: Copy
# Under each command: when it last ran, and how often.
history.entry.runs.one:
en: Ran once
history.entry.runs.other:
zh-CN: 执行 %{count} 次
en: Ran %{count} times
history.when.just_now:
zh-CN: 刚刚
en: Just now
history.when.minutes.one:
en: "%{count} minute ago"
history.when.minutes.other:
zh-CN: "%{count} 分钟前"
en: "%{count} minutes ago"
history.when.hours.one:
en: "%{count} hour ago"
history.when.hours.other:
zh-CN: "%{count} 小时前"
en: "%{count} hours ago"
history.when.days.one:
en: "%{count} day ago"
history.when.days.other:
zh-CN: "%{count} 天前"
en: "%{count} days ago"
+613
View File
@@ -0,0 +1,613 @@
_version: 2
# 主机: the saved hosts, their groups and the store that keeps them.
host.state.disconnected:
zh-CN: 未连接
en: Not connected
host.state.connecting:
zh-CN: 连接中
en: Connecting
host.state.connected:
zh-CN: 已连接
en: Connected
host.proxy_kind.http:
zh-CN: HTTP 代理
en: HTTP proxy
host.proxy_kind.socks5:
zh-CN: SOCKS5 代理
en: SOCKS5 proxy
host.draft.name:
zh-CN: 请输入名称
en: Enter a name
host.draft.address:
zh-CN: 请输入地址
en: Enter an address
host.draft.port:
zh-CN: 端口必须是 1 到 65535 之间的数字
en: The port must be a number from 1 to 65535
host.draft.no_jump_hosts:
zh-CN: 请添加跳板主机
en: Add a jump host
host.draft.deleted_jump_host:
zh-CN: 请移除已删除的跳板主机
en: Remove the deleted jump host
host.draft.proxy_address:
zh-CN: 请输入代理地址
en: Enter the proxy address
host.draft.proxy_port:
zh-CN: 代理端口必须是 1 到 65535 之间的数字
en: The proxy port must be a number from 1 to 65535
host.credential_kind.password:
zh-CN: 密码
en: Password
host.credential_kind.key:
zh-CN: 密钥
en: Key
host.credential_draft.name:
zh-CN: 请输入名称
en: Enter a name
host.credential_draft.key_path:
zh-CN: 密钥凭据需要选择私钥文件
en: Choose a private key file for a key credential
host.database.newer:
zh-CN: 数据库来自更新版本的 ShellRS(结构版本 %{version},本版本支持到 %{supported}),请安装最新版本
en: The database is from a newer version of ShellRS (schema version %{version}; this version supports up to %{supported}). Install the latest version.
host.database.too_old:
zh-CN: 数据库来自 ShellRS 的早期开发版本(结构版本 %{version}),无法升级
en: The database is from an early development version of ShellRS (schema version %{version}) and can’t be upgraded.
host.forward_kind.local:
zh-CN: 本地转发
en: Local
host.forward_kind.remote:
zh-CN: 远程转发
en: Remote
host.forward_kind.dynamic:
zh-CN: 动态转发
en: Dynamic
host.forward_draft.bind_host:
zh-CN: 请输入监听地址,地址中不能有空格
en: Enter a listening address without spaces
host.forward_draft.bind_port:
zh-CN: 监听端口应为 1–65535 的数字
en: The listening port must be a number from 1 to 65535
host.forward_draft.target_host:
zh-CN: 请输入目标地址,地址中不能有空格
en: Enter a destination address without spaces
host.forward_draft.target_port:
zh-CN: 目标端口应为 1–65535 的数字
en: The destination port must be a number from 1 to 65535
host.auth.password:
zh-CN: 密码
en: Password
host.auth.credential:
zh-CN: 使用凭据
en: Credential
host.auth.no_password:
zh-CN: 无密码
en: No password
host.route.direct:
zh-CN: 直接连接
en: Direct
host.route.jump:
zh-CN: SSH 跳板
en: Jump hosts
host.route.proxy:
zh-CN: 代理连接
en: Proxy
host.dialog.title_new:
zh-CN: 新建主机
en: New host
host.dialog.title_edit:
zh-CN: 编辑主机
en: Edit host
host.dialog.title_temporary:
zh-CN: 临时连接
en: Quick connect
host.dialog.create:
zh-CN: 创建
en: Create
host.dialog.connect:
zh-CN: 连接
en: Connect
host.dialog.test_connection:
zh-CN: 测试连接
en: Test connection
host.dialog.name:
zh-CN: 名称
en: Name
host.dialog.name_placeholder:
zh-CN: 例如 web-01
en: e.g. web-01
host.dialog.temporary_name_placeholder:
zh-CN: 可选,默认用地址
en: Optional; the address if left empty
host.dialog.address:
zh-CN: 地址
en: Address
host.dialog.address_placeholder:
zh-CN: 主机名或 IP 地址
en: Hostname or IP address
host.dialog.port:
zh-CN: 端口
en: Port
host.dialog.auth:
zh-CN: 认证方式
en: Authentication
host.dialog.no_password_note:
zh-CN: 依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥。
en: Tries logging in without authentication, then the SSH agent, then the default private keys in ~/.ssh.
host.dialog.user:
zh-CN: 用户名
en: Username
host.dialog.password:
zh-CN: 密码
en: Password
host.dialog.credential:
zh-CN: 凭据
en: Credential
host.dialog.choose_credential:
zh-CN: 请选择凭据
en: Choose a credential
host.dialog.search_credentials:
zh-CN: 搜索凭据
en: Search credentials
host.dialog.no_credentials:
zh-CN: 还没有凭据
en: No credentials yet
host.dialog.no_credentials_hint:
zh-CN: 还没有凭据,可在侧栏的「凭据」中新建
en: No credentials yet. Create one in the sidebar’s Credentials list.
host.dialog.credential_option:
zh-CN: "%{name}(%{summary})"
en: "%{name} (%{summary})"
host.dialog.logs_in_with.password:
zh-CN: 以 %{user} 登录,使用凭据保存的密码
en: Logs in as %{user} with the password saved in the credential
host.dialog.logs_in_with.kept_key:
zh-CN: 以 %{user} 登录,使用 ShellRS 保存的私钥
en: Logs in as %{user} with the private key ShellRS keeps
host.dialog.logs_in_with.key:
zh-CN: 以 %{user} 登录,使用私钥 %{path}
en: Logs in as %{user} with the private key %{path}
host.dialog.logs_in_with.agent:
zh-CN: 以 %{user} 登录,使用 SSH Agent 中的密钥
en: Logs in as %{user} with the keys in the SSH agent
host.dialog.enter_user:
zh-CN: 请输入用户名
en: Enter a username
host.dialog.group:
zh-CN: 分组
en: Group
host.dialog.no_group:
zh-CN: (无分组)
en: (No group)
host.dialog.notes:
zh-CN: 备注
en: Notes
host.dialog.optional:
zh-CN: 可选
en: Optional
host.dialog.keychain_note:
zh-CN: 密码保存在系统钥匙串,不会写入 ShellRS 的数据库。
en: Passwords are kept in the system keychain, never in ShellRS’s database.
host.dialog.no_keychain_note:
zh-CN: 系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。
en: The system keychain isn’t available, so passwords can’t be saved on this computer and are asked for at each connection.
host.dialog.reconnect_note:
zh-CN: 保存后连接设置将立即生效并重连。
en: Saving applies the connection settings at once and reconnects.
host.dialog.temporary_note:
zh-CN: 仅当前使用的临时会话,不会保存到主机列表。
en: For this connection only. It won’t be added to your hosts.
host.dialog.route:
zh-CN: 连接方式
en: Connection
host.dialog.jump_note:
zh-CN: 依次经过跳板主机连接到当前主机,可添加多台。
en: Connects to this host through each jump host in turn. You can add several.
host.dialog.this_computer:
zh-CN: 本机
en: This computer
host.dialog.this_host:
zh-CN: 当前主机
en: This host
host.dialog.deleted_host:
zh-CN: 已删除的主机
en: Deleted host
host.dialog.jump_host_title:
zh-CN: "%{name}(%{address})"
en: "%{name} (%{address})"
host.dialog.remove_hop:
zh-CN: 移除
en: Remove
host.dialog.add_jump_host:
zh-CN: 添加跳板主机
en: Add jump host
host.dialog.search_hosts:
zh-CN: 搜索主机
en: Search hosts
host.dialog.no_matching_hosts:
zh-CN: 没有匹配的主机
en: No matching hosts
host.dialog.no_other_hosts:
zh-CN: 还没有其他主机可做跳板
en: No other hosts to jump through
host.dialog.all_hosts_added:
zh-CN: 其他主机都已添加
en: All other hosts are added
host.dialog.proxy_kind:
zh-CN: 代理类型
en: Proxy type
host.dialog.proxy_address:
zh-CN: 代理地址
en: Proxy address
host.dialog.proxy_host_placeholder:
zh-CN: 例如 127.0.0.1
en: e.g. 127.0.0.1
host.dialog.proxy_note:
zh-CN: 目标地址由代理服务器解析;代理不需要认证时,用户名和密码留空。
en: The proxy server resolves the host’s address. Leave the username and password empty if the proxy doesn’t need them.
host.dialog.proxy_password_needs_user:
zh-CN: 填写代理密码时请同时填写用户名
en: Enter a username to go with the proxy password
host.dialog.test_start_failed:
zh-CN: 无法启动连接测试:%{error}
en: "Couldn’t start the connection test: %{error}"
host.dialog.test_aborted:
zh-CN: 连接测试意外中止
en: The connection test stopped unexpectedly
host.test.connected:
zh-CN: 连接成功
en: Connection succeeded
host.test.failed:
zh-CN: 连接失败
en: Couldn’t connect
host.trust.title:
zh-CN: 首次连接此主机
en: First connection to this host
host.trust.trust:
zh-CN: 信任并继续
en: Trust and continue
host.delete.closes_tabs:
zh-CN: 会一并关闭该主机已打开的终端和 SFTP 标签。
en: Its open terminal and SFTP tabs will close.
host.delete.transfers.one:
en: "%{count} transfer batch will stop, with its progress kept for resuming."
host.delete.transfers.other:
zh-CN: 将停止 %{count} 个传输批次并保留续传进度。
en: "%{count} transfer batches will stop, with their progress kept for resuming."
host.delete.forwards.one:
en: "%{count} port forward through this host will also be deleted."
host.delete.forwards.other:
zh-CN: 将同时删除经由该主机的 %{count} 条端口转发。
en: "%{count} port forwards through this host will also be deleted."
host.delete.jump_users.one:
en: "%{count} host uses it as a jump host and will need another one to connect."
host.delete.jump_users.other:
zh-CN: 有 %{count} 台主机把它用作跳板主机,删除后要重新选择跳板主机才能连接。
en: "%{count} hosts use it as a jump host and will need another one to connect."
host.count.hosts.one:
en: "%{count} host"
host.count.hosts.other:
zh-CN: "%{count} 台主机"
en: "%{count} hosts"
host.count.subgroups.one:
en: "%{count} subgroup"
host.count.subgroups.other:
zh-CN: "%{count} 个子分组"
en: "%{count} subgroups"
host.group_dialog.title_new:
zh-CN: 新建分组
en: New group
host.group_dialog.title_rename:
zh-CN: 重命名分组
en: Rename group
host.group_dialog.create:
zh-CN: 创建
en: Create
host.group_dialog.name:
zh-CN: 名称
en: Name
host.group_dialog.name_placeholder:
zh-CN: 例如 生产
en: e.g. Production
host.group_dialog.parent:
zh-CN: 上级分组
en: Parent group
host.group_dialog.top_level:
zh-CN: (顶层)
en: (Top level)
host.group_dialog.enter_name:
zh-CN: 请输入分组名称
en: Enter a group name
host.group_dialog.duplicate:
zh-CN: 同一层级下已有同名分组
en: A group with this name is already at this level
host.group_dialog.into_itself:
zh-CN: 无法把分组移动到它自己的下级
en: A group can’t move into one of its own subgroups
host.delete_group.contents:
zh-CN: 将同时删除其中的 %{items}。
en: This also deletes the group’s %{items}.
host.delete_group.closes_tabs:
zh-CN: 已打开的终端和 SFTP 标签会一并关闭。
en: Open terminal and SFTP tabs will close.
host.delete_group.forwards.one:
en: "%{count} port forward through these hosts will also be deleted."
host.delete_group.forwards.other:
zh-CN: 这些主机的 %{count} 条端口转发会一并删除。
en: "%{count} port forwards through these hosts will also be deleted."
host.delete_group.jump_users.one:
en: "%{count} host outside the group jumps through these hosts and will need another jump host to connect."
host.delete_group.jump_users.other:
zh-CN: 分组外有 %{count} 台主机经由这些主机跳转,删除后要重新选择跳板主机才能连接。
en: "%{count} hosts outside the group jump through these hosts and will need another jump host to connect."
host.panel.title:
zh-CN: 主机
en: Hosts
host.panel.search:
zh-CN: 搜索主机或分组
en: Search hosts or groups
host.menu.connect:
zh-CN: 连接
en: Connect
host.menu.open_sftp:
zh-CN: 打开 SFTP
en: Open SFTP
host.menu.edit_host:
zh-CN: 编辑主机…
en: Edit host…
host.menu.duplicate:
zh-CN: 复制
en: Duplicate
host.menu.copy_ip:
zh-CN: 复制 IP 地址
en: Copy IP address
host.menu.copy_host_name:
zh-CN: 复制主机名
en: Copy hostname
host.menu.copy_id:
zh-CN: 复制 ID
en: Copy ID
host.menu.new_host:
zh-CN: 新建主机…
en: New host…
host.menu.new_group:
zh-CN: 新建分组…
en: New group…
host.menu.connect_group:
zh-CN: 连接组内主机
en: Connect all in group
host.menu.new_subgroup:
zh-CN: 新建子分组…
en: New subgroup…
host.menu.expand_all:
zh-CN: 展开全部分组
en: Expand all groups
host.menu.collapse_all:
zh-CN: 折叠全部分组
en: Collapse all groups
host.menu.rename_group:
zh-CN: 重命名分组…
en: Rename group…
host.menu.delete_group:
zh-CN: 删除分组
en: Delete group
host.pasted_key.empty:
zh-CN: 请粘贴私钥
en: Paste a private key
host.pasted_key.public_key:
zh-CN: 这是公钥,请粘贴私钥(以 -----BEGIN 开头的那一段)
en: This is a public key. Paste the private key, the part that starts with -----BEGIN.
host.pasted_key.unreadable:
zh-CN: 无法识别这段私钥,支持 OpenSSH、PEM 和 PuTTY 格式
en: This private key can’t be read. Use the OpenSSH, PEM or PuTTY format.
host.quick_connect.title:
zh-CN: 快速连接
en: Find a host
host.quick_connect.search:
zh-CN: 搜索主机名称、地址、用户名、备注或分组
en: Search by name, address, username, notes or group
host.quick_connect.no_hosts:
zh-CN: 还没有保存的主机
en: No saved hosts yet
host.quick_connect.no_matches:
zh-CN: 没有匹配的主机
en: No matching hosts
host.quick_connect.choose:
zh-CN: 选中
en: Select
host.quick_connect.choose_all:
zh-CN: 全选
en: Select all
host.quick_connect.connect_chosen:
zh-CN: 批量连接
en: Connect selected
host.secret_fields.ask_each_time:
zh-CN: 留空则每次连接都询问
en: Leave empty to be asked at each connection
host.secret_fields.key_path_placeholder:
zh-CN: 选择 OpenSSH 私钥文件
en: Choose an OpenSSH private key file
host.secret_fields.choose_key_prompt:
zh-CN: 选择 SSH 私钥
en: Choose an SSH private key
host.secret_fields.choose:
zh-CN: 选择…
en: Choose…
host.snippet_draft.name:
zh-CN: 请输入名称
en: Enter a name
host.snippet_draft.command:
zh-CN: 请输入命令
en: Enter a command
host.link.missing:
zh-CN: 启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口](打开 SFTP 用 sftp://)。
en: The launch arguments have no address to connect to. Use ssh://user@address[:port], or sftp:// to open SFTP.
host.link.malformed:
zh-CN: 链接的格式不对,应为 ssh://用户[:密码]@地址[:端口](打开 SFTP 用 sftp://)。
en: The link isn’t in the right format. Use ssh://user[:password]@address[:port], or sftp:// to open SFTP.
host.link.invalid_port:
zh-CN: 链接里的端口无效。
en: The port in the link isn’t valid.
host.link.no_address:
zh-CN: 链接里没有地址。
en: The link has no address.
host.link.unsupported_scheme:
zh-CN: 只支持 ssh:// 和 sftp:// 链接,不支持 %{scheme}://。
en: Only ssh:// and sftp:// links are supported, not %{scheme}://.
host.link.no_user:
zh-CN: 链接里没有用户名,应为 %{scheme}://用户@地址[:端口]。
en: The link has no username. Use %{scheme}://user@address[:port].
host.link.unreadable:
zh-CN: 链接里有无法识别的字符。
en: The link has characters that can’t be read.
# 本地数据库: a change that stands in memory but is not on disk.
host.store.not_saved.new_host:
zh-CN: 新建主机未能保存到本地数据库:%{error}
en: "Couldn’t save the new host to the local database: %{error}"
host.store.not_saved.save_host:
zh-CN: 保存主机未能保存到本地数据库:%{error}
en: "Couldn’t save the host’s changes to the local database: %{error}"
host.store.not_saved.delete_host:
zh-CN: 删除主机未能保存到本地数据库:%{error}
en: "Couldn’t delete the host from the local database: %{error}"
host.store.not_saved.duplicate_host:
zh-CN: 复制主机未能保存到本地数据库:%{error}
en: "Couldn’t save the host’s copy to the local database: %{error}"
host.store.not_saved.host_order:
zh-CN: 保存主机顺序未能保存到本地数据库:%{error}
en: "Couldn’t save the order of the hosts to the local database: %{error}"
host.store.not_saved.new_group:
zh-CN: 新建分组未能保存到本地数据库:%{error}
en: "Couldn’t save the new group to the local database: %{error}"
host.store.not_saved.save_group:
zh-CN: 保存分组未能保存到本地数据库:%{error}
en: "Couldn’t save the group’s changes to the local database: %{error}"
host.store.not_saved.add_bookmark:
zh-CN: 添加书签未能保存到本地数据库:%{error}
en: "Couldn’t save the new bookmark to the local database: %{error}"
host.store.not_saved.delete_bookmark:
zh-CN: 删除书签未能保存到本地数据库:%{error}
en: "Couldn’t delete the bookmark from the local database: %{error}"
host.store.not_saved.bookmark_order:
zh-CN: 调整书签顺序未能保存到本地数据库:%{error}
en: "Couldn’t save the order of the bookmarks to the local database: %{error}"
host.store.not_saved.new_forward:
zh-CN: 新建端口转发未能保存到本地数据库:%{error}
en: "Couldn’t save the new port forward to the local database: %{error}"
host.store.not_saved.save_forward:
zh-CN: 保存端口转发未能保存到本地数据库:%{error}
en: "Couldn’t save the port forward’s changes to the local database: %{error}"
host.store.not_saved.delete_forward:
zh-CN: 删除端口转发未能保存到本地数据库:%{error}
en: "Couldn’t delete the port forward from the local database: %{error}"
host.store.not_saved.new_credential:
zh-CN: 新建凭据未能保存到本地数据库:%{error}
en: "Couldn’t save the new credential to the local database: %{error}"
host.store.not_saved.save_credential:
zh-CN: 保存凭据未能保存到本地数据库:%{error}
en: "Couldn’t save the credential’s changes to the local database: %{error}"
host.store.not_saved.delete_credential:
zh-CN: 删除凭据未能保存到本地数据库:%{error}
en: "Couldn’t delete the credential from the local database: %{error}"
host.store.not_saved.group_expanded:
zh-CN: 保存分组展开状态未能保存到本地数据库:%{error}
en: "Couldn’t save whether the group is expanded to the local database: %{error}"
host.store.not_saved.all_groups_expanded:
zh-CN: 保存所有分组展开状态未能保存到本地数据库:%{error}
en: "Couldn’t save whether the groups are expanded to the local database: %{error}"
host.store.not_saved.move_node:
zh-CN: 调整主机顺序未能保存到本地数据库:%{error}
en: "Couldn’t save the rearranged host list to the local database: %{error}"
host.store.not_saved.delete_group:
zh-CN: 删除分组未能保存到本地数据库:%{error}
en: "Couldn’t delete the group from the local database: %{error}"
host.store.not_saved.recent:
zh-CN: 记录最近连接未能保存到本地数据库:%{error}
en: "Couldn’t save the recent connections to the local database: %{error}"
host.store.not_saved.host_os:
zh-CN: 记录主机系统未能保存到本地数据库:%{error}
en: "Couldn’t save the host’s operating system to the local database: %{error}"
host.store.not_saved.new_snippet:
zh-CN: 新建命令片段未能保存到本地数据库:%{error}
en: "Couldn’t save the new snippet to the local database: %{error}"
host.store.not_saved.save_snippet:
zh-CN: 保存命令片段未能保存到本地数据库:%{error}
en: "Couldn’t save the snippet’s changes to the local database: %{error}"
host.store.not_saved.delete_snippet:
zh-CN: 删除命令片段未能保存到本地数据库:%{error}
en: "Couldn’t delete the snippet from the local database: %{error}"
host.store.not_saved.new_snippet_category:
zh-CN: 新建片段分类未能保存到本地数据库:%{error}
en: "Couldn’t save the new snippet category to the local database: %{error}"
host.store.not_saved.rename_snippet_category:
zh-CN: 重命名片段分类未能保存到本地数据库:%{error}
en: "Couldn’t save the renamed snippet category to the local database: %{error}"
host.store.not_saved.delete_snippet_category:
zh-CN: 删除片段分类未能保存到本地数据库:%{error}
en: "Couldn’t delete the snippet category from the local database: %{error}"
host.store.copy_name:
zh-CN: "%{name} 副本"
en: "%{name} copy"
host.store.no_key_dir:
zh-CN: 没有可以保存私钥的目录
en: There’s no folder to keep private keys in
host.store.key_not_deleted:
zh-CN: ShellRS 保存的私钥未能删除:%{error}
en: "Couldn’t delete a private key ShellRS keeps: %{error}"
host.store.secret.password_write:
zh-CN: 密码未能写入系统钥匙串:%{error}
en: "Couldn’t save the password to the system keychain: %{error}"
host.store.secret.password_delete:
zh-CN: 密码未能从系统钥匙串删除:%{error}
en: "Couldn’t delete the password from the system keychain: %{error}"
host.store.secret.passphrase_write:
zh-CN: 私钥口令未能写入系统钥匙串:%{error}
en: "Couldn’t save the private key’s passphrase to the system keychain: %{error}"
host.store.secret.passphrase_delete:
zh-CN: 私钥口令未能从系统钥匙串删除:%{error}
en: "Couldn’t delete the private key’s passphrase from the system keychain: %{error}"
host.store.secret.credential_write:
zh-CN: 凭据的密码未能写入系统钥匙串:%{error}
en: "Couldn’t save the credential’s password to the system keychain: %{error}"
host.store.secret.credential_delete:
zh-CN: 凭据的密码未能从系统钥匙串删除:%{error}
en: "Couldn’t delete the credential’s password from the system keychain: %{error}"
host.store.secret.proxy_write:
zh-CN: 代理的密码未能写入系统钥匙串:%{error}
en: "Couldn’t save the proxy’s password to the system keychain: %{error}"
host.store.secret.proxy_delete:
zh-CN: 代理的密码未能从系统钥匙串删除:%{error}
en: "Couldn’t delete the proxy’s password from the system keychain: %{error}"
host.store.secret.temporary:
zh-CN: 这次连接的密码未能记下:%{error}
en: "Couldn’t keep the password for this connection: %{error}"
+92
View File
@@ -0,0 +1,92 @@
_version: 2
# 系统监控: a Linux host's CPU, memory, network and disks.
monitor.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机的 CPU、内存、网络和磁盘。
en: The terminal isn’t connected. Once it is, the host’s CPU, memory, network and disks show here.
monitor.panel.unsupported_system:
zh-CN: 暂不支持 %{system} 的监控,目前只支持 Linux 主机。
en: Monitoring %{system} isn’t supported yet. Only Linux hosts are supported.
monitor.panel.unsupported:
zh-CN: 暂不支持这台主机的监控,目前只支持 Linux 主机。
en: Monitoring this host isn’t supported yet. Only Linux hosts are supported.
monitor.system.title:
zh-CN: 系统
en: System
monitor.system.host:
zh-CN: 主机
en: Host
monitor.system.arch:
zh-CN: 架构
en: Architecture
monitor.system.os:
zh-CN: 系统
en: OS
monitor.system.uptime:
zh-CN: 运行时长
en: Uptime
monitor.cpu.cores.one:
en: "%{count} core"
monitor.cpu.cores.other:
zh-CN: "%{count} 核"
en: "%{count} cores"
monitor.cpu.unfold_cores:
zh-CN: 显示更多核的占用
en: Show every core
monitor.cpu.fold_cores:
zh-CN: 收起更多核的占用
en: Show fewer cores
monitor.cpu.average:
zh-CN: 平均使用率
en: Average
# 「核 0:100.0%,核 1:33.3%」: each core, and the list they make.
monitor.cpu.core:
zh-CN: 核 %{index}:%{load}
en: "Core %{index}: %{load}"
monitor.cpu.core_list:
zh-CN: "%{list},%{core}"
en: "%{list}, %{core}"
monitor.memory.title:
zh-CN: 内存
en: Memory
monitor.memory.physical:
zh-CN: 物理内存
en: Physical memory
monitor.memory.swap:
zh-CN: 交换空间
en: Swap
monitor.network.title:
zh-CN: 网络
en: Network
monitor.network.interfaces.one:
en: "%{count} interface"
monitor.network.interfaces.other:
zh-CN: "%{count} 个网卡"
en: "%{count} interfaces"
monitor.network.unfold:
zh-CN: 显示全部网卡
en: Show all interfaces
monitor.network.fold:
zh-CN: 只显示主网卡
en: Show only the main interface
monitor.network.none:
zh-CN: 没有在用的网卡
en: No interfaces in use
monitor.network.label:
zh-CN: 上传 %{up},下载 %{down}
en: Up %{up}, down %{down}
monitor.disks.title:
zh-CN: 磁盘
en: Disks
monitor.disks.none:
zh-CN: 没有读到磁盘
en: No disks found
monitor.disks.label:
zh-CN: "%{percent},%{used} / %{total}"
en: "%{percent}, %{used} / %{total}"
+100
View File
@@ -0,0 +1,100 @@
_version: 2
# 网络连接: a Linux host's sockets, as `ss` lists them.
netstat.panel.search:
zh-CN: 搜索地址、端口、状态、PID 或进程
en: Address, port, state, PID or process
netstat.panel.truncated:
zh-CN: 连接太多,只列出了前 %{limit} 条。
en: There are too many sockets, so only the first %{limit} are listed.
netstat.panel.own_processes:
zh-CN: 当前用户不是 root,只能看到自己的进程。
en: You’re not logged in as root, so only your own processes show.
netstat.panel.no_ss:
zh-CN: 这台主机没有 ss 命令,看不到占用连接的进程。
en: This host has no ss command, so the processes behind the sockets don’t show.
netstat.panel.protocol:
zh-CN: 协议
en: Protocol
netstat.panel.state:
zh-CN: 状态
en: State
netstat.panel.matches.one:
en: "%{count} match"
netstat.panel.matches.other:
zh-CN: 筛选出 %{count} 条
en: "%{count} matches"
netstat.panel.empty:
zh-CN: 没有网络连接
en: No sockets
netstat.panel.no_match:
zh-CN: 没有符合条件的连接
en: No matching sockets
netstat.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机的网络连接。
en: The terminal isn’t connected. Once it is, the host’s sockets show here.
netstat.panel.unsupported_system:
zh-CN: 暂不支持查看 %{system} 的网络连接,目前只支持 Linux 主机。
en: Sockets on %{system} can’t be listed yet. Only Linux hosts are supported.
netstat.panel.unsupported:
zh-CN: 暂不支持查看这台主机的网络连接,目前只支持 Linux 主机。
en: This host’s sockets can’t be listed yet. Only Linux hosts are supported.
# 「共 96 条 · 15 个监听端口 · 21 条已连接」.
netstat.summary.total.one:
en: "%{count} socket"
netstat.summary.total.other:
zh-CN: 共 %{count} 条
en: "%{count} sockets"
netstat.summary.listening.one:
en: "%{count} listening port"
netstat.summary.listening.other:
zh-CN: "%{count} 个监听端口"
en: "%{count} listening ports"
netstat.summary.connected.other:
zh-CN: "%{count} 条已连接"
en: "%{count} established"
netstat.filter.all_protocols:
zh-CN: 全部协议
en: All protocols
netstat.filter.all_states:
zh-CN: 全部状态
en: All states
netstat.filter.other_states:
zh-CN: 其他状态
en: Other states
# The two states said in words; the rest keep their TCP names.
netstat.state.listen:
zh-CN: 监听
en: Listening
netstat.state.established:
zh-CN: 已连接
en: Established
netstat.role.listening:
zh-CN: 监听端口
en: Listening port
netstat.role.inbound:
zh-CN: 入站连接
en: Inbound
netstat.role.outbound:
zh-CN: 出站连接
en: Outbound
netstat.card.label:
zh-CN: "%{protocol} %{state} · 本地 %{local} · 远端 %{peer} · %{process} · %{user}"
en: "%{protocol} %{state} · Local %{local} · Remote %{peer} · %{process} · %{user}"
netstat.card.local:
zh-CN: 本地
en: Local
netstat.card.peer:
zh-CN: 远端
en: Remote
netstat.card.process:
zh-CN: 进程
en: Process
netstat.process.several:
zh-CN: "%{name} (PID %{pid} 等 %{count} 个)"
en: "%{name} (PID %{pid} and others, %{count} in all)"
+166
View File
@@ -0,0 +1,166 @@
_version: 2
# 进程管理: a Linux host's processes.
processes.panel.search:
zh-CN: 搜索进程名称、PID 或用户
en: Search names, PIDs and users
processes.panel.summary.one:
en: "%{count} process · refreshed every %{seconds} s"
processes.panel.summary.other:
zh-CN: 共 %{count} 个进程 · 每 %{seconds} 秒刷新
en: "%{count} processes · refreshed every %{seconds} s"
processes.panel.truncated:
zh-CN: 进程太多,只列出了前 %{limit} 个。
en: There are too many processes, so only the first %{limit} are listed.
processes.panel.empty:
zh-CN: 没有进程
en: No processes
processes.panel.no_match:
zh-CN: 没有符合条件的进程
en: No matching processes
processes.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机的进程。
en: The terminal isn’t connected. Once it is, the host’s processes show here.
processes.panel.unsupported_system:
zh-CN: 暂不支持管理 %{system} 的进程,目前只支持 Linux 主机。
en: Processes on %{system} can’t be managed yet. Only Linux hosts are supported.
processes.panel.unsupported:
zh-CN: 暂不支持管理这台主机的进程,目前只支持 Linux 主机。
en: This host’s processes can’t be managed yet. Only Linux hosts are supported.
processes.memory:
zh-CN: 内存
en: Memory
processes.card.label:
zh-CN: "%{name} · PID %{pid} · %{state} · %{user} · 内存 %{memory} · CPU %{cpu}"
en: "%{name} · PID %{pid} · %{state} · %{user} · Memory %{memory} · CPU %{cpu}"
processes.sort.memory_descending:
zh-CN: 按内存从多到少排列
en: Sort by memory, most first
processes.sort.memory_ascending:
zh-CN: 改为按内存从少到多排列
en: Sort by memory, least first
processes.sort.cpu_descending:
zh-CN: 按CPU从多到少排列
en: Sort by CPU, most first
processes.sort.cpu_ascending:
zh-CN: 改为按CPU从少到多排列
en: Sort by CPU, least first
processes.menu.end:
zh-CN: 结束进程…
en: End process…
processes.menu.kill:
zh-CN: 强制结束进程…
en: Kill process…
# What a process is doing: the letter in /proc/<pid>/stat.
processes.state.running:
zh-CN: 运行中
en: Running
processes.state.sleeping:
zh-CN: 休眠
en: Sleeping
processes.state.uninterruptible:
zh-CN: 等待 I/O
en: Waiting for I/O
processes.state.stopped:
zh-CN: 已暂停
en: Stopped
processes.state.zombie:
zh-CN: 僵尸进程
en: Zombie
processes.state.idle:
zh-CN: 空闲
en: Idle
processes.state.misc:
zh-CN: 其他
en: Other
processes.error.not_permitted:
zh-CN: 没有权限:只能结束自己的进程,root 才能结束所有进程
en: "Permission denied: only root can end another user’s process"
processes.error.gone:
zh-CN: 进程已经退出
en: The process has already exited
processes.error.status:
zh-CN: kill 返回 %{status}
en: kill exited with status %{status}
# A process's details.
processes.details.info:
zh-CN: 进程信息
en: Overview
processes.details.command:
zh-CN: 完整命令
en: Command line
processes.details.copy_pid:
zh-CN: 复制 PID
en: Copy PID
processes.details.copy_command:
zh-CN: 复制命令
en: Copy command
processes.details.copied:
zh-CN: 已复制
en: Copied
processes.details.end:
zh-CN: 结束进程
en: End process
processes.details.kill:
zh-CN: 强制结束
en: Kill
processes.details.none:
zh-CN: 无
en: None
processes.details.no_command:
zh-CN: 没有命令行:进程已经退出,或是僵尸进程
en: No command line. The process has exited, or it’s a zombie.
processes.field.pid:
zh-CN: PID
en: PID
processes.field.parent:
zh-CN: 父进程
en: Parent
processes.field.user:
zh-CN: 用户
en: User
processes.field.state:
zh-CN: 状态
en: State
processes.field.started:
zh-CN: 启动时间
en: Started
processes.field.running:
zh-CN: 运行时长
en: Elapsed
processes.field.terminal:
zh-CN: 终端
en: TTY
processes.field.priority:
zh-CN: 优先级
en: Priority
processes.field.nice:
zh-CN: Nice
en: Nice
processes.field.cpu:
zh-CN: CPU 使用率
en: CPU usage
processes.field.cpu_time:
zh-CN: 累计 CPU 时间
en: CPU time
processes.field.memory:
zh-CN: 常驻内存
en: Memory (RSS)
processes.field.virtual_memory:
zh-CN: 虚拟内存
en: Virtual memory
processes.field.threads:
zh-CN: 线程
en: Threads
processes.field.children:
zh-CN: 直接子进程
en: Children
processes.field.descendants:
zh-CN: 全部后代进程
en: Descendants
+38
View File
@@ -0,0 +1,38 @@
_version: 2
# 钥匙串: what the system keychain says when it fails.
secrets.unavailable:
zh-CN: 系统钥匙串不可用,密码无法保存
en: The system keychain isn’t available, so the password can’t be saved
secrets.keychain.open_failed:
zh-CN: 打开系统钥匙串条目失败:%{reason}
en: "Couldn’t open the system keychain entry: %{reason}"
secrets.keychain.read_failed:
zh-CN: 读取系统钥匙串条目失败:%{reason}
en: "Couldn’t read the system keychain entry: %{reason}"
secrets.keychain.write_failed:
zh-CN: 写入系统钥匙串条目失败:%{reason}
en: "Couldn’t write the system keychain entry: %{reason}"
secrets.keychain.delete_failed:
zh-CN: 删除系统钥匙串条目失败:%{reason}
en: "Couldn’t delete the system keychain entry: %{reason}"
secrets.keychain.no_store:
zh-CN: 系统钥匙串不可用
en: the system keychain isn’t available
secrets.keychain.no_access:
zh-CN: 系统钥匙串被锁定或拒绝访问
en: the system keychain is locked or denied access
secrets.keychain.bad_encoding:
zh-CN: 已保存的内容不是有效的 UTF-8 文本
en: what’s saved isn’t valid UTF-8 text
secrets.keychain.ambiguous.one:
en: the keychain has %{count} entry with this name
secrets.keychain.ambiguous.other:
zh-CN: 钥匙串里有 %{count} 条同名条目
en: the keychain has %{count} entries with this name
secrets.keychain.too_long:
zh-CN: 超过系统钥匙串 %{limit} 个字符的长度上限
en: it’s longer than the system keychain’s limit of %{limit} characters
+251
View File
@@ -0,0 +1,251 @@
_version: 2
# 系统服务: a Linux host's systemd services. systemd's states keep the
# words systemd uses.
services.panel.search:
zh-CN: 搜索服务名称或描述
en: Search names and descriptions
services.panel.empty:
zh-CN: 没有服务
en: No services
services.panel.no_match:
zh-CN: 没有符合条件的服务
en: No matching services
services.panel.no_description:
zh-CN: 无描述
en: No description
services.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机的系统服务。
en: The terminal isn’t connected. Once it is, the host’s services show here.
services.panel.no_systemd:
zh-CN: 这台主机没有使用 systemd,暂不支持管理它的服务。
en: This host doesn’t use systemd, so its services can’t be managed yet.
services.panel.unsupported_system:
zh-CN: 暂不支持管理 %{system} 的服务,目前只支持使用 systemd 的 Linux 主机。
en: Services on %{system} can’t be managed yet. Only Linux hosts with systemd are supported.
services.panel.unsupported:
zh-CN: 暂不支持管理这台主机的服务,目前只支持使用 systemd 的 Linux 主机。
en: This host’s services can’t be managed yet. Only Linux hosts with systemd are supported.
services.group.custom:
zh-CN: 自定义服务
en: Custom services
services.group.system:
zh-CN: 系统服务
en: System services
services.filter.all:
zh-CN: 全部
en: All
services.filter.running:
zh-CN: 运行中
en: Active
services.filter.stopped:
zh-CN: 已停止
en: Inactive
services.filter.failed:
zh-CN: 失败
en: Failed
services.command.start:
zh-CN: 启动
en: Start
services.command.stop:
zh-CN: 停止
en: Stop
services.command.restart:
zh-CN: 重启
en: Restart
services.command.enable:
zh-CN: 启用开机启动
en: Enable at boot
services.command.disable:
zh-CN: 禁用开机启动
en: Disable at boot
# systemd's ActiveState.
services.active.active:
zh-CN: 运行中
en: Active
services.active.reloading:
zh-CN: 正在重载
en: Reloading
services.active.inactive:
zh-CN: 已停止
en: Inactive
services.active.failed:
zh-CN: 失败
en: Failed
services.active.activating:
zh-CN: 正在启动
en: Activating
services.active.deactivating:
zh-CN: 正在停止
en: Deactivating
# systemd's SubState.
services.sub.running:
zh-CN: 运行中
en: Running
services.sub.exited:
zh-CN: 已退出
en: Exited
services.sub.dead:
zh-CN: 未运行
en: Dead
services.sub.failed:
zh-CN: 失败
en: Failed
services.sub.waiting:
zh-CN: 等待中
en: Waiting
services.sub.listening:
zh-CN: 监听中
en: Listening
services.sub.reload:
zh-CN: 正在重载
en: Reloading
services.sub.auto_restart:
zh-CN: 等待自动重启
en: Waiting to restart
services.sub.condition:
zh-CN: 条件不满足
en: Condition not met
services.sub.starting:
zh-CN: 正在启动
en: Starting
services.sub.stopping:
zh-CN: 正在停止
en: Stopping
# systemd's UnitFileState: whether it starts at boot.
services.file.enabled:
zh-CN: 已启用
en: Enabled
services.file.enabled_runtime:
zh-CN: 已启用(本次运行)
en: Enabled until reboot
services.file.disabled:
zh-CN: 已禁用
en: Disabled
services.file.static:
zh-CN: 静态
en: Static
services.file.masked:
zh-CN: 已屏蔽
en: Masked
services.file.generated:
zh-CN: 自动生成
en: Generated
services.file.indirect:
zh-CN: 间接启用
en: Indirect
services.file.linked:
zh-CN: 已链接
en: Linked
services.file.transient:
zh-CN: 临时
en: Transient
services.file.alias:
zh-CN: 别名
en: Alias
services.file.bad:
zh-CN: 出错
en: Bad
# systemd's LoadState.
services.load.loaded:
zh-CN: 已加载
en: Loaded
services.load.not_found:
zh-CN: 未找到
en: Not found
services.load.masked:
zh-CN: 已屏蔽
en: Masked
services.load.error:
zh-CN: 出错
en: Error
services.load.bad_setting:
zh-CN: 配置有误
en: Bad setting
# `systemctl is-system-running`.
services.system.running:
zh-CN: 运行正常
en: Running
services.system.degraded:
zh-CN: 降级运行
en: Degraded
services.system.starting:
zh-CN: 正在启动
en: Starting
services.system.initializing:
zh-CN: 正在初始化
en: Initializing
services.system.maintenance:
zh-CN: 维护模式
en: Maintenance
services.system.stopping:
zh-CN: 正在关机
en: Shutting down
services.system.offline:
zh-CN: 离线
en: Offline
services.error.needs_root:
zh-CN: 需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo
en: "Needs root: log in as root, or give this user passwordless sudo"
services.error.needs_root_no_sudo:
zh-CN: 需要 root 权限:这台主机没有 sudo,请以 root 登录
en: "Needs root: this host has no sudo, so log in as root"
services.error.status:
zh-CN: systemctl 返回 %{status}
en: systemctl exited with status %{status}
# A service's details.
services.details.tab_status:
zh-CN: 状态
en: Status
services.details.tab_journal:
zh-CN: 日志
en: Journal
services.details.unreadable:
zh-CN: 读不到这个服务的状态
en: Couldn’t read this service’s state
services.details.no_journal:
zh-CN: 没有日志
en: No journal entries
services.field.load:
zh-CN: 加载状态
en: Load state
services.field.active:
zh-CN: 运行状态
en: Active state
services.field.boot:
zh-CN: 开机启动
en: At boot
services.field.main_pid:
zh-CN: 主进程 PID
en: Main PID
services.field.memory:
zh-CN: 内存
en: Memory
services.field.tasks:
zh-CN: 任务数
en: Tasks
services.field.restarts:
zh-CN: 重启次数
en: Restarts
services.field.exit_status:
zh-CN: 退出状态
en: Exit status
services.field.started:
zh-CN: 启动时间
en: Started
services.field.stopped:
zh-CN: 停止时间
en: Stopped
services.field.unit_file:
zh-CN: Unit 文件
en: Unit file
+387
View File
@@ -0,0 +1,387 @@
_version: 2
settings.choice.system:
zh-CN: 跟随系统
en: System
settings.appearance.dark:
zh-CN: 深色
en: Dark
settings.appearance.light:
zh-CN: 浅色
en: Light
settings.channel.stable:
zh-CN: 稳定版
en: Stable
settings.store.unreadable:
zh-CN: 无法读取设置,本次使用默认设置:%{error}
en: "Couldn’t read the settings, so this session uses the defaults: %{error}"
settings.store.unsaved:
zh-CN: 无法保存设置,本次运行的改动不会被保存:%{error}
en: "Settings can’t be saved, so changes last only until ShellRS quits: %{error}"
settings.store.save_failed:
zh-CN: 无法保存设置:%{error}
en: "Couldn’t save the settings: %{error}"
settings.tab.title:
zh-CN: 设置
en: Settings
settings.preview:
zh-CN: 预览
en: Preview
# A choice that is what ShellRS uses unless told otherwise: 「13(默认)」.
settings.default_option:
zh-CN: "%{value}(默认)"
en: "%{value} (default)"
settings.category.appearance:
zh-CN: 外观
en: Appearance
settings.category.terminal:
zh-CN: 终端
en: Terminal
settings.category.highlight:
zh-CN: 关键字高亮
en: Highlighting
settings.category.shortcuts:
zh-CN: 键盘快捷键
en: Keyboard shortcuts
settings.category.external_cli:
zh-CN: 外部 CLI
en: External CLI
settings.category.application:
zh-CN: 应用
en: General
settings.category.about:
zh-CN: 关于
en: About
settings.appearance.interface:
zh-CN: 常规
en: Interface
settings.appearance.language:
zh-CN: 界面语言
en: Language
settings.appearance.mode:
zh-CN: 应用外观
en: Appearance
settings.appearance.theme:
zh-CN: 主题
en: Theme
settings.appearance.theme_description:
zh-CN: 浅色外观时用左栏选中的主题,深色外观时用右栏的。
en: The light appearance uses the theme chosen on the left, the dark appearance the one on the right.
settings.terminal.font:
zh-CN: 字体配置
en: Font
settings.terminal.family:
zh-CN: 字体
en: Font family
settings.terminal.family_description:
zh-CN: 只列出等宽字体。
en: Only monospaced fonts are listed.
settings.terminal.size:
zh-CN: 字号
en: Font size
settings.terminal.size_description:
zh-CN: 以像素计。
en: In pixels.
settings.terminal.line_height:
zh-CN: 行高
en: Line height
settings.terminal.line_height_description:
zh-CN: 字号的倍数。
en: A multiple of the font size.
settings.terminal.preview_keywords:
zh-CN: 预览,字体,字号,行高
en: preview,font,size,line height
settings.interaction.title:
zh-CN: 终端交互
en: Interaction
settings.interaction.copy_on_select:
zh-CN: 选中复制
en: Copy on select
settings.interaction.copy_on_select_description:
zh-CN: 选中文本自动进入系统剪贴板,无需按快捷键。
en: Selected text is copied to the clipboard without pressing a shortcut.
settings.interaction.right_click:
zh-CN: 右键行为
en: Right-click
settings.interaction.right_click_description:
zh-CN: 设置鼠标右键在终端中的行为。
en: What right-clicking in a terminal does.
settings.right_click.nothing:
zh-CN: 无动作
en: Do nothing
settings.right_click.paste:
zh-CN: 粘贴
en: Paste
settings.right_click.menu:
zh-CN: 显示菜单
en: Show menu
settings.notifications.title:
zh-CN: 通知
en: Notifications
settings.notifications.description:
zh-CN: ShellRS 不在前台时发系统通知;在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。
en: When ShellRS is in the background, you get a system notification. When it’s in front but the terminal is in another tab, a notice appears at the top right of the window; click it to go there.
settings.notifications.programs:
zh-CN: 程序发送的通知
en: Notifications from programs
settings.notifications.programs_description:
zh-CN: 远程或本地程序用 OSC 9 / 777 请求的通知,例如 printf '\e]9;完成\a'。
en: Notifications that remote or local programs ask for with OSC 9 / 777, for example printf '\e]9;Done\a'.
settings.notifications.bell:
zh-CN: 响铃时通知
en: Notify on bell
settings.notifications.bell_description:
zh-CN: 终端就在当前标签时不提示。
en: Not when the terminal is the current tab.
settings.highlight.options:
zh-CN: 常规
en: Options
settings.highlight.enabled:
zh-CN: 启用关键字高亮
en: Highlight keywords
settings.highlight.enabled_description:
zh-CN: 全屏程序(vim、less、tmux 等)里不高亮。
en: Not in full-screen programs such as vim, less and tmux.
settings.highlight.preview_description:
zh-CN: 使用当前终端主题
en: In the current terminal theme
settings.highlight.preview_keywords:
zh-CN: 预览,高亮
en: preview,highlight
settings.highlight.rules:
zh-CN: 规则(%{count})
en: Rules (%{count})
settings.highlight.rules_description:
zh-CN: 规则按从上到下应用,勾选「通知」的规则在看不到终端时弹出提醒。
en: Rules apply from top to bottom. A rule with Notify checked alerts you when its terminal isn’t in view.
settings.highlight.rules_keywords:
zh-CN: 规则,正则,关键字,高亮,颜色,备注,通知,排序
en: rule,regex,keyword,highlight,color,note,notify,order
settings.window.title:
zh-CN: 窗口
en: Window
settings.window.remember_size:
zh-CN: 记住窗口大小
en: Remember window size
settings.window.remember_size_description:
zh-CN: 下次启动时恢复上次关闭时的窗口大小和最大化状态。
en: The next launch restores the window’s size, maximized or not, as it was when it closed.
settings.window.remember_position:
zh-CN: 记住窗口位置
en: Remember window position
settings.window.remember_position_description:
zh-CN: 下次启动时把窗口放回上次所在的屏幕和位置;那块屏幕不在时放在主屏幕中间。
en: The next launch puts the window back on the same screen and in the same place. If that screen is gone, it opens in the middle of the main screen.
settings.cli.access:
zh-CN: 访问控制
en: Access
settings.cli.enabled:
zh-CN: 启用外部 CLI
en: Enable external CLI
settings.cli.enabled_description:
zh-CN: 开启后,本机当前用户下的程序(如 AI Agent)可以通过 shellrs 命令,在已保存的主机上执行命令、传输和同步文件,并查看、新建、修改和删除主机与凭据,无需知道密码。
en: When on, programs running as you on this computer, such as AI agents, can use the shellrs command to run commands and transfer and sync files on saved hosts, and to view, create, change and delete hosts and credentials, without knowing any password.
settings.cli.binary:
zh-CN: CLI 二进制
en: Command
settings.cli.binary_description:
zh-CN: 把 shellrs 命令加入 PATH,方便在终端和 Agent 中直接调用。
en: Puts the shellrs command on the PATH so terminals and agents can call it directly.
settings.cli.binary_description_windows:
zh-CN: 把 shellrs 命令加入当前用户的 PATH,方便在终端和 Agent 中直接调用。重新打开的终端和 Agent 才能找到它。
en: Puts the shellrs command on your user PATH so terminals and agents can call it directly. Terminals and agents find it once reopened.
settings.cli.skills_description:
zh-CN: 安装 shellrs skill,让外部 Agent 在连接服务器、执行远程命令和传输文件时使用 shellrs CLI。可安装到通用目录或指定 Agent 的专用目录。
en: Install the shellrs skill so agents use the shellrs CLI to connect to servers, run remote commands and transfer files. Install it to the shared folder or to a particular agent’s own folder.
settings.cli.status:
zh-CN: 状态:
en: "Status:"
settings.cli.checking:
zh-CN: 正在检查…
en: Checking…
settings.cli.binary_unsupported:
zh-CN: 此系统暂不支持安装 shellrs 命令
en: The shellrs command can’t be installed on this system yet
settings.cli.skill_unsupported:
zh-CN: 此系统暂不支持安装
en: Can’t be installed on this system yet
settings.cli.missing:
zh-CN: 未安装(%{path})
en: Not installed (%{path})
settings.cli.outdated:
zh-CN: 已安装旧版本(%{path})
en: An older version is installed (%{path})
settings.cli.binary_stale:
zh-CN: "%{path} 指向 %{target},已失效或不是这个 ShellRS"
en: "%{path} points to %{target}, which is gone or isn’t this ShellRS"
settings.cli.binary_occupied_by:
zh-CN: "%{path} 已被其他程序占用(指向 %{target})"
en: "%{path} is taken by another program (it points to %{target})"
settings.cli.binary_occupied:
zh-CN: "%{path} 已被其他程序占用"
en: "%{path} is taken by another program"
settings.cli.binary_not_on_path:
zh-CN: "%{path} 所在的文件夹不在 PATH 中"
en: The folder of %{path} isn’t on the PATH
settings.cli.binary_unavailable:
zh-CN: 找不到 shellrs-cli.exe:它应在 ShellRS 程序旁边(开发时请先 cargo build)
en: Can’t find shellrs-cli.exe. It belongs next to the ShellRS program (in development, run cargo build first).
settings.cli.installed_at:
zh-CN: 已安装于
en: Installed at
settings.cli.installed_at_path:
zh-CN: 已安装于 %{path}
en: Installed at %{path}
settings.cli.install:
zh-CN: 安装
en: Install
settings.cli.reinstall:
zh-CN: 重新安装
en: Reinstall
settings.cli.update:
zh-CN: 更新
en: Update
settings.cli.remove:
zh-CN: 移除
en: Remove
settings.cli.binary_keywords:
zh-CN: CLI,PATH,shellrs,命令,安装
en: CLI,PATH,shellrs,command,install
settings.cli.check:
zh-CN: 检查状态
en: Check status
settings.cli.copy_skills:
zh-CN: 复制 skills
en: Copy skills
settings.cli.skills_keywords:
zh-CN: skill,Agent,检查,复制
en: skill,agent,check,copy
settings.about.updates:
zh-CN: 应用更新
en: Updates
settings.about.version:
zh-CN: 当前版本
en: Current version
settings.about.version_keywords:
zh-CN: 当前版本,检查更新,更新,升级,版本,下载
en: current version,check for updates,update,upgrade,version,download
settings.about.check:
zh-CN: 检查更新
en: Check for updates
settings.about.restart:
zh-CN: 重启并安装…
en: Restart to update…
settings.about.download_page:
zh-CN: 前往下载页
en: Open download page
settings.about.retry_download:
zh-CN: 重试下载
en: Retry download
settings.about.download:
zh-CN: 下载更新
en: Download update
settings.about.local_build:
zh-CN: 本机构建
en: Local build
# The version's tooltip: the build, then the system it is for.
settings.about.build:
zh-CN: "%{build}(%{platform})"
en: "%{build} (%{platform})"
settings.about.channel:
zh-CN: 更新渠道
en: Update channel
settings.about.channel_description:
zh-CN: 稳定版适合日常使用,Beta 可提前接收测试版本。
en: Stable is for everyday use. Beta gets test versions early.
settings.about.automatic:
zh-CN: 自动升级
en: Automatic updates
settings.about.automatic_description:
zh-CN: 检测到新版本后自动下载,下载完成后点击「重启并安装」完成更新。
en: A new version downloads by itself. Once it’s ready, click Restart to update.
settings.about.privacy:
zh-CN: 隐私
en: Privacy
settings.about.analytics:
zh-CN: 发送匿名使用统计
en: Send anonymous usage statistics
settings.about.analytics_description:
zh-CN: 发送应用版本、操作系统/架构,用于评估安装量、版本分布,用于改进 ShellRS。不会发送任何主机、凭据、命令或会话内容。
en: Sends the app version, operating system and architecture, to count installs and versions and to improve ShellRS. No host, credential, command or session content is ever sent.
settings.about.website:
zh-CN: 官网
en: Website
settings.about.website_description:
zh-CN: 功能介绍、下载和更新内容。
en: Features, downloads and what’s new.
settings.highlight.pattern_placeholder:
zh-CN: 例如:ERROR|WARN 或 failed\s+\d+
en: e.g. ERROR|WARN or failed\s+\d+
settings.highlight.note_placeholder:
zh-CN: 例如:错误告警
en: e.g. Error alert
settings.highlight.add_rule:
zh-CN: 添加规则
en: Add rule
settings.highlight.column_enabled:
zh-CN: 启用
en: "On"
settings.highlight.column_pattern:
zh-CN: 正则表达式
en: Regular expression
settings.highlight.column_note:
zh-CN: 备注
en: Note
settings.highlight.column_color:
zh-CN: 颜色
en: Color
settings.highlight.column_notify:
zh-CN: 通知
en: Notify
settings.highlight.drag:
zh-CN: 拖动调整顺序
en: Drag to reorder
settings.highlight.empty:
zh-CN: 还没有规则
en: No rules yet
settings.shortcuts.recording:
zh-CN: 按下组合键
en: Press a key combination
settings.shortcuts.none:
zh-CN: 无
en: None
settings.shortcuts.change:
zh-CN: 修改快捷键
en: Change shortcut
settings.shortcuts.enable:
zh-CN: 启用
en: Enable
settings.shortcuts.disable:
zh-CN: 禁用
en: Disable
settings.shortcuts.reset:
zh-CN: 恢复默认
en: Restore default
settings.theme.keywords:
zh-CN: 主题,终端主题,配色,颜色,浅色,深色
en: theme,terminal theme,color scheme,color,light,dark
settings.theme.app_follows:
zh-CN: 界面跟随主题
en: Match interface to theme
settings.theme.app_follows_description:
zh-CN: 关闭时界面用中性的黑白灰,只有终端按所选主题配色。
en: When off, the interface stays neutral black, white and gray, and only terminals take the theme’s colors.
+326
View File
@@ -0,0 +1,326 @@
_version: 2
# SFTP connections, transfers and local file operations: mostly errors,
# which the SFTP tab, the editor and the external CLI show.
sftp.place.home:
zh-CN: 主目录
en: Home
sftp.place.desktop:
zh-CN: 桌面
en: Desktop
sftp.place.documents:
zh-CN: 文稿
en: Documents
sftp.place.downloads:
zh-CN: 下载
en: Downloads
sftp.place.drive_local:
zh-CN: "%{drive} 本地磁盘"
en: "%{drive} Local disk"
sftp.place.drive_removable:
zh-CN: "%{drive} 可移动磁盘"
en: "%{drive} Removable disk"
sftp.place.drive_network:
zh-CN: "%{drive} 网络驱动器"
en: "%{drive} Network drive"
sftp.place.drive_optical:
zh-CN: "%{drive} CD 驱动器"
en: "%{drive} CD drive"
sftp.error.remote_path_invalid:
zh-CN: 远程路径不能为空或包含空字符
en: A remote path can’t be empty or contain a null character
sftp.error.file_name_invalid:
zh-CN: 无效的文件名:%{name}
en: "Invalid file name: %{name}"
sftp.error.nothing_to_upload:
zh-CN: 请选择要上传的文件或目录
en: Choose files or folders to upload
sftp.error.nothing_to_download:
zh-CN: 请选择要下载的文件或目录
en: Choose files or folders to download
sftp.error.download_target_relative:
zh-CN: 下载目标必须是绝对路径
en: The download destination must be an absolute path
sftp.error.file_name_not_utf8:
zh-CN: 目录包含无法用 UTF-8 表示的文件名
en: The folder has a file name that isn’t valid UTF-8
sftp.error.path_not_utf8:
zh-CN: 路径不是有效的 UTF-8
en: The path isn’t valid UTF-8
sftp.error.trash_failed:
zh-CN: 无法移到废纸篓:%{error}
en: "Couldn’t move to the Trash: %{error}"
sftp.error.recycle_bin_failed:
zh-CN: 无法移到废纸篓:%{error}
en: "Couldn’t move to the Recycle Bin: %{error}"
sftp.error.exists:
zh-CN: 已有名为「%{name}」的项目
en: An item named “%{name}” already exists
sftp.error.permission_denied:
zh-CN: 没有权限操作「%{name}」
en: Permission denied for “%{name}”
sftp.error.not_found:
zh-CN: 「%{name}」不存在
en: “%{name}” doesn’t exist
sftp.error.item:
zh-CN: 「%{name}」:%{error}
en: "“%{name}”: %{error}"
sftp.error.permissions_unsupported:
zh-CN: 此系统不支持修改权限
en: This system doesn’t support changing permissions
sftp.transfer.host_key_changed:
zh-CN: 服务器主机指纹已改变,已停止续传
en: The server’s host key changed, so resuming stopped
sftp.transfer.cancelled:
zh-CN: 传输已停止,进度已保留
en: Transfer stopped. Its progress is kept
sftp.upload.source_name_not_utf8:
zh-CN: 来源文件名不是有效的 UTF-8
en: The source file name isn’t valid UTF-8
sftp.upload.target_busy:
zh-CN: 另一个传输正在上传同一目标,请稍后继续上传
en: Another transfer is uploading to the same destination. Resume this upload later
sftp.upload.parent_missing:
zh-CN: 父目录未创建,无法上传
en: Can’t upload because the parent folder wasn’t created
sftp.upload.failed:
zh-CN: 无法上传:%{error}
en: "Couldn’t upload: %{error}"
sftp.upload.folder_in_the_way:
zh-CN: 目录与文件类型冲突,不会删除远程目录
en: A remote folder has this name, and it won’t be deleted to make way for a file
sftp.upload.exists.one:
en: A remote item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces this file or link.
sftp.upload.exists.other:
zh-CN: 远程项目已存在(%{count} 字节,修改时间 %{modified})。覆盖会替换这个文件或链接。
en: A remote item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces this file or link.
sftp.upload.exists_changed.one:
en: The destination changed during the upload. A remote item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces this file or link.
sftp.upload.exists_changed.other:
zh-CN: 目标在上传期间发生变化。远程项目已存在(%{count} 字节,修改时间 %{modified})。覆盖会替换这个文件或链接。
en: The destination changed during the upload. A remote item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces this file or link.
sftp.upload.target_gone:
zh-CN: 原目标在上传期间已被删除或移走。是否继续发布此文件?
en: The original destination was deleted or moved during the upload. Put this file in its place anyway?
sftp.upload.source_now_folder:
zh-CN: 来源已变为目录,请丢弃旧文件进度后重新上传,或跳过。
en: The source is now a folder. Discard the old file’s progress and upload again, or skip it.
sftp.upload.target_not_folder:
zh-CN: 目标已存在且不是目录,不会跟随或替换链接
en: The destination exists and isn’t a folder. Links aren’t followed or replaced
sftp.upload.special_file:
zh-CN: 不支持上传设备、套接字或其他特殊文件
en: Devices, sockets and other special files can’t be uploaded
sftp.upload.resume_found:
zh-CN: 发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。
en: Found an unfinished .filepart file. Resuming skips as much of the local file as it already holds, so make sure the local file is the same version.
sftp.upload.link_target_not_utf8:
zh-CN: 链接目标不是有效的 UTF-8
en: The link target isn’t valid UTF-8
sftp.upload.record_without_link:
zh-CN: 续传记录中缺少链接目标
en: The resume record has no link target
sftp.upload.temporary_link_changed:
zh-CN: 临时链接已变化,无法继续上传
en: The temporary link changed, so the upload can’t resume
sftp.upload.record_without_source:
zh-CN: 续传记录中缺少来源文件信息
en: The resume record has no details of the source file
sftp.upload.temporary_kind_changed:
zh-CN: 远端临时文件类型已变化
en: The remote temporary file is no longer a file
sftp.upload.partial_too_large:
zh-CN: 远端续传偏移大于本地文件
en: The remote partial file is larger than the local file
sftp.upload.pipeline_ended:
zh-CN: 上传写入流水线意外结束
en: The upload stopped writing unexpectedly
sftp.upload.publish_unconfirmed:
zh-CN: 无法确认文件发布结果,已保留恢复文件;请检查远端目录
en: Couldn’t confirm the file was put in place. The recovery files are kept; check the remote folder
sftp.upload.temporary_changed:
zh-CN: 临时文件大小或类型已变化,未替换目标
en: The temporary file’s size or type changed, so the destination wasn’t replaced
sftp.upload.target_created:
zh-CN: 替换期间目标被其他操作创建,已保留目标、备份和临时文件
en: Something else created the destination during the replace. The destination, backup and temporary file are kept
sftp.upload.backup_changed:
zh-CN: 恢复备份已变化,停止替换
en: The recovery backup changed, so the replace stopped
sftp.upload.backup_changed_after_publish:
zh-CN: 备份已变化,未清理;上传的目标文件已发布
en: The backup changed, so it wasn’t cleaned up. The uploaded file is in place
sftp.upload.backup_and_target:
zh-CN: 存在恢复备份和目标文件,请先检查远端目录;未删除任何文件
en: Both a recovery backup and the destination file exist. Check the remote folder first; nothing was deleted
sftp.download.name_invalid:
zh-CN: 名称「%{name}」在本机无效
en: The name “%{name}” isn’t valid on this computer
sftp.download.source_missing:
zh-CN: 远程项目不存在
en: The remote item doesn’t exist
sftp.download.target_busy:
zh-CN: 另一个传输正在下载到同一位置,请稍后继续下载
en: Another transfer is downloading to the same place. Resume this download later
sftp.download.parent_missing:
zh-CN: 父目录未创建,无法下载
en: Can’t download because the parent folder wasn’t created
sftp.download.failed:
zh-CN: 无法下载:%{error}
en: "Couldn’t download: %{error}"
sftp.download.target_invalid:
zh-CN: 无效的下载目标
en: Invalid download destination
sftp.download.folder_in_the_way:
zh-CN: 本地已有同名目录,不会删除它
en: A local folder has this name, and it won’t be deleted
sftp.download.exists.one:
en: A local item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces it.
sftp.download.exists.other:
zh-CN: 本地已有同名项目(%{count} 字节,修改时间 %{modified})。覆盖会替换它。
en: A local item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces it.
sftp.download.exists_changed.one:
en: The destination changed during the download. A local item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces it.
sftp.download.exists_changed.other:
zh-CN: 目标在下载期间发生变化。本地已有同名项目(%{count} 字节,修改时间 %{modified})。覆盖会替换它。
en: The destination changed during the download. A local item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces it.
sftp.download.file_in_the_way:
zh-CN: 本地已有同名文件,不会替换为目录
en: A local file has this name, and it won’t be replaced by a folder
sftp.download.special_file:
zh-CN: 不支持下载设备、套接字或其他特殊文件
en: Devices, sockets and other special files can’t be downloaded
sftp.download.partial_not_file:
zh-CN: 临时文件 %{path} 不是普通文件
en: The temporary file %{path} isn’t a regular file
sftp.download.partial_too_large:
zh-CN: 本地 .filepart 文件比远程文件大,无法续传。
en: The local .filepart file is larger than the remote file, so the download can’t resume.
sftp.download.source_changed:
zh-CN: 远程文件在上次下载后已变化,无法续传。
en: The remote file changed after the last download, so the download can’t resume.
sftp.download.resume_found:
zh-CN: 发现未完成的 .filepart 文件。续传会按它的现有大小跳过远程文件前缀,请确认远程文件仍是同一版本。
en: Found an unfinished .filepart file. Resuming skips as much of the remote file as it already holds, so make sure the remote file is the same version.
sftp.download.pipeline_ended:
zh-CN: 下载读取流水线意外结束
en: The download stopped reading unexpectedly
sftp.download.source_shrank:
zh-CN: 远程文件在下载期间变短,已停止
en: The remote file got shorter during the download, so it stopped
sftp.download.links_unsupported:
zh-CN: 此系统不支持创建符号链接
en: This system can’t create symbolic links
sftp.journal.source_not_file:
zh-CN: 本地来源已不再是普通文件
en: The local source is no longer a regular file
sftp.journal.mismatch:
zh-CN: 续传记录与当前来源或服务器不匹配
en: The resume record doesn’t match this source or server
sftp.journal.temporary_invalid:
zh-CN: 无效的续传临时路径
en: Invalid temporary path in the resume record
sftp.journal.replaces_folder:
zh-CN: 续传记录不能替换目录
en: A resume record can’t replace a folder
sftp.journal.upload_damaged:
zh-CN: 续传记录损坏,原文件和临时文件均已保留
en: The resume record is damaged. The original and temporary files are both kept
sftp.journal.download_damaged:
zh-CN: 续传记录损坏,临时文件已保留
en: The resume record is damaged. The temporary file is kept
sftp.journal.unreadable:
zh-CN: 无法读取续传记录
en: Couldn’t read the resume record
sftp.journal.unsaved:
zh-CN: 无法保存续传进度
en: Couldn’t save the resume progress
sftp.worker.not_connected:
zh-CN: SFTP 未连接,请重新连接
en: SFTP isn’t connected. Reconnect to continue
sftp.worker.not_connected_short:
zh-CN: SFTP 未连接
en: SFTP isn’t connected
sftp.worker.disconnected:
zh-CN: SFTP 已断开
en: SFTP disconnected
sftp.worker.list_failed:
zh-CN: 无法读取目录:%{error}
en: "Couldn’t read the folder: %{error}"
sftp.worker.busy:
zh-CN: 已有传输批次正在处理
en: A transfer batch is already running
sftp.worker.local_folder_missing:
zh-CN: 本地目录 %{path} 不存在
en: The local folder %{path} doesn’t exist
sftp.worker.remote_folder_missing:
zh-CN: 远程目录 %{path} 不存在
en: The remote folder %{path} doesn’t exist
sftp.worker.dropped_kept:
zh-CN: 连接中断,传输进度已保留
en: The connection dropped. Transfer progress is kept
sftp.worker.reconnect_in.one:
en: Reconnecting in %{count} second (%{attempt}/3)
sftp.worker.reconnect_in.other:
zh-CN: 将在 %{count} 秒后重连(%{attempt}/3)
en: Reconnecting in %{count} seconds (%{attempt}/3)
sftp.worker.not_a_folder:
zh-CN: 远程路径 %{path} 不是目录
en: The remote path %{path} isn’t a folder
sftp.worker.sync_to_root:
zh-CN: 不能同步到根目录 /
en: Can’t sync to the root folder /
sftp.worker.dropped:
zh-CN: SFTP 连接中断,请重新连接
en: The SFTP connection dropped. Reconnect to continue
sftp.edit.not_file:
zh-CN: 不是普通文件
en: Not a regular file
sftp.edit.too_large.one:
en: File too large (%{count} byte)
sftp.edit.too_large.other:
zh-CN: 文件太大(%{count} 字节)
en: File too large (%{count} bytes)
sftp.edit.not_text:
zh-CN: 不是 UTF-8 文本文件
en: Not a UTF-8 text file
sftp.edit.changed:
zh-CN: 文件在打开后已被修改
en: The file changed after it was opened
sftp.edit.missing:
zh-CN: 文件不存在
en: The file doesn’t exist
sftp.edit.shrank:
zh-CN: 文件在读取时变短了,请重新打开
en: The file got shorter while it was read. Open it again
sftp.edit.gone_after_save:
zh-CN: 保存后找不到文件
en: The file is gone after saving
sftp.client.start_timeout:
zh-CN: 启动 SFTP 超时
en: SFTP took too long to start
sftp.client.no_v3:
zh-CN: 服务器未提供 SFTP v3
en: The server doesn’t offer SFTP v3
sftp.client.no_path:
zh-CN: 服务器未返回目录路径
en: The server didn’t return the folder’s path
sftp.client.no_link_target:
zh-CN: 无法读取链接目标
en: Couldn’t read the link target
sftp.client.ambiguous_name:
zh-CN: 远端名称包含无法可靠表示的字符,已停止操作
en: A remote name has characters that can’t be shown reliably, so the operation stopped
sftp.sync.delete_failed:
zh-CN: 无法删除:%{error}
en: "Couldn’t delete: %{error}"
+36
View File
@@ -0,0 +1,36 @@
_version: 2
shared.tab.unsaved:
zh-CN: 未保存
en: Unsaved changes
shared.host_mark.unknown_os:
zh-CN: 未探测到系统
en: System not detected
shared.duration.days.one:
en: "%{count} day %{clock}"
shared.duration.days.other:
zh-CN: "%{count} 天 %{clock}"
en: "%{count} days %{clock}"
shared.rename_tab.title:
zh-CN: 重命名标签
en: Rename tab
shared.rename_tab.name:
zh-CN: 标签名称
en: Tab name
shared.rename_tab.default:
zh-CN: 留空则使用「%{title}」。
en: Leave empty to use “%{title}”.
shared.tab_menu.close_left:
zh-CN: 关闭左侧
en: Close to the left
shared.tab_menu.close_right:
zh-CN: 关闭右侧
en: Close to the right
shared.tab_menu.close_others:
zh-CN: 关闭其他
en: Close others
shared.tab_menu.close_all:
zh-CN: 关闭全部
en: Close all
+114
View File
@@ -0,0 +1,114 @@
_version: 2
# 命令片段: the commands kept for every host, in categories.
snippets.uncategorized:
zh-CN: 未分类
en: Uncategorized
snippets.panel.search:
zh-CN: 搜索名称或命令
en: Search names and commands
snippets.panel.count.one:
en: "%{count} snippet"
snippets.panel.count.other:
zh-CN: 共 %{count} 个片段
en: "%{count} snippets"
snippets.panel.categories.one:
en: "%{count} category"
snippets.panel.categories.other:
zh-CN: "%{count} 个分类"
en: "%{count} categories"
snippets.panel.new_snippet:
zh-CN: 新建片段
en: New snippet
snippets.panel.new_category:
zh-CN: 新建分类
en: New category
snippets.panel.empty:
zh-CN: 还没有命令片段
en: No snippets yet
snippets.panel.empty_description:
zh-CN: 把常用的命令存成片段,点一下就输入到当前终端。
en: Save the commands you use often as snippets, then type one into the terminal with a click.
snippets.panel.no_match:
zh-CN: 没有符合条件的片段
en: No matching snippets
snippets.card.runs_on_click:
zh-CN: 点击时自动执行
en: Runs on click
snippets.menu.new_snippet:
zh-CN: 新建片段…
en: New snippet…
snippets.menu.new_category:
zh-CN: 新建分类…
en: New category…
snippets.menu.rename_category:
zh-CN: 重命名分类…
en: Rename category…
snippets.menu.delete_category:
zh-CN: 删除分类…
en: Delete category…
snippets.menu.copy:
zh-CN: 复制命令
en: Copy command
snippets.menu.edit:
zh-CN: 编辑…
en: Edit…
snippets.menu.delete:
zh-CN: 删除…
en: Delete…
snippets.dialog.new_title:
zh-CN: 新建命令片段
en: New snippet
snippets.dialog.edit_title:
zh-CN: 编辑命令片段
en: Edit snippet
snippets.dialog.create:
zh-CN: 创建
en: Create
snippets.dialog.name:
zh-CN: 名称
en: Name
snippets.dialog.name_placeholder:
zh-CN: 例如 查看容器
en: e.g. List containers
snippets.dialog.category:
zh-CN: 分类
en: Category
snippets.dialog.command:
zh-CN: 命令
en: Command
snippets.dialog.command_placeholder:
zh-CN: 例如 docker ps -a
en: e.g. docker ps -a
snippets.dialog.command_description:
zh-CN: 可以有多行。
en: It can span several lines.
snippets.dialog.run_on_click:
zh-CN: 点击时自动执行
en: Run on click
snippets.dialog.run_on_click_description:
zh-CN: 启用后将自动执行命令,否则只将内容填入终端
en: A click runs the command; otherwise it’s only typed into the terminal
snippets.category.new_title:
zh-CN: 新建分类
en: New category
snippets.category.rename_title:
zh-CN: 重命名分类
en: Rename category
snippets.category.name:
zh-CN: 名称
en: Name
snippets.category.name_placeholder:
zh-CN: 例如 Docker
en: e.g. Docker
snippets.category.name_missing:
zh-CN: 请输入分类名称
en: Enter a category name
snippets.category.name_taken:
zh-CN: 已有同名分类
en: A category with this name already exists
+369
View File
@@ -0,0 +1,369 @@
_version: 2
# Connecting
ssh.connect.failed:
zh-CN: 无法建立 SSH 连接,请检查地址、端口和主机密钥
en: Couldn’t establish an SSH connection (check the address, port and host key)
ssh.connect.cancelled:
zh-CN: 连接已取消
en: Connection cancelled
# Jump hosts
ssh.jump.deleted:
zh-CN: 第 %{number} 台跳板主机已被删除,请编辑这台主机的连接方式
en: Jump host %{number} was deleted. Edit how this host connects
ssh.jump.failed:
zh-CN: 跳板主机「%{name}」:%{reason}
en: "Jump host “%{name}”: %{reason}"
ssh.jump.none:
zh-CN: 没有可用的跳板主机
en: No jump hosts to connect through
ssh.jump.forward.connect_failed:
zh-CN: 跳板主机「%{name}」无法连接到 %{host}:%{port}:连接失败
en: "Jump host “%{name}” couldn’t connect to %{host}:%{port}: connection failed"
ssh.jump.forward.prohibited:
zh-CN: 跳板主机「%{name}」无法连接到 %{host}:%{port}:服务器不允许端口转发
en: "Jump host “%{name}” couldn’t connect to %{host}:%{port}: the server doesn’t allow port forwarding"
ssh.jump.forward.channel_failed:
zh-CN: 跳板主机「%{name}」无法连接到 %{host}:%{port}:无法打开转发通道
en: "Jump host “%{name}” couldn’t connect to %{host}:%{port}: couldn’t open a forwarding channel"
# Proxy
ssh.proxy.connect_failed:
zh-CN: 无法连接代理服务器 %{host}:%{port}:%{reason}
en: "Couldn’t connect to the proxy at %{host}:%{port}: %{reason}"
ssh.proxy.bad_credentials:
zh-CN: 代理服务器拒绝了用户名或密码
en: The proxy rejected the username or password
ssh.proxy.auth_required:
zh-CN: 代理服务器要求认证,请填写代理的用户名和密码
en: The proxy requires authentication. Enter a username and password for it
ssh.proxy.forbidden:
zh-CN: 代理服务器不允许连接到 %{target}
en: The proxy doesn’t allow connections to %{target}
ssh.proxy.reply_too_long:
zh-CN: 代理服务器的回复过长
en: The proxy’s reply is too long
ssh.proxy.closed:
zh-CN: 代理服务器没有回复就关闭了连接
en: The proxy closed the connection without replying
ssh.proxy.http.bad_gateway:
zh-CN: 代理服务器无法连接到 %{target}:连接失败(HTTP 502)
en: "The proxy couldn’t connect to %{target}: connection failed (HTTP 502)"
ssh.proxy.http.gateway_timeout:
zh-CN: 代理服务器无法连接到 %{target}:连接超时(HTTP 504)
en: "The proxy couldn’t connect to %{target}: timed out (HTTP 504)"
ssh.proxy.http.refused:
zh-CN: 代理服务器拒绝了连接:%{status}
en: "The proxy refused the connection: %{status}"
ssh.proxy.http.unrecognized:
zh-CN: 代理服务器的回复无法识别,请确认它是 HTTP 代理
en: The proxy’s reply wasn’t understood. Check that it’s an HTTP proxy
ssh.proxy.socks.unrecognized:
zh-CN: 代理服务器的回复无法识别,请确认它是 SOCKS5 代理
en: The proxy’s reply wasn’t understood. Check that it’s a SOCKS5 proxy
ssh.proxy.socks.credentials_too_long:
zh-CN: 代理的用户名或密码太长,SOCKS5 最多 255 个字节
en: The proxy username or password is too long. SOCKS5 allows at most 255 bytes
ssh.proxy.socks.no_password_auth:
zh-CN: 代理服务器不接受用户名和密码认证
en: The proxy doesn’t accept username and password authentication
ssh.proxy.socks.host_too_long:
zh-CN: 主机名太长,SOCKS5 最多 255 个字节
en: The hostname is too long. SOCKS5 allows at most 255 bytes
ssh.proxy.socks.rules_forbid:
zh-CN: 代理服务器的规则不允许连接到 %{target}
en: The proxy’s rules don’t allow connections to %{target}
ssh.proxy.socks.network_unreachable:
zh-CN: 代理服务器无法连接到 %{target}:网络不可达
en: "The proxy couldn’t connect to %{target}: network unreachable"
ssh.proxy.socks.host_unreachable:
zh-CN: 代理服务器无法连接到 %{target}:主机不可达
en: "The proxy couldn’t connect to %{target}: host unreachable"
ssh.proxy.socks.refused:
zh-CN: 代理服务器无法连接到 %{target}:连接被拒绝
en: "The proxy couldn’t connect to %{target}: connection refused"
ssh.proxy.socks.timeout:
zh-CN: 代理服务器无法连接到 %{target}:连接超时
en: "The proxy couldn’t connect to %{target}: timed out"
ssh.proxy.socks.internal:
zh-CN: 代理服务器无法连接到 %{target}:代理服务器内部错误
en: "The proxy couldn’t connect to %{target}: internal proxy error"
ssh.proxy.socks.no_connect:
zh-CN: 代理服务器不支持 CONNECT 命令
en: The proxy doesn’t support the CONNECT command
ssh.proxy.socks.address_type:
zh-CN: 代理服务器不支持这种地址类型
en: The proxy doesn’t support this type of address
# A credential a connection without anyone to ask needed
ssh.missing.password_rejected:
zh-CN: 用户名或密码错误
en: Incorrect username or password
ssh.missing.password:
zh-CN: 未填写密码
en: No password entered
ssh.missing.passphrase_rejected:
zh-CN: 私钥口令错误
en: Incorrect private key passphrase
ssh.missing.passphrase:
zh-CN: 私钥已加密,未填写口令
en: The private key is encrypted and no passphrase was entered
ssh.missing.keyboard_interactive:
zh-CN: 服务器要求键盘交互式认证,无法在测试中完成
en: The server asks for keyboard-interactive authentication, which a test can’t complete
ssh.password_wanted:
zh-CN: 服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」
en: The server wants a password, and “No password” doesn’t ask for one. Choose “Password” or “Credential” instead
# Questions
ssh.prompt.view_closed:
zh-CN: 连接视图已关闭
en: The tab for this connection was closed
ssh.prompt.request_cancelled:
zh-CN: 认证请求已取消
en: Authentication request cancelled
# Known hosts
ssh.known_hosts.changed_while_saving:
zh-CN: 保存主机密钥时发现信任文件已发生变化
en: The known hosts file changed while the host key was being saved
ssh.known_hosts.write_failed:
zh-CN: 无法写入主机信任文件:%{path}
en: "Couldn’t write to the known hosts file: %{path}"
ssh.known_hosts.read_failed:
zh-CN: 无法读取主机信任文件:%{path}
en: "Couldn’t read the known hosts file: %{path}"
ssh.known_hosts.corrupt:
zh-CN: 主机信任文件已损坏:%{path}
en: "The known hosts file is damaged: %{path}"
ssh.known_hosts.unreadable:
zh-CN: 主机信任文件已损坏或无法读取:%{path}
en: "The known hosts file is damaged or unreadable: %{path}"
# Authentication
ssh.auth.methods_unknown:
zh-CN: 无法查询服务器支持的认证方式
en: Couldn’t ask the server which authentication methods it supports
ssh.auth.agent_rejected:
zh-CN: 服务器未接受 SSH Agent 中的密钥
en: The server didn’t accept the keys in the SSH agent
ssh.auth.agent_no_publickey:
zh-CN: 服务器不接受公钥登录,无法使用 SSH Agent
en: The server doesn’t accept public key logins, so the SSH agent can’t be used
ssh.auth.rsa_hash:
zh-CN: 无法协商 RSA 签名算法
en: Couldn’t agree on an RSA signature algorithm with the server
ssh.auth.key_failed:
zh-CN: 私钥认证失败
en: Private key authentication failed
ssh.auth.key_rejected:
zh-CN: 服务器未接受指定的私钥
en: The server didn’t accept the private key
ssh.auth.no_key_accepted:
zh-CN: 服务器未接受 SSH Agent 和 ~/.ssh 中的任何私钥
en: The server accepted none of the keys in the SSH agent or ~/.ssh
ssh.auth.password_failed:
zh-CN: 密码认证失败
en: Password authentication failed
ssh.auth.saved_password_rejected:
zh-CN: 已保存的密码被服务器拒绝,请重新输入
en: The server rejected the saved password. Enter it again
ssh.auth.enter_password:
zh-CN: 请输入登录密码
en: Enter the password to log in
ssh.auth.prompt_title:
zh-CN: SSH 登录
en: SSH login
ssh.auth.password_field:
zh-CN: 密码
en: Password
ssh.auth.failed:
zh-CN: 认证失败:服务器未接受可用的认证方式
en: "Authentication failed: the server accepted none of the available methods"
ssh.auth.cancelled:
zh-CN: 认证已取消
en: Authentication cancelled
ssh.auth.invalid_reply:
zh-CN: 认证回复无效
en: Invalid authentication reply
ssh.auth.keyboard_start_failed:
zh-CN: 无法开始交互式认证
en: Couldn’t start keyboard-interactive authentication
ssh.auth.keyboard_cancelled:
zh-CN: 交互式认证已取消
en: Keyboard-interactive authentication cancelled
ssh.auth.keyboard_failed:
zh-CN: 交互式认证失败
en: Keyboard-interactive authentication failed
# SSH agent
ssh.agent.unreachable:
zh-CN: 无法连接 SSH Agent:%{reason}
en: "Couldn’t connect to the SSH agent: %{reason}"
ssh.agent.empty:
zh-CN: SSH Agent 中没有密钥,请先用 ssh-add 添加
en: The SSH agent has no keys. Add one with ssh-add first
ssh.agent.unsigned:
zh-CN: SSH Agent 拒绝了签名请求
en: The SSH agent refused to sign
ssh.agent.hash:
zh-CN: 无法协商 SSH Agent 签名算法
en: Couldn’t agree on a signature algorithm for the SSH agent
ssh.agent.no_socket:
zh-CN: 没有设置 SSH_AUTH_SOCK
en: SSH_AUTH_SOCK isn’t set
ssh.agent.missing_socket:
zh-CN: "%{path} 不存在"
en: "%{path} doesn’t exist"
ssh.agent.no_response:
zh-CN: 没有响应
en: no response
ssh.agent.service_not_running:
zh-CN: OpenSSH Authentication Agent 服务没有运行
en: the OpenSSH Authentication Agent service isn’t running
ssh.agent.unsupported:
zh-CN: 这个平台不支持
en: not supported on this platform
# Private keys
ssh.key.not_chosen:
zh-CN: 私钥认证需要选择私钥文件
en: Choose a private key file to log in with a key
ssh.key.unreadable:
zh-CN: 无法读取私钥文件:%{path}
en: "Couldn’t read the private key file: %{path}"
ssh.key.saved_passphrase_rejected:
zh-CN: 已保存的口令无法解开 %{path},请重新输入
en: The saved passphrase doesn’t unlock %{path}. Enter it again
ssh.key.enter_passphrase:
zh-CN: 请输入 %{path} 的口令
en: Enter the passphrase for %{path}
ssh.key.prompt_title:
zh-CN: 私钥口令
en: Private key passphrase
ssh.key.passphrase_field:
zh-CN: 口令
en: Passphrase
ssh.key.too_many_attempts:
zh-CN: 私钥口令错误次数过多
en: Too many incorrect passphrases
# Why a login failed
ssh.error.timeout:
zh-CN: 连接超时
en: Connection timed out
ssh.error.refused:
zh-CN: 连接被拒绝,该端口上没有服务在监听
en: "Connection refused: nothing is listening on that port"
ssh.error.unreachable:
zh-CN: 主机不可达
en: Host unreachable
ssh.error.connect_failed:
zh-CN: 无法连接:%{error}
en: "Couldn’t connect: %{error}"
ssh.error.no_common.kex:
zh-CN: 无法建立 SSH 连接:和服务器没有共同的密钥交换算法,服务器支持的是 %{theirs}
en: "Couldn’t establish an SSH connection: no key exchange algorithm in common with the server, which supports %{theirs}"
ssh.error.no_common.host_key:
zh-CN: 无法建立 SSH 连接:和服务器没有共同的主机密钥算法,服务器支持的是 %{theirs}
en: "Couldn’t establish an SSH connection: no host key algorithm in common with the server, which supports %{theirs}"
ssh.error.no_common.cipher:
zh-CN: 无法建立 SSH 连接:和服务器没有共同的加密算法,服务器支持的是 %{theirs}
en: "Couldn’t establish an SSH connection: no cipher in common with the server, which supports %{theirs}"
ssh.error.no_common.mac:
zh-CN: 无法建立 SSH 连接:和服务器没有共同的消息校验(MAC)算法,服务器支持的是 %{theirs}
en: "Couldn’t establish an SSH connection: no MAC algorithm in common with the server, which supports %{theirs}"
ssh.error.no_common.compression:
zh-CN: 无法建立 SSH 连接:和服务器没有共同的压缩算法,服务器支持的是 %{theirs}
en: "Couldn’t establish an SSH connection: no compression algorithm in common with the server, which supports %{theirs}"
# Connection test
ssh.test.runtime_failed:
zh-CN: 无法启动连接测试:%{error}
en: "Couldn’t start the connection test: %{error}"
ssh.test.unresolved:
zh-CN: 无法解析主机 %{host}
en: Couldn’t resolve %{host}
ssh.test.host_key_changed:
zh-CN: 主机密钥与已保存的不一致,已阻止连接。请先核实服务器身份
en: The host key doesn’t match the saved one, so the connection was blocked. Verify the server’s identity first
ssh.test.host_key_declined:
zh-CN: 未信任该主机的密钥
en: The host key wasn’t trusted
# Remote terminal
ssh.terminal.shell_only:
zh-CN: 这个终端经由堡垒机打开,只能使用终端本身,不能另外执行命令
en: This terminal was opened by a bastion host and can’t run commands beside the shell
ssh.terminal.runtime_failed:
zh-CN: 无法启动 SSH 运行时
en: Couldn’t start the SSH runtime
ssh.terminal.bridge_failed:
zh-CN: 无法启动 SSH 命令桥接线程
en: Couldn’t start the SSH command thread
ssh.terminal.session_failed:
zh-CN: 无法创建 SSH 会话通道
en: Couldn’t open an SSH session channel
ssh.terminal.pty_refused:
zh-CN: 服务器拒绝创建终端
en: The server refused to create a terminal
ssh.terminal.shell_refused:
zh-CN: 服务器拒绝启动 Shell
en: The server refused to start a shell
ssh.terminal.tab_closed:
zh-CN: 终端标签页已关闭
en: The terminal tab was closed
ssh.terminal.write_failed:
zh-CN: 向远程终端写入失败
en: Couldn’t write to the remote terminal
ssh.terminal.resize_failed:
zh-CN: 调整远程终端尺寸失败
en: Couldn’t resize the remote terminal
# Commands: beside the shell, and the external CLI’s exec
ssh.exec.channel_failed:
zh-CN: 无法打开 SSH 通道
en: Couldn’t open an SSH channel
ssh.exec.send_failed:
zh-CN: 无法发送命令
en: Couldn’t send the command
ssh.exec.refused:
zh-CN: 服务器拒绝执行命令
en: The server refused to run the command
ssh.exec.timeout:
zh-CN: 命令超时
en: The command timed out
ssh.exec.runtime_failed:
zh-CN: 无法启动连接:%{error}
en: "Couldn’t start the connection: %{error}"
ssh.exec.command_failed:
zh-CN: 无法执行命令:%{error}
en: "Couldn’t run the command: %{error}"
ssh.exec.aborted:
zh-CN: 输出已无人接收,命令已放弃
en: Nothing was reading the output, so the command was abandoned
ssh.exec.disconnected:
zh-CN: 连接在命令结束前断开
en: The connection closed before the command finished
ssh.exec.host_key_changed:
zh-CN: "%{endpoint} 的主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份"
en: The host key of %{endpoint} doesn’t match the saved one, so the connection was refused. Verify the server’s identity first
ssh.exec.host_key_unknown:
zh-CN: 尚未信任 %{endpoint} 的主机密钥:请先在 ShellRS 中连接一次这台主机
en: The host key of %{endpoint} isn’t trusted yet. Connect to this host in ShellRS once first
ssh.exec.no_saved_password:
zh-CN: 这台主机没有保存密码:请先在 ShellRS 中连接一次这台主机并保存密码
en: This host has no saved password. Connect to it in ShellRS once and save the password
ssh.exec.saved_password_rejected:
zh-CN: 保存的密码被服务器拒绝:请先在 ShellRS 中连接一次这台主机并保存密码
en: The server rejected the saved password. Connect to this host in ShellRS once and save the right one
ssh.exec.no_saved_passphrase:
zh-CN: 私钥已加密,但没有保存口令:请先在 ShellRS 中连接一次这台主机并保存密码
en: The private key is encrypted and has no saved passphrase. Connect to this host in ShellRS once and save it
ssh.exec.saved_passphrase_rejected:
zh-CN: 保存的私钥口令不正确:请先在 ShellRS 中连接一次这台主机并保存密码
en: The saved private key passphrase is incorrect. Connect to this host in ShellRS once and save the right one
ssh.exec.keyboard_interactive:
zh-CN: 服务器在键盘交互式认证中提出了密码以外的问题(如验证码),外部 CLI 无法回答
en: The server asks keyboard-interactive questions besides the password (such as a verification code), which the external CLI can’t answer
+213
View File
@@ -0,0 +1,213 @@
_version: 2
# Remote terminal tab
terminal.remote.label:
zh-CN: "%{host} 的终端"
en: "Terminal on %{host}"
terminal.remote.fallback_title:
zh-CN: 终端
en: Terminal
terminal.remote.sftp_tooltip:
zh-CN: 打开 SFTP 文件浏览
en: Browse files over SFTP
terminal.remote.open_sftp:
zh-CN: 打开 SFTP
en: Open SFTP
terminal.remote.reconnect:
zh-CN: 重新连接
en: Reconnect
terminal.remote.disconnect:
zh-CN: 断开连接
en: Disconnect
terminal.remote.copy_ip:
zh-CN: 复制 IP 地址
en: Copy IP address
terminal.remote.copy_host_name:
zh-CN: 复制主机名
en: Copy hostname
terminal.remote.rename:
zh-CN: 重命名标签…
en: Rename tab…
terminal.remote.connect_in_new_tab:
zh-CN: 在新标签页中连接
en: Connect in new tab
terminal.remote.edit_host:
zh-CN: 编辑主机…
en: Edit host…
# Local terminal tab
terminal.local.title:
zh-CN: 本地终端 %{number}
en: Local terminal %{number}
terminal.local.restart:
zh-CN: 重新启动
en: Restart
# Notes written into the terminal itself
terminal.notice.disconnected:
zh-CN: 已断开连接
en: Disconnected
terminal.notice.cancelled:
zh-CN: 连接已取消
en: Connection cancelled
terminal.notice.exited_signal:
zh-CN: 进程已退出:%{signal},退出码 %{code}
en: "Process exited on signal %{signal}, exit code %{code}"
terminal.notice.exited:
zh-CN: 进程已退出,退出码 %{code}
en: Process exited with code %{code}
terminal.notice.error:
zh-CN: 终端错误:%{error}
en: "Terminal error: %{error}"
# A command put on the input line (history, snippets)
terminal.command.not_connected:
zh-CN: 终端没有连接
en: The terminal isn’t connected
terminal.command.full_screen:
zh-CN: 终端里正在运行全屏程序,先退出它
en: A full-screen program is running in the terminal. Quit it first
terminal.command.multiline:
zh-CN: 这是一条多行命令,在这个终端里只能直接执行
en: This is a multi-line command, which this terminal can only run directly
# A screenshot pasted for an agent in the terminal
terminal.paste.image_empty:
zh-CN: 剪贴板里的图片是空的
en: The image on the clipboard is empty
terminal.paste.image_too_large:
zh-CN: 图片太大,粘贴不了
en: The image is too large to paste
terminal.paste.image_save_failed:
zh-CN: 没法把图片存到本机:%{error}
en: "Couldn’t save the image on this computer: %{error}"
terminal.paste.image_upload_failed:
zh-CN: 没法把图片传到主机:%{error}
en: "Couldn’t send the image to the host: %{error}"
terminal.paste.image_unsupported:
zh-CN: 外部连接和未保存的主机不能粘贴图片
en: External connections and unsaved hosts can’t paste images
terminal.paste.image_title:
zh-CN: 粘贴图片
en: Paste image
# State of a terminal
terminal.lifecycle.starting:
zh-CN: 正在启动
en: Starting
terminal.lifecycle.running:
zh-CN: 运行中
en: Running
terminal.lifecycle.exited:
zh-CN: 已退出
en: Exited
terminal.lifecycle.failed:
zh-CN: 启动失败
en: Couldn’t start
terminal.lifecycle.closing:
zh-CN: 正在关闭
en: Closing
terminal.lifecycle.exited_signal:
zh-CN: 已退出(%{signal},退出码 %{code})
en: Exited (%{signal}, exit code %{code})
terminal.lifecycle.exited_code:
zh-CN: 已退出(退出码 %{code})
en: Exited (exit code %{code})
terminal.lifecycle.failed_with:
zh-CN: 启动失败:%{error}
en: "Couldn’t start: %{error}"
# Context menu
terminal.menu.copy:
zh-CN: 复制
en: Copy
terminal.menu.paste:
zh-CN: 粘贴
en: Paste
terminal.menu.find:
zh-CN: 查找…
en: Find…
terminal.menu.clear:
zh-CN: 清屏
en: Clear screen
# Links
terminal.link.open_hint:
zh-CN: "%{key} 单击打开"
en: "%{key}-click to open"
# Find bar
terminal.find.placeholder:
zh-CN: 查找
en: Find
terminal.find.previous:
zh-CN: 上一项(⇧↩)
en: Previous (⇧↩)
terminal.find.next:
zh-CN: 下一项(↩)
en: Next (↩)
terminal.find.close:
zh-CN: 关闭(Esc)
en: Close (Esc)
terminal.find.no_results:
zh-CN: 无结果
en: No results
terminal.find.count.one:
en: "%{count} match"
terminal.find.count.other:
zh-CN: "%{count} 项"
en: "%{count} matches"
# Font preview in settings, for screen readers
terminal.font.preview:
zh-CN: "%{family} %{size} px,行高 %{line_height}"
en: "%{family} %{size} px, line height %{line_height}"
# Keyword highlighting
terminal.highlight.example.error:
zh-CN: 错误
en: Error
terminal.highlight.example.warning:
zh-CN: 警告
en: Warning
terminal.highlight.example.ipv4:
zh-CN: IPv4 地址
en: IPv4 address
terminal.highlight.too_long:
zh-CN: 不能超过 %{limit} 个字符
en: Can’t be longer than %{limit} characters
terminal.highlight.too_complex:
zh-CN: 正则表达式过于复杂
en: This regular expression is too complex
terminal.highlight.invalid:
zh-CN: 正则表达式写法有误
en: Not a valid regular expression
# Local shell
terminal.pty.open_failed:
zh-CN: 无法创建本地 PTY
en: Couldn’t create a local PTY
terminal.pty.shell_failed:
zh-CN: 无法启动系统默认 shell
en: Couldn’t start the default shell
terminal.pty.reader_failed:
zh-CN: 无法读取本地 PTY
en: Couldn’t read from the local PTY
terminal.pty.writer_failed:
zh-CN: 无法写入本地 PTY
en: Couldn’t write to the local PTY
terminal.pty.reader_thread:
zh-CN: 无法启动 PTY 读取线程
en: Couldn’t start the PTY reader thread
terminal.pty.read_error:
zh-CN: 读取 PTY 失败:%{error}
en: "Couldn’t read from the PTY: %{error}"
terminal.pty.write_error:
zh-CN: 写入 PTY 失败:%{error}
en: "Couldn’t write to the PTY: %{error}"
terminal.pty.resize_error:
zh-CN: 调整 PTY 尺寸失败:%{error}
en: "Couldn’t resize the PTY: %{error}"
terminal.pty.exit_status:
zh-CN: 无法读取 shell 退出状态
en: Couldn’t read the shell’s exit status
+251
View File
@@ -0,0 +1,251 @@
_version: 2
# The right sidebar: its seven tools, the words they share, and the
# workspace's confirmations and notifications for what they run.
tools.kind.snippets:
zh-CN: 命令片段
en: Snippets
tools.kind.history:
zh-CN: 历史命令
en: History
tools.kind.services:
zh-CN: 系统服务
en: Services
tools.kind.processes:
zh-CN: 进程管理
en: Processes
tools.kind.connections:
zh-CN: 网络连接
en: Network
tools.kind.monitor:
zh-CN: 系统监控
en: Monitor
# Shared by the tools.
tools.refresh:
zh-CN: 刷新
en: Refresh
tools.reading:
zh-CN: 正在读取…
en: Loading…
tools.read_failed:
zh-CN: 读取失败:%{error}
en: "Couldn’t load: %{error}"
tools.not_connected:
zh-CN: 终端没有连接
en: The terminal isn’t connected
tools.no_answer:
zh-CN: 主机没有回答
en: The host didn’t answer
tools.details_and_logs:
zh-CN: 详情和日志
en: Details and logs
tools.run:
zh-CN: 执行
en: Run
tools.menu.insert:
zh-CN: 输入到终端
en: Insert into terminal
tools.menu.details:
zh-CN: 查看详情
en: View details
# A command of 命令片段 or 历史命令 onto the terminal's input line.
tools.command.run_failed:
zh-CN: 无法执行命令:%{error}
en: "Couldn’t run the command: %{error}"
tools.command.enter_failed:
zh-CN: 无法输入命令:%{error}
en: "Couldn’t insert the command: %{error}"
tools.command.copied:
zh-CN: 已复制命令
en: Command copied
# Docker: a container, or a compose project's containers.
tools.container.start.done:
zh-CN: 容器“%{name}”已启动
en: Container “%{name}” started
tools.container.start.failed:
zh-CN: 无法启动容器“%{name}”:%{error}
en: "Couldn’t start container “%{name}”: %{error}"
tools.container.stop.title:
zh-CN: 停止容器“%{name}”?
en: Stop container “%{name}”?
tools.container.stop.description:
zh-CN: 停止后它提供的服务就不可用了,直到再次启动。
en: What it serves is unavailable until it’s started again.
tools.container.stop.done:
zh-CN: 容器“%{name}”已停止
en: Container “%{name}” stopped
tools.container.stop.failed:
zh-CN: 无法停止容器“%{name}”:%{error}
en: "Couldn’t stop container “%{name}”: %{error}"
tools.container.restart.title:
zh-CN: 重启容器“%{name}”?
en: Restart container “%{name}”?
tools.container.restart.description:
zh-CN: 会先停止再启动,中间短暂不可用。
en: It stops and starts again, so it’s briefly unavailable.
tools.container.restart.done:
zh-CN: 容器“%{name}”已重启
en: Container “%{name}” restarted
tools.container.restart.failed:
zh-CN: 无法重启容器“%{name}”:%{error}
en: "Couldn’t restart container “%{name}”: %{error}"
tools.project.start.done:
zh-CN: 项目“%{name}”已启动
en: Project “%{name}” started
tools.project.start.failed:
zh-CN: 无法启动项目“%{name}”:%{error}
en: "Couldn’t start project “%{name}”: %{error}"
tools.project.stop.title:
zh-CN: 停止项目“%{name}”?
en: Stop project “%{name}”?
tools.project.stop.description:
zh-CN: 停止后它提供的服务就不可用了,直到再次启动。
en: What its containers serve is unavailable until they’re started again.
tools.project.stop.done:
zh-CN: 项目“%{name}”已停止
en: Project “%{name}” stopped
tools.project.stop.failed:
zh-CN: 无法停止项目“%{name}”:%{error}
en: "Couldn’t stop project “%{name}”: %{error}"
tools.project.restart.title:
zh-CN: 重启项目“%{name}”?
en: Restart project “%{name}”?
tools.project.restart.description:
zh-CN: 会先停止再启动,中间短暂不可用。
en: Its containers stop and start again, so they’re briefly unavailable.
tools.project.restart.done:
zh-CN: 项目“%{name}”已重启
en: Project “%{name}” restarted
tools.project.restart.failed:
zh-CN: 无法重启项目“%{name}”:%{error}
en: "Couldn’t restart project “%{name}”: %{error}"
tools.remove.container.title:
zh-CN: 删除容器“%{name}”?
en: Delete container “%{name}”?
tools.remove.container.description:
zh-CN: 容器和它里面没有存进卷的数据会一起删除,不能恢复。
en: The container is deleted with any data not kept in a volume. This can’t be undone.
tools.remove.container.done:
zh-CN: 容器“%{name}”已删除
en: Container “%{name}” deleted
tools.remove.container.failed:
zh-CN: 无法删除容器“%{name}”:%{error}
en: "Couldn’t delete container “%{name}”: %{error}"
tools.remove.image.title:
zh-CN: 删除镜像“%{name}”?
en: Delete image “%{name}”?
tools.remove.image.description:
zh-CN: 删除后再要用它,需要重新拉取或构建。
en: To use it again, you’ll need to pull or build it again.
tools.remove.image.done:
zh-CN: 镜像“%{name}”已删除
en: Image “%{name}” deleted
tools.remove.image.failed:
zh-CN: 无法删除镜像“%{name}”:%{error}
en: "Couldn’t delete image “%{name}”: %{error}"
tools.remove.volume.title:
zh-CN: 删除卷“%{name}”?
en: Delete volume “%{name}”?
tools.remove.volume.description:
zh-CN: 卷里的数据会一起删除,不能恢复。
en: The data in it is deleted too. This can’t be undone.
tools.remove.volume.done:
zh-CN: 卷“%{name}”已删除
en: Volume “%{name}” deleted
tools.remove.volume.failed:
zh-CN: 无法删除卷“%{name}”:%{error}
en: "Couldn’t delete volume “%{name}”: %{error}"
tools.remove.network.title:
zh-CN: 删除网络“%{name}”?
en: Delete network “%{name}”?
tools.remove.network.description:
zh-CN: 删除后再要用它,需要重新创建。
en: To use it again, you’ll need to create it again.
tools.remove.network.done:
zh-CN: 网络“%{name}”已删除
en: Network “%{name}” deleted
tools.remove.network.failed:
zh-CN: 无法删除网络“%{name}”:%{error}
en: "Couldn’t delete network “%{name}”: %{error}"
# 系统服务: a systemd service.
tools.service.start.done:
zh-CN: "%{name} 已启动"
en: "%{name} started"
tools.service.start.failed:
zh-CN: 无法启动 %{name}:%{error}
en: "Couldn’t start %{name}: %{error}"
tools.service.stop.title:
zh-CN: 停止服务“%{name}”?
en: Stop service “%{name}”?
tools.service.stop.description:
zh-CN: 停止后它提供的功能就不可用了,直到再次启动。
en: What it provides is unavailable until it’s started again.
tools.service.stop.description_ssh:
zh-CN: 这是 SSH 服务:停止后新的连接都连不上这台主机,直到它再次启动。
en: This is the SSH service. Until it’s started again, no new connection can reach this host.
tools.service.stop.done:
zh-CN: "%{name} 已停止"
en: "%{name} stopped"
tools.service.stop.failed:
zh-CN: 无法停止 %{name}:%{error}
en: "Couldn’t stop %{name}: %{error}"
tools.service.restart.title:
zh-CN: 重启服务“%{name}”?
en: Restart service “%{name}”?
tools.service.restart.description:
zh-CN: 服务会先停止再启动,中间短暂不可用。
en: The service stops and starts again, so it’s briefly unavailable.
tools.service.restart.done:
zh-CN: "%{name} 已重启"
en: "%{name} restarted"
tools.service.restart.failed:
zh-CN: 无法重启 %{name}:%{error}
en: "Couldn’t restart %{name}: %{error}"
tools.service.enable.done:
zh-CN: "%{name} 已设为开机启动"
en: "%{name} now starts at boot"
tools.service.enable.failed:
zh-CN: 无法启用开机启动 %{name}:%{error}
en: "Couldn’t enable %{name} at boot: %{error}"
tools.service.disable.done:
zh-CN: "%{name} 已取消开机启动"
en: "%{name} no longer starts at boot"
tools.service.disable.failed:
zh-CN: 无法禁用开机启动 %{name}:%{error}
en: "Couldn’t disable %{name} at boot: %{error}"
# 进程管理: ending a process.
tools.process.end.title:
zh-CN: 结束进程“%{name}”?
en: End process “%{name}”?
tools.process.end.description:
zh-CN: PID %{pid},用户 %{user}。进程会收到结束信号(SIGTERM),可以先做完收尾再退出。
en: PID %{pid}, user %{user}. The process gets SIGTERM, so it can clean up before it exits.
tools.process.end.confirm:
zh-CN: 结束进程
en: End process
tools.process.kill.title:
zh-CN: 强制结束进程“%{name}”?
en: Kill process “%{name}”?
tools.process.kill.description:
zh-CN: PID %{pid},用户 %{user}。进程会立即被终止(SIGKILL),来不及保存数据;只在「结束进程」不起作用时使用。
en: PID %{pid}, user %{user}. The process is stopped at once (SIGKILL), with no chance to save its data. Use this only when “End process” doesn’t work.
tools.process.kill.confirm:
zh-CN: 强制结束
en: Kill
tools.process.ended:
zh-CN: 已向 %{name}(PID %{pid})发送结束信号
en: Sent SIGTERM to %{name} (PID %{pid})
tools.process.killed:
zh-CN: 已强制结束 %{name}(PID %{pid})
en: Killed %{name} (PID %{pid})
tools.process.end_failed:
zh-CN: 无法结束 %{name}(PID %{pid}):%{error}
en: "Couldn’t end %{name} (PID %{pid}): %{error}"
+257
View File
@@ -0,0 +1,257 @@
_version: 2
# 在线升级: the status line on 设置 › 关于, the title bar button, the update
# dialog and what goes wrong. Reasons that follow a colon start in lower
# case in English, like the system's own errors they stand beside.
# `UpdateError`, always shown after 「检查更新失败:」 and the like.
update.error.network:
zh-CN: 无法连接更新服务器:%{reason}
en: "couldn’t reach the update server: %{reason}"
update.error.http:
zh-CN: 更新服务器返回 HTTP %{status}
en: the update server answered HTTP %{status}
update.error.stalled:
zh-CN: 下载长时间没有进展,已中断
en: the download stalled and was stopped
update.error.bad_signature:
zh-CN: 更新清单的签名无效
en: the update manifest’s signature isn’t valid
update.error.bad_manifest:
zh-CN: 无法读取更新清单:%{reason}
en: "couldn’t read the update manifest: %{reason}"
update.error.corrupt:
zh-CN: 下载的文件与更新清单不符
en: the downloaded file doesn’t match the update manifest
update.error.no_space:
zh-CN: 磁盘空间不足
en: not enough disk space
update.error.disk:
zh-CN: 无法写入更新文件:%{reason}
en: "couldn’t write the update file: %{reason}"
update.error.install:
zh-CN: 无法安装新版本:%{reason}
en: "couldn’t install the new version: %{reason}"
update.error.cancelled:
zh-CN: 已取消
en: cancelled
# The reasons inside those errors.
update.feed.wrong_range:
zh-CN: 服务器返回的续传位置不对
en: the server resumed at the wrong position
update.feed.cut_off:
zh-CN: 连接在下载完成前断开
en: the connection closed before the download finished
update.feed.too_large:
zh-CN: 文件过大
en: the file is too large
update.feed.no_urls:
zh-CN: 没有下载地址
en: there’s no download address
update.feed.timed_out:
zh-CN: 连接超时
en: the connection timed out
update.updater.restart_failed:
zh-CN: 无法自动重新启动,请手动重新打开 ShellRS
en: ShellRS couldn’t restart itself, so open it again
update.updater.check_stopped:
zh-CN: 检查意外中止
en: the check stopped unexpectedly
update.install.not_possible:
zh-CN: 这份 ShellRS 无法自动更新
en: this copy of ShellRS can’t update itself
update.install.setup_missing:
zh-CN: 找不到下载的安装程序
en: the downloaded setup program is missing
update.install.tool_failed:
zh-CN: "%{program}:%{error}"
en: "%{program}: %{error}"
update.install.bad_signature:
zh-CN: 新版本的签名无效或不是 ShellRS 的开发者
en: the new version’s signature isn’t valid or isn’t from ShellRS’s developer
update.install.no_app:
zh-CN: 更新包里没有应用程序
en: the update package has no app in it
update.install.wrong_version:
zh-CN: 更新包的版本与更新清单不符
en: the update package’s version doesn’t match the update manifest
# Why this copy cannot replace itself: on 设置 › 关于 (with a full stop
# added) and in the update dialog.
update.unsupported.development_build:
zh-CN: 开发构建,不检查更新
en: Development builds don’t check for updates
update.unsupported.translocated:
zh-CN: ShellRS 不在“应用程序”文件夹中,无法自动更新。请把它移到“应用程序”文件夹后重新打开
en: ShellRS isn’t in the Applications folder, so it can’t update itself. Move it to Applications, then open it again
update.unsupported.disk_image:
zh-CN: ShellRS 正在从磁盘映像运行,无法自动更新。请先把它拖到“应用程序”文件夹
en: ShellRS is running from its disk image, so it can’t update itself. Drag it to the Applications folder first
update.unsupported.read_only:
zh-CN: 没有权限写入 %{folder},无法自动更新
en: ShellRS can’t write to %{folder}, so it can’t update itself
update.unsupported.not_bundled:
zh-CN: ShellRS 不是从应用程序包运行的,无法自动更新
en: ShellRS isn’t running from an app bundle, so it can’t update itself
update.unsupported.not_installed:
zh-CN: 这份 ShellRS 不是用安装程序安装的,无法自动更新
en: This copy of ShellRS wasn’t installed by the setup program, so it can’t update itself
update.unsupported.not_appimage:
zh-CN: 这份 ShellRS 不是 AppImage,请用安装它的方式更新
en: This copy of ShellRS isn’t an AppImage. Update it the way you installed it
update.unsupported.platform:
zh-CN: ShellRS 没有为这个系统提供更新
en: ShellRS has no updates for this system
# The line under 当前版本 on 设置 › 关于.
update.status.unsupported:
zh-CN: "%{reason}。"
en: "%{reason}."
update.status.off:
zh-CN: 不检查更新。
en: Not checking for updates.
update.status.idle:
zh-CN: 尚未检查更新。
en: Not checked for updates yet.
update.status.checking:
zh-CN: 正在检查更新…
en: Checking for updates…
update.status.up_to_date:
zh-CN: 当前已是最新版本。
en: ShellRS is up to date.
update.status.available_manual:
zh-CN: 发现新版本 %{version},需要从官网下载安装。
en: ShellRS %{version} is available. Download it from the website to install it.
update.status.available_unsupported:
zh-CN: 发现新版本 %{version}。%{reason}。
en: ShellRS %{version} is available. %{reason}.
update.status.available:
zh-CN: 发现新版本 %{version}。
en: ShellRS %{version} is available.
update.status.downloading:
zh-CN: 正在下载新版本 %{version}… %{percent}%
en: Downloading ShellRS %{version}… %{percent}%
update.status.verifying:
zh-CN: 正在校验新版本 %{version}…
en: Verifying ShellRS %{version}…
update.status.ready:
zh-CN: 新版本 %{version} 已下载,重启 ShellRS 即可完成更新。
en: ShellRS %{version} is downloaded. Restart ShellRS to finish updating.
update.status.installing:
zh-CN: 正在安装新版本 %{version}…
en: Installing ShellRS %{version}…
update.status.check_failed:
zh-CN: 检查更新失败:%{error}
en: "Couldn’t check for updates: %{error}"
update.status.download_failed:
zh-CN: 下载新版本 %{version} 失败:%{error}
en: "Couldn’t download ShellRS %{version}: %{error}"
update.status.install_failed:
zh-CN: 安装新版本 %{version} 失败:%{error}
en: "Couldn’t install ShellRS %{version}: %{error}"
# The title bar button's tooltip.
update.badge.ready:
zh-CN: 新版本 %{version} 已就绪
en: ShellRS %{version} is ready to install
update.badge.available:
zh-CN: 新版本 %{version} 可用
en: ShellRS %{version} is available
update.badge.download_failed:
zh-CN: 新版本 %{version} 下载失败
en: Couldn’t download ShellRS %{version}
update.badge.install_failed:
zh-CN: 新版本 %{version} 安装失败
en: Couldn’t install ShellRS %{version}
# The update dialog.
update.dialog.title:
zh-CN: ShellRS 更新
en: ShellRS update
update.dialog.versions_released:
zh-CN: 当前版本 %{current} · 新版本发布于 %{day}
en: Current version %{current} · New version released %{day}
update.dialog.current_version:
zh-CN: 当前版本 %{current}
en: Current version %{current}
update.dialog.ready:
zh-CN: 新版本已下载,重启 ShellRS 即可完成更新。
en: The new version is downloaded. Restart ShellRS to finish updating.
update.dialog.downloading:
zh-CN: 正在下载 · %{percent}%
en: Downloading · %{percent}%
update.dialog.verifying:
zh-CN: 正在校验…
en: Verifying…
update.dialog.check_failed:
zh-CN: 检查失败:%{error}
en: "Couldn’t check for updates: %{error}"
update.dialog.download_failed:
zh-CN: 下载失败:%{error}
en: "Couldn’t download the update: %{error}"
update.dialog.install_failed:
zh-CN: 安装失败:%{error}
en: "Couldn’t install the update: %{error}"
update.dialog.manual:
zh-CN: 这个版本需要从官网下载安装,无法在 ShellRS 中直接更新。
en: This version can’t be installed from within ShellRS. Download it from the website.
update.dialog.restart:
zh-CN: 重启并安装
en: Restart to update
update.dialog.download_page:
zh-CN: 前往下载页
en: Open download page
update.dialog.retry:
zh-CN: 重试
en: Retry
update.dialog.download:
zh-CN: 下载
en: Download
update.dialog.whats_new:
zh-CN: 查看更新内容
en: What’s new
# What a restart interrupts, in the update dialog above 重启并安装.
update.restart.remote_terminals.one:
en: 1 remote terminal
update.restart.remote_terminals.other:
zh-CN: "%{count} 个远程终端"
en: "%{count} remote terminals"
update.restart.sftp_tabs.one:
en: 1 SFTP tab
update.restart.sftp_tabs.other:
zh-CN: "%{count} 个 SFTP 标签"
en: "%{count} SFTP tabs"
update.restart.local_terminals.one:
en: 1 local terminal
update.restart.local_terminals.other:
zh-CN: "%{count} 个本地终端"
en: "%{count} local terminals"
update.restart.transfers.one:
en: 1 transfer (its progress is kept)
update.restart.transfers.other:
zh-CN: "%{count} 个传输(保留续传进度)"
en: "%{count} transfers (their progress is kept)"
update.restart.forwards.one:
en: 1 port forward
update.restart.forwards.other:
zh-CN: "%{count} 条端口转发"
en: "%{count} port forwards"
update.restart.closes:
zh-CN: 重启会关闭 %{closes}。
en: Restarting closes %{closes}.
update.restart.stops:
zh-CN: 重启会停止 %{stops}。
en: Restarting stops %{stops}.
update.restart.closes_and_stops:
zh-CN: 重启会关闭 %{closes},停止 %{stops}。
en: Restarting closes %{closes} and stops %{stops}.
update.restart.unsaved_files.one:
en: Unsaved changes in 1 file will be lost.
update.restart.unsaved_files.other:
zh-CN: "%{count} 个文件有未保存的修改,重启后会丢失。"
en: Unsaved changes in %{count} files will be lost.
update.restart.and_unsaved_files:
zh-CN: "%{note}%{unsaved}"
en: "%{note} %{unsaved}"
+257
View File
@@ -0,0 +1,257 @@
_version: 2
# The title bar.
workspace.title_bar.hosts:
zh-CN: 主机
en: Hosts
workspace.title_bar.forwards:
zh-CN: 端口转发
en: Port forwarding
workspace.title_bar.forwards_running:
zh-CN: 端口转发,%{count} 条运行中
en: Port forwarding, %{count} running
workspace.title_bar.credentials:
zh-CN: 凭据
en: Credentials
workspace.title_bar.hide_sidebar:
zh-CN: 隐藏侧栏
en: Hide sidebar
workspace.title_bar.show_sidebar:
zh-CN: 显示侧栏
en: Show sidebar
workspace.title_bar.quick_connect:
zh-CN: 临时连接…
en: Quick connect…
workspace.title_bar.quick_connect_tooltip:
zh-CN: 连接一台主机,不保存
en: Connect to a host without saving it
workspace.title_bar.local_terminal:
zh-CN: 本地终端
en: Local terminal
workspace.title_bar.new_local_terminal:
zh-CN: 新建本地终端
en: New local terminal
workspace.title_bar.to_light:
zh-CN: 切换为浅色
en: Switch to light
workspace.title_bar.to_dark:
zh-CN: 切换为深色
en: Switch to dark
workspace.sidebar.settings:
zh-CN: 设置
en: Settings
workspace.sidebar.open_settings:
zh-CN: 打开设置
en: Open settings
# The status bar.
workspace.status.connected_to:
zh-CN: 已连接 %{name}
en: Connected to %{name}
workspace.status.connecting_to:
zh-CN: 连接中 %{name}
en: Connecting to %{name}
workspace.status.disconnected_from:
zh-CN: 未连接 %{name}
en: Not connected to %{name}
# How the connection stands, then what went wrong.
workspace.status.problem:
zh-CN: "%{state}:%{problem}"
en: "%{state}: %{problem}"
workspace.status.local_file:
zh-CN: 本机文件
en: Local file
workspace.status.local_starting:
zh-CN: 正在启动 本地终端
en: Local terminal starting
workspace.status.local_running:
zh-CN: 运行中 本地终端
en: Local terminal running
workspace.status.local_exited:
zh-CN: 已退出 本地终端
en: Local terminal exited
workspace.status.local_failed:
zh-CN: 启动失败 本地终端
en: Local terminal failed to start
workspace.status.local_closing:
zh-CN: 正在关闭 本地终端
en: Local terminal closing
workspace.status.encoding:
zh-CN: 编码 UTF-8
en: Encoding UTF-8
workspace.status.terminal_type:
zh-CN: 终端 xterm-256color
en: Terminal xterm-256color
workspace.status.terminal_size:
zh-CN: 终端尺寸:%{columns} 列,%{rows} 行
en: "Terminal size: %{columns} columns, %{rows} rows"
# 最近连接, the start page.
workspace.recent.title:
zh-CN: 最近连接
en: Recent
workspace.recent.hint:
zh-CN: 双击主机连接,或新建一台主机
en: Double-click a host to connect, or create a new one
workspace.recent.find_host:
zh-CN: 快速连接
en: Find a host
workspace.recent.new_host:
zh-CN: 新建主机…
en: New host…
workspace.recent.empty:
zh-CN: 还没有连接过的主机
en: No hosts connected yet
workspace.recent.connected:
zh-CN: 已连接
en: Connected
workspace.copied:
zh-CN: 已复制 %{text}
en: Copied %{text}
workspace.copied_id:
zh-CN: 已复制 ID %{id}
en: Copied ID %{id}
workspace.link.open_failed:
zh-CN: 无法打开链接
en: Couldn’t open the link
# Questions while connecting.
workspace.host_key.unknown_title:
zh-CN: 首次连接此主机
en: First connection to this host
workspace.host_key.trust:
zh-CN: 信任并连接
en: Trust and connect
workspace.host_key.changed_title:
zh-CN: 服务器主机密钥已变更
en: Server host key changed
workspace.host_key.changed:
zh-CN: "主机:%{host}:%{port}\n算法:%{algorithm}\n已保存指纹:%{old}\n服务器当前指纹:%{fingerprint}\n\n连接已阻断。请核验服务器身份后手动处理:%{path}"
en: "Host: %{host}:%{port}\nAlgorithm: %{algorithm}\nSaved fingerprint: %{old}\nServer’s fingerprint now: %{fingerprint}\n\nThe connection was blocked. Verify the server’s identity, then sort it out by hand in %{path}"
workspace.host_key.changed_via_jump_host:
zh-CN: "跳板主机:%{jump_host}\n主机:%{host}:%{port}\n算法:%{algorithm}\n已保存指纹:%{old}\n服务器当前指纹:%{fingerprint}\n\n连接已阻断。请核验服务器身份后手动处理:%{path}"
en: "Jump host: %{jump_host}\nHost: %{host}:%{port}\nAlgorithm: %{algorithm}\nSaved fingerprint: %{old}\nServer’s fingerprint now: %{fingerprint}\n\nThe connection was blocked. Verify the server’s identity, then sort it out by hand in %{path}"
workspace.auth.title:
zh-CN: SSH 认证
en: SSH authentication
workspace.auth.continue:
zh-CN: 继续
en: Continue
# 外部 CLI.
workspace.cli.start_failed:
zh-CN: 外部 CLI 无法启动
en: The external CLI couldn’t start
workspace.cli.install_command_failed:
zh-CN: 无法安装 shellrs 命令
en: Couldn’t install the shellrs command
workspace.cli.remove_command_failed:
zh-CN: 无法移除 shellrs 命令
en: Couldn’t remove the shellrs command
workspace.cli.install_skill_failed:
zh-CN: 无法安装 skill
en: Couldn’t install the skill
workspace.cli.remove_skill_failed:
zh-CN: 无法移除 skill
en: Couldn’t remove the skill
workspace.cli.skill_copied:
zh-CN: 已复制 shellrs skill
en: Copied the shellrs skill
# Changes not saved yet, which a close or a quit would lose. `files` is
# one of the workspace.files phrases.
workspace.files.single:
zh-CN: “%{name}”
en: “%{name}”
workspace.files.pair:
zh-CN: “%{first}”和“%{second}”
en: “%{first}” and “%{second}”
workspace.files.several:
zh-CN: “%{first}”等 %{count} 个文件
en: "%{count} files including “%{first}”"
workspace.unsaved.quit_title:
zh-CN: 退出 ShellRS?
en: Quit ShellRS?
workspace.unsaved.quit:
zh-CN: "%{files}有未保存的修改,退出后会丢失。"
en: Unsaved changes to %{files} will be lost.
workspace.unsaved.discard_and_quit:
zh-CN: 放弃修改并退出
en: Discard changes and quit
workspace.unsaved.close_tabs_title.one:
en: Close 1 tab?
workspace.unsaved.close_tabs_title.other:
zh-CN: 关闭 %{count} 个标签?
en: Close %{count} tabs?
workspace.unsaved.close_tabs:
zh-CN: "%{files}有未保存的修改,关闭后会丢失。"
en: Unsaved changes to %{files} will be lost.
workspace.unsaved.close_sftp_title:
zh-CN: 关闭 SFTP 标签?
en: Close the SFTP tab?
workspace.unsaved.close_sftp:
zh-CN: "%{files}有未保存的修改,关闭 SFTP 标签会一起关掉它们,修改会丢失。"
en: Closing the SFTP tab also closes %{files}. Unsaved changes will be lost.
workspace.unsaved.discard_and_close:
zh-CN: 放弃修改并关闭
en: Discard changes and close
# Terminal notices: the tab first, so a notice says which terminal it is
# from.
workspace.notice.titled:
zh-CN: "%{tab}:%{title}"
en: "%{tab}: %{title}"
workspace.notice.bell:
zh-CN: "%{tab}:响铃"
en: "%{tab}: Bell"
workspace.credential.public_key_copied:
zh-CN: 已复制公钥
en: Copied the public key
workspace.credential.public_key_unreadable:
zh-CN: 无法从 %{path} 读出公钥
en: Couldn’t read a public key from %{path}
workspace.credential.in_use_then_password.one:
en: 1 host uses this credential. Once it’s deleted, that host switches to Password, asks for the password when connecting, and keeps its username.
workspace.credential.in_use_then_password.other:
zh-CN: 有 %{count} 台主机正在使用此凭据。删除后它们改为「密码」,连接时询问密码,用户名不变。
en: "%{count} hosts use this credential. Once it’s deleted, they switch to Password, ask for the password when connecting, and keep their usernames."
workspace.credential.in_use_then_no_password.one:
en: 1 host uses this credential. Once it’s deleted, that host switches to No password, signs in with the SSH agent and the private keys in ~/.ssh, and keeps its username.
workspace.credential.in_use_then_no_password.other:
zh-CN: 有 %{count} 台主机正在使用此凭据。删除后它们改为「无密码」,用 SSH Agent 和 ~/.ssh 中的私钥登录,用户名不变。
en: "%{count} hosts use this credential. Once it’s deleted, they switch to No password, sign in with the SSH agent and the private keys in ~/.ssh, and keep their usernames."
workspace.forward.needs_host:
zh-CN: 端口转发经由一台主机的 SSH 连接,请先新建主机。
en: A port forward runs over a host’s SSH connection. Create a host first.
workspace.forward.delete_running:
zh-CN: 这条转发正在运行,会先停止。
en: This forward is running and will be stopped first.
workspace.forward.stopped:
zh-CN: 端口转发“%{title}”已停止
en: Port forward “%{title}” stopped
workspace.forward.prompt_origin:
zh-CN: 端口转发“%{title}”正在通过 %{host} 连接。
en: Port forward “%{title}” is connecting through %{host}.
workspace.snippets.delete_category.one:
en: The 1 snippet in the category is deleted with it.
workspace.snippets.delete_category.other:
zh-CN: 分类里的 %{count} 个片段会一起删除。
en: The %{count} snippets in the category are deleted with it.
workspace.update.done_title:
zh-CN: 更新完成
en: Update complete
workspace.update.done:
zh-CN: ShellRS 已更新到 %{version}。
en: ShellRS is now %{version}.
workspace.update.failed_title:
zh-CN: 更新没有完成
en: Update not completed
workspace.update.failed:
zh-CN: 没有装上 %{to},ShellRS 仍是 %{from}。可以到官网下载安装。
en: "%{to} wasn’t installed, so ShellRS is still %{from}. You can download it from the website."
+2 -1
View File
@@ -1,7 +1,8 @@
[Desktop Entry]
Type=Application
Name=ShellRS
Comment=SSH 主机管理:终端、SFTP 与端口转发
Comment=SSH host manager: terminal, SFTP and port forwarding
Comment[zh_CN]=SSH 主机管理:终端、SFTP 与端口转发
Exec=shellrs
Icon=shellrs
Terminal=false
+11 -2
View File
@@ -5,11 +5,20 @@
change: the keychain grants each saved password to them, so a new
identifier would have macOS ask about every password again.
bundle.sh fills in @VERSION@ and @BUILD@ (a number that only goes up).
The languages are the interface's: the system's own panels (the file
picker, the menu bar's standard items) follow whichever of them the user
prefers, and the *.lproj folders beside this file translate the
descriptions below.
-->
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<string>en</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh-Hans</string>
</array>
<key>CFBundleDisplayName</key>
<string>ShellRS</string>
<key>CFBundleExecutable</key>
@@ -35,7 +44,7 @@
<key>NSHighResolutionCapable</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>ShellRS 需要访问本地网络,才能连接局域网中的主机。</string>
<string>ShellRS needs the local network to connect to hosts on your LAN.</string>
<key>NSHumanReadableCopyright</key>
<string>© ShellRS</string>
</dict>
+5
View File
@@ -24,6 +24,11 @@ chmod 755 "$app/Contents/MacOS/shellrs"
sed -e "s/@VERSION@/$version/g" -e "s/@BUILD@/$build/g" \
"$here/Info.plist.in" > "$app/Contents/Info.plist"
plutil -lint "$app/Contents/Info.plist" >/dev/null
# The Info.plist descriptions in each language of the interface.
for lproj in "$here"/*.lproj; do
cp -R "$lproj" "$app/Contents/Resources/"
plutil -lint "$app/Contents/Resources/$(basename "$lproj")/InfoPlist.strings" >/dev/null
done
icns="$root/assets/logo/shellrs.icns"
icon="$root/assets/logo/shellrs.png"
@@ -0,0 +1,2 @@
/* What macOS shows when ShellRS first reaches for the local network. */
NSLocalNetworkUsageDescription = "ShellRS needs the local network to connect to hosts on your LAN.";
@@ -0,0 +1,2 @@
/* ShellRS 第一次访问本地网络时,macOS 显示的说明。 */
NSLocalNetworkUsageDescription = "ShellRS 需要访问本地网络,才能连接局域网中的主机。";
+12 -8
View File
@@ -23,8 +23,10 @@ out=$5
base="https://dl.shellrs.com/releases/$version"
mirror=${SHELLRS_MIRROR_BASE:-}
mac="ShellRS-$version-macos-universal.app.zip"
dmg="ShellRS-$version-macos-universal.dmg"
mac_arm="ShellRS-$version-macos-aarch64.app.zip"
mac_intel="ShellRS-$version-macos-x86_64.app.zip"
dmg_arm="ShellRS-$version-macos-aarch64.dmg"
dmg_intel="ShellRS-$version-macos-x86_64.dmg"
win="ShellRS-$version-windows-x86_64-setup.exe"
linux="ShellRS-$version-linux-x86_64.AppImage"
@@ -58,10 +60,12 @@ jq -n -j \
--arg published "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--rawfile notes "$notes" \
--arg notes_url "https://shellrs.com/changelog#$version" \
--argjson mac "$(asset "$mac")" \
--argjson mac_arm "$(asset "$mac_arm")" \
--argjson mac_intel "$(asset "$mac_intel")" \
--argjson win "$(asset "$win")" \
--argjson linux "$(asset "$linux")" \
--arg dmg "$base/$dmg" \
--arg dmg_arm "$base/$dmg_arm" \
--arg dmg_intel "$base/$dmg_intel" \
--arg win_url "$base/$win" \
--arg linux_url "$base/$linux" \
'{
@@ -74,14 +78,14 @@ jq -n -j \
notes: $notes,
notes_url: $notes_url,
assets: {
"macos-aarch64": $mac,
"macos-x86_64": $mac,
"macos-aarch64": $mac_arm,
"macos-x86_64": $mac_intel,
"windows-x86_64": $win,
"linux-x86_64": $linux
},
installers: {
"macos-aarch64": $dmg,
"macos-x86_64": $dmg,
"macos-aarch64": $dmg_arm,
"macos-x86_64": $dmg_intel,
"windows-x86_64": $win_url,
"linux-x86_64": $linux_url
}
+18 -6
View File
@@ -9,29 +9,41 @@
"notes_url": "https://shellrs.com/changelog#0.2.0",
"assets": {
"macos-aarch64": {
"urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip"],
"urls": [
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-aarch64.app.zip",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-aarch64.app.zip"
],
"size": 41234567,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
},
"macos-x86_64": {
"urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip"],
"urls": [
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-x86_64.app.zip",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-x86_64.app.zip"
],
"size": 41234567,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
},
"windows-x86_64": {
"urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe"],
"urls": [
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe"
],
"size": 18234567,
"sha256": "1111111111111111111111111111111111111111111111111111111111111111"
},
"linux-x86_64": {
"urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage"],
"urls": [
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage"
],
"size": 28234567,
"sha256": "2222222222222222222222222222222222222222222222222222222222222222"
}
},
"installers": {
"macos-aarch64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.dmg",
"macos-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.dmg",
"macos-aarch64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-aarch64.dmg",
"macos-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-x86_64.dmg",
"windows-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe",
"linux-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage"
}
+5 -2
View File
@@ -60,8 +60,11 @@ Source: "{#BuildDir}\shellrs.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#BuildDir}\shellrs-cli.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\ShellRS"; Filename: "{app}\shellrs.exe"
Name: "{autodesktop}\ShellRS"; Filename: "{app}\shellrs.exe"; Tasks: desktopicon
; The AppUserModelID ShellRS gives itself (`set_app_identity`, for its
; notifications): the same here keeps a pinned shortcut and the running
; window one taskbar button.
Name: "{autoprograms}\ShellRS"; Filename: "{app}\shellrs.exe"; AppUserModelID: "com.shellrs.ShellRS"
Name: "{autodesktop}\ShellRS"; Filename: "{app}\shellrs.exe"; AppUserModelID: "com.shellrs.ShellRS"; Tasks: desktopicon
[Run]
; Interactive installs only: an update is restarted by ShellRS's own helper,
+455
View File
@@ -0,0 +1,455 @@
//! The analytics thread, the handle the workspace signals it through, and
//! the request to Aptabase.
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::Duration;
use chrono::Utc;
use reqwest::blocking::Client;
use reqwest::header::{CONTENT_TYPE, USER_AGENT};
use super::APP_KEY;
use super::model::{
Counter, Delivery, Pace, Reporter, Signal, Snapshot, State, SystemProps, body, endpoint,
};
use super::system::SystemInfo;
use crate::update::{Channel, build_info};
/// Lets a development build report, to its debug view, to try it out.
const DEBUG_ENV: &str = "SHELLRS_ANALYTICS_DEBUG";
/// Where the events go.
pub trait Transport: Send + Sync {
/// Post `body`, a JSON array of events, and say how it went. Blocks: it
/// runs on the analytics thread.
fn send(&self, body: &[u8], user_agent: &str) -> Delivery;
}
/// Aptabase, over HTTPS.
pub struct HttpTransport {
endpoint: String,
app_key: String,
/// How long connecting may take, and the whole request.
connect_timeout: Duration,
timeout: Duration,
/// Whether to go through the system's proxy, as the update check does.
/// Only the loopback tests turn it off: a developer's proxy would
/// capture 127.0.0.1.
system_proxy: bool,
}
impl HttpTransport {
pub fn new(endpoint: impl Into<String>, app_key: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
app_key: app_key.into(),
connect_timeout: Duration::from_secs(5),
timeout: Duration::from_secs(15),
system_proxy: true,
}
}
}
impl Transport for HttpTransport {
fn send(&self, body: &[u8], user_agent: &str) -> Delivery {
// Built for each request, on the analytics thread: the blocking
// client runs a runtime of its own, and requests are a few a day.
let builder = Client::builder()
.connect_timeout(self.connect_timeout)
.timeout(self.timeout);
let builder = if self.system_proxy {
builder
} else {
builder.no_proxy()
};
let Ok(client) = builder.build() else {
return Delivery::Retry;
};
let response = client
.post(&self.endpoint)
.header("App-Key", &self.app_key)
.header(CONTENT_TYPE, "application/json")
.header(USER_AGENT, user_agent)
.body(body.to_vec())
.send();
match response {
Ok(response) => delivery(response.status().as_u16()),
Err(_) => Delivery::Retry,
}
}
}
/// What an answer means for the events: kept for a later try only when
/// trying again can help.
fn delivery(status: u16) -> Delivery {
match status {
200..=299 => Delivery::Sent,
408 | 429 | 500..=599 => Delivery::Retry,
_ => Delivery::Rejected,
}
}
/// What the analytics thread works with: production's from `system`, the
/// tests' with a fake transport.
pub struct AnalyticsServices {
pub transport: Arc<dyn Transport>,
/// `analytics.json`.
pub state_path: PathBuf,
/// A development build's events go to the dashboard's debug view, and
/// at its quicker pace.
pub is_debug: bool,
/// Reads the system's name and version, on the thread.
pub detect: fn() -> SystemInfo,
}
impl AnalyticsServices {
/// Production's, or `None` for a build that does not report: only
/// release builds do, and a development build with
/// `SHELLRS_ANALYTICS_DEBUG` set.
pub fn system() -> Option<Self> {
let is_debug = Channel::of_this_build().is_none();
if is_debug && std::env::var_os(DEBUG_ENV).is_none() {
return None;
}
let endpoint = endpoint(APP_KEY)?;
Some(Self {
transport: Arc::new(HttpTransport::new(endpoint, APP_KEY)),
state_path: crate::app::analytics_path(),
is_debug,
detect: SystemInfo::detect,
})
}
}
/// The workspace's end. Signals go to the analytics thread and nothing
/// comes back, so the window never waits on the network; with 发送匿名使用统计
/// off, none are sent.
pub struct Analytics {
sender: Sender<Signal>,
enabled: bool,
}
impl Analytics {
/// Signals go to `sender`. The tests read them off its receiver.
pub fn new(sender: Sender<Signal>, enabled: bool) -> Self {
Self { sender, enabled }
}
/// Start the analytics thread. Off, it only forgets what an earlier
/// run left counted.
pub fn start(services: AnalyticsServices, enabled: bool) -> Self {
let (sender, receiver) = mpsc::channel();
// A thread that cannot start leaves the signals unheard, which is
// all it costs.
let _ = thread::Builder::new()
.name("shellrs-analytics".into())
.spawn(move || run(services, receiver));
let analytics = Self::new(sender, enabled);
if !enabled {
let _ = analytics.sender.send(Signal::Enabled(false));
}
analytics
}
pub fn started(&self, snapshot: Snapshot) {
self.send(Signal::Started(snapshot));
}
pub fn window_active(&self, snapshot: Snapshot) {
self.send(Signal::Active(snapshot));
}
pub fn count(&self, counter: Counter) {
self.send(Signal::Count(counter));
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn set_enabled(&mut self, enabled: bool) {
if enabled != self.enabled {
self.enabled = enabled;
let _ = self.sender.send(Signal::Enabled(enabled));
}
}
/// A thread that is gone is not the window's problem.
fn send(&self, signal: Signal) {
if self.enabled {
let _ = self.sender.send(signal);
}
}
}
/// The analytics thread: wait for a signal, take it in with whatever else
/// arrived meanwhile, send what is due, keep the state. Nothing happens
/// between signals; the thread ends with the workspace's handle.
fn run(services: AnalyticsServices, signals: Receiver<Signal>) {
let info = (services.detect)();
let user_agent = info.user_agent(build_info::VERSION);
let system = SystemProps {
is_debug: services.is_debug,
os_name: info.os_name,
os_version: info.os_version,
app_version: build_info::VERSION.to_string(),
};
let pace = if services.is_debug {
Pace::Debug
} else {
Pace::Release
};
let mut reporter = Reporter::new(State::load(&services.state_path), pace);
while let Ok(signal) = signals.recv() {
let now = Utc::now();
reporter.signal(signal, now);
for signal in signals.try_iter() {
reporter.signal(signal, now);
}
if let Some(batch) = reporter.take_batch(now) {
let delivery = services
.transport
.send(&body(&batch.events, &system), &user_agent);
reporter.settle(batch, delivery, Utc::now());
}
if reporter.take_changed() {
// Unwritable, the state lives on in memory until the next try.
let _ = reporter.state().save(&services.state_path);
}
}
}
#[cfg(test)]
mod tests {
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Mutex;
use std::time::Instant;
use serde_json::Value;
use super::*;
/// A request as the loopback server saw it.
#[derive(Clone, Debug, Default)]
struct Seen {
request_line: String,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl Seen {
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
}
/// Answers every request with `status`, or with nothing at all for
/// `None`, and remembers what it was sent.
struct Server {
port: u16,
seen: Arc<Mutex<Vec<Seen>>>,
}
impl Server {
fn start(status: Option<u16>) -> Self {
let listener = {
let _forks = crate::testing::no_forks();
TcpListener::bind("127.0.0.1:0").unwrap()
};
let port = listener.local_addr().unwrap().port();
let seen = Arc::new(Mutex::new(Vec::new()));
let shared = seen.clone();
thread::spawn(move || {
for stream in listener.incoming().flatten() {
let seen = shared.clone();
thread::spawn(move || serve(stream, status, &seen));
}
});
Self { port, seen }
}
fn transport(&self) -> HttpTransport {
HttpTransport {
endpoint: format!("http://127.0.0.1:{}/api/v0/events", self.port),
app_key: "A-US-1".into(),
connect_timeout: Duration::from_millis(500),
timeout: Duration::from_millis(500),
system_proxy: false,
}
}
fn seen(&self) -> Vec<Seen> {
self.seen.lock().unwrap().clone()
}
}
fn serve(stream: TcpStream, status: Option<u16>, seen: &Mutex<Vec<Seen>>) {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request = Seen::default();
if reader.read_line(&mut request.request_line).is_err() {
return;
}
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
break;
}
if let Some((name, value)) = line.split_once(':') {
request
.headers
.push((name.trim().to_string(), value.trim().to_string()));
}
}
let length = request
.header("content-length")
.and_then(|length| length.parse().ok())
.unwrap_or(0);
request.body = vec![0; length];
let _ = reader.read_exact(&mut request.body);
seen.lock().unwrap().push(request);
let Some(status) = status else {
// Keep the connection open and say nothing.
thread::sleep(Duration::from_secs(2));
return;
};
let mut stream = stream;
let _ = stream.write_all(
format!(
"HTTP/1.1 {status} Whatever\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
)
.as_bytes(),
);
}
#[test]
fn the_request_is_what_aptabase_reads() {
let server = Server::start(Some(200));
let delivery = server
.transport()
.send(b"[]", "ShellRS/1 (macOS 15; aarch64)");
assert_eq!(delivery, Delivery::Sent);
let seen = server.seen();
let request = &seen[0];
assert!(request.request_line.starts_with("POST /api/v0/events "));
assert_eq!(request.header("app-key"), Some("A-US-1"));
assert_eq!(request.header("content-type"), Some("application/json"));
assert_eq!(
request.header("user-agent"),
Some("ShellRS/1 (macOS 15; aarch64)")
);
assert_eq!(request.body, b"[]");
}
#[test]
fn only_what_may_pass_is_tried_again() {
for (status, expected) in [
(202, Delivery::Sent),
(500, Delivery::Retry),
(503, Delivery::Retry),
(429, Delivery::Retry),
(400, Delivery::Rejected),
(404, Delivery::Rejected),
] {
let server = Server::start(Some(status));
assert_eq!(server.transport().send(b"[]", "ua"), expected, "{status}");
}
}
#[test]
fn no_network_is_tried_again_later() {
// A port nothing listens on.
let port = {
let _forks = crate::testing::no_forks();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
};
let mut transport = Server::start(Some(200)).transport();
transport.endpoint = format!("http://127.0.0.1:{port}/api/v0/events");
assert_eq!(transport.send(b"[]", "ua"), Delivery::Retry);
}
#[test]
fn a_server_that_never_answers_is_given_up_on() {
let server = Server::start(None);
let started = Instant::now();
assert_eq!(server.transport().send(b"[]", "ua"), Delivery::Retry);
assert!(started.elapsed() < Duration::from_secs(2));
}
/// Hands each body to the test.
struct FakeTransport(Mutex<Sender<Vec<u8>>>);
impl Transport for FakeTransport {
fn send(&self, body: &[u8], _: &str) -> Delivery {
let _ = self.0.lock().unwrap().send(body.to_vec());
Delivery::Sent
}
}
fn a_mac() -> SystemInfo {
SystemInfo {
os_name: "macOS".into(),
os_version: "15.1.0".into(),
}
}
#[test]
fn the_thread_reports_the_start_and_keeps_the_state() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("analytics.json");
let (bodies, received) = mpsc::channel();
let services = AnalyticsServices {
transport: Arc::new(FakeTransport(Mutex::new(bodies))),
state_path: path.clone(),
is_debug: true,
detect: a_mac,
};
let analytics = Analytics::start(services, true);
analytics.started(Snapshot::default());
let body = received.recv_timeout(Duration::from_secs(5)).unwrap();
let events: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(events[0]["eventName"], "app_installed");
assert_eq!(events[1]["eventName"], "app_started");
assert_eq!(events[0]["systemProps"]["osName"], "macOS");
assert_eq!(events[0]["systemProps"]["isDebug"], true);
analytics.count(Counter::Ssh);
// The count is on disk once the thread has taken it in.
let deadline = Instant::now() + Duration::from_secs(5);
let state = loop {
let state = State::load(&path);
if state.counting.is_some() || Instant::now() > deadline {
break state;
}
thread::sleep(Duration::from_millis(10));
};
assert!(state.installed);
assert_eq!(state.counting.unwrap().counts["ssh"], 1);
}
#[test]
fn switched_off_nothing_is_signalled() {
let (sender, receiver) = mpsc::channel();
let mut analytics = Analytics::new(sender, true);
analytics.count(Counter::Find);
analytics.set_enabled(false);
analytics.count(Counter::Find);
analytics.window_active(Snapshot::default());
analytics.set_enabled(true);
let signals: Vec<Signal> = receiver.try_iter().collect();
assert_eq!(
signals,
[
Signal::Count(Counter::Find),
Signal::Enabled(false),
Signal::Enabled(true)
]
);
}
}
+35
View File
@@ -0,0 +1,35 @@
//! 匿名使用统计: how many installations, how many devices a day, which
//! versions, and which features get used, reported to Aptabase.
//!
//! What goes out: the version, the operating system and its version, the
//! CPU architecture, a few settings (each from a fixed set of values, the
//! saved hosts and such only counted, in bands), and how many times each
//! feature was used. Never a host, credential, command, file name, path or
//! anything that identifies the machine: Aptabase tells devices apart from
//! the address and the User-Agent, hashed with a salt it replaces daily.
//!
//! Aptabase has no Rust SDK beyond its Tauri plugin, so this posts to its
//! REST API itself, as its other SDKs do: batches of up to 25 events, the
//! network and its 5xx retried, any other refusal dropped. Unlike them,
//! nothing runs on a timer: the workspace sends signals (started, the
//! window came forward, a feature was used, the switch flipped) to one
//! thread, which counts, works out what is due and sends it then. Feature
//! uses are summed for four hours into one `usage` event and kept on disk
//! until it is sent, so quitting loses nothing and never waits.
//!
//! Only release builds report; a development build does with
//! `SHELLRS_ANALYTICS_DEBUG` set, as debug events, summed by the minute and
//! retried after ten seconds. The external CLI, a second copy passing its
//! links on, and the UI tests never start it.
mod client;
mod model;
mod system;
pub use client::{Analytics, AnalyticsServices, HttpTransport, Transport};
pub use model::{Counter, Delivery, Signal, Snapshot};
pub use system::SystemInfo;
/// The Aptabase app ShellRS reports to. Not a secret: every build carries
/// it, and all it allows is sending events.
pub const APP_KEY: &str = "A-US-1582708162";
File diff suppressed because it is too large. Load diff
+97
View File
@@ -0,0 +1,97 @@
//! The machine as Aptabase hears of it: the system's name and version.
use os_info::{Type, Version};
/// The operating system, named as Aptabase's dashboard knows them:
/// `macOS`, `Windows`, or a Linux distribution such as `Ubuntu`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SystemInfo {
pub os_name: String,
/// Empty when the system does not say.
pub os_version: String,
}
impl SystemInfo {
/// Ask the system. It may start a process or two (`uname`,
/// `lsb_release`, `sw_vers`), so only the analytics thread asks.
pub fn detect() -> Self {
let info = os_info::get();
Self::from_os(info.os_type(), info.version())
}
fn from_os(kind: Type, version: &Version) -> Self {
let os_name = match kind {
// os_info's own name for it is "Mac OS".
Type::Macos => "macOS".to_string(),
Type::Windows => "Windows".to_string(),
Type::Unknown => std::env::consts::OS.to_string(),
kind => kind.to_string(),
};
let os_version = match version {
Version::Unknown => String::new(),
version => version.to_string(),
};
Self {
os_name,
os_version,
}
}
/// `ShellRS/0.1.5 (macOS 15.1.0; aarch64)`. Aptabase tells the devices
/// behind one address apart by it, so it says what differs between
/// machines and nothing that names one.
pub fn user_agent(&self, version: &str) -> String {
let system = format!("{} {}", self.os_name, self.os_version);
format!(
"ShellRS/{version} ({}; {})",
system.trim(),
std::env::consts::ARCH
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn systems_are_named_as_aptabase_knows_them() {
let macos = SystemInfo::from_os(Type::Macos, &Version::Semantic(15, 1, 0));
assert_eq!(macos.os_name, "macOS");
assert_eq!(macos.os_version, "15.1.0");
let windows = SystemInfo::from_os(Type::Windows, &Version::Semantic(10, 0, 22631));
assert_eq!(windows.os_name, "Windows");
assert_eq!(windows.os_version, "10.0.22631");
let ubuntu = SystemInfo::from_os(Type::Ubuntu, &Version::Custom("24.04".into()));
assert_eq!(ubuntu.os_name, "Ubuntu");
let unknown = SystemInfo::from_os(Type::Unknown, &Version::Unknown);
assert_eq!(unknown.os_name, std::env::consts::OS);
assert_eq!(unknown.os_version, "");
}
#[test]
fn the_user_agent_names_the_system_and_architecture() {
let macos = SystemInfo::from_os(Type::Macos, &Version::Semantic(15, 1, 0));
assert_eq!(
macos.user_agent("0.1.5"),
format!("ShellRS/0.1.5 (macOS 15.1.0; {})", std::env::consts::ARCH)
);
let bare = SystemInfo::from_os(Type::Unknown, &Version::Unknown);
assert_eq!(
bare.user_agent("0.1.5"),
format!(
"ShellRS/0.1.5 ({}; {})",
std::env::consts::OS,
std::env::consts::ARCH
)
);
}
#[test]
fn this_machine_has_a_name() {
// os_info starts `uname` and the like.
let _forks = crate::testing::no_forks();
let info = SystemInfo::detect();
assert!(!info.os_name.is_empty());
}
}
+249 -4
View File
@@ -5,8 +5,15 @@ use gpui_kit::*;
use crate::{
cli::AgentKind,
explorer::{ExplorerId, FileSizeFormat},
host::{CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop},
docker::{ContainerCommand, ContainerSubject, DockerObject},
editor::EditorId,
explorer::{ExplorerId, FileSizeFormat, PaneSide},
host::{
CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop, SnippetCategoryId, SnippetId,
},
monitor::MonitorDetail,
processes::ProcessSort,
services::ServiceCommand,
terminal::{LocalTerminalId, RemoteTerminalId},
};
@@ -15,6 +22,11 @@ gpui_kit::actions!(
[
/// Open the new-host dialog for a host at the root of the tree.
NewHost,
/// Open the 临时连接 dialog: connect to a host without saving it.
NewTemporaryConnection,
/// Open 快速连接: search the saved hosts and connect to one or
/// several.
QuickConnect,
/// Open the new-group dialog for a top-level group.
NewGroup,
/// Open a new local login-shell terminal.
@@ -36,6 +48,22 @@ gpui_kit::actions!(
ClearTerminal,
/// Show or hide the left dock, whichever list it is showing.
ToggleHostPanel,
/// Show or hide the right sidebar, whichever tool it is showing.
ToggleToolSidebar,
/// Read the network connections of the SSH terminal's host again.
RefreshConnections,
/// Read the processes of the SSH terminal's host again.
RefreshProcesses,
/// Read the services of the SSH terminal's host again.
RefreshServices,
/// Read the Docker of the SSH terminal's host again.
RefreshDocker,
/// Read the bash history of the SSH terminal's host again.
RefreshHistory,
/// Open the new-snippet dialog, for a snippet of no category.
NewSnippet,
/// Open the new-category dialog of 命令片段.
NewSnippetCategory,
/// Show the host list in the left dock.
ShowHosts,
/// Show the port-forwarding list in the left dock.
@@ -75,6 +103,12 @@ gpui_kit::actions!(
CollapseAllGroups,
/// Close the center tab displayed most recently (the one ⌘W acts on).
CloseActiveTab,
/// Show the tab after the current one in its group, the first after
/// the last.
NextTab,
/// Show the tab before the current one in its group, the last before
/// the first.
PreviousTab,
/// Open the settings tab, or bring it forward if it is already open.
OpenSettings,
/// Close the settings tab.
@@ -85,8 +119,22 @@ gpui_kit::actions!(
ZoomOut,
/// Reset the application base font.
ZoomReset,
/// Make the terminals' text one size larger (设置 › 终端 › 字号).
ZoomTerminalIn,
/// Make the terminals' text one size smaller.
ZoomTerminalOut,
/// Put the terminals' text back to its default size.
ZoomTerminalReset,
/// Quit the application.
Quit,
/// Show the previewed image larger, by one step.
ZoomPreviewIn,
/// Show the previewed image smaller, by one step.
ZoomPreviewOut,
/// Fit the previewed image into its frame.
FitPreview,
/// Show the previewed image at its own size (100%).
ActualSizePreview,
/// Put the `shellrs` command on the PATH.
InstallCliCommand,
/// Take the `shellrs` command off the PATH.
@@ -99,12 +147,17 @@ gpui_kit::actions!(
CheckForUpdates,
/// Download the newer ShellRS that was found.
DownloadUpdate,
/// Show the newer ShellRS: what changed, and restarting into it.
/// Show the newer ShellRS: restarting into it, and where to read
/// what changed.
ShowUpdate,
/// Restart into the downloaded ShellRS.
RestartToUpdate,
/// Open the page to download ShellRS by hand.
OpenDownloadPage,
/// Open the website's changelog.
OpenChangelog,
/// Open the ShellRS website.
OpenWebsite,
]
);
@@ -167,6 +220,9 @@ id_actions! {
/// Close one SFTP tab.
CloseExplorer(ExplorerId);
/// Close one editor tab, asking first when it has unsaved changes.
CloseEditor(EditorId);
/// Open the dialog that gives one SFTP tab its own title.
RenameExplorer(ExplorerId);
@@ -177,6 +233,45 @@ id_actions! {
/// Show the SFTP 大小 column in another format, from its title's menu.
SetFileSizeFormat(FileSizeFormat);
/// Show the dot files of one side of the SFTP tabs, or stop showing
/// them: that side of every tab, kept in the settings.
ToggleHiddenFiles(PaneSide);
/// Show the tab at a position, from 1, in the current tab's group; 9 is
/// the last, as in browsers.
SwitchToTab(usize);
/// Show a tool in the right sidebar, or hide the sidebar if it is
/// already showing that tool.
ToggleTool(ToolKind);
/// Unfold a part of the system monitor, or fold it away again.
ToggleMonitorDetail(MonitorDetail);
/// Sort the process list by memory or CPU, the most first; by the same
/// again, the other way.
SortProcesses(ProcessSort);
/// Open the details of a process of the host of the SSH terminal in
/// front, by its PID.
ShowProcess(u32);
/// Open the details of a service of the host of the SSH terminal in
/// front, by its name.
ShowService(String);
/// Fold a compose project of the Docker tool away, or unfold it, by
/// its name.
ToggleDockerProject(String);
/// Copy a command of 历史命令 or 命令片段 to the clipboard.
CopyCommand(String);
/// Open the new-snippet dialog with this category picked.
NewSnippetIn(SnippetCategoryId);
/// Open the edit-snippet dialog.
EditSnippet(SnippetId);
/// Ask for confirmation, then delete a snippet.
DeleteSnippet(SnippetId);
/// Open the rename dialog of a snippet category.
RenameSnippetCategory(SnippetCategoryId);
/// Ask for confirmation, then delete a snippet category with the
/// snippets in it.
DeleteSnippetCategory(SnippetCategoryId);
/// Fold a category of 命令片段 away, or unfold it; `None` is 未分类.
ToggleSnippetCategory(Option<SnippetCategoryId>);
/// Start a port forward over a connection of its own.
StartForward(ForwardId);
@@ -205,6 +300,68 @@ pub struct MoveHostNode {
pub destination: NodeDrop,
}
/// Ask for confirmation, then end a process of the host of the SSH terminal
/// in front: with SIGTERM, or with `force` SIGKILL.
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct EndProcess {
pub pid: u32,
pub force: bool,
}
/// Start, stop or restart a service of the host of the SSH terminal in
/// front, or enable or disable it at boot; asks first before stopping or
/// restarting it.
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct ControlService {
pub name: String,
pub command: ServiceCommand,
}
/// Start, stop or restart containers of the host of the SSH terminal in
/// front: one, or a compose project's all; asks first before stopping or
/// restarting them.
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct ControlContainers {
/// What they are, for the question and the notification: 「容器“web”」,
/// 「项目“php-56”」.
pub subject: ContainerSubject,
pub ids: Vec<String>,
pub command: ContainerCommand,
}
/// Open the details of a container, an image, a volume or a network of the
/// host of the SSH terminal in front.
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct ShowDockerObject {
pub object: DockerObject,
/// What Docker knows it by: the ID, or a volume's name.
pub id: String,
}
/// Ask, then remove a container, an image, a volume or a network of the
/// host of the SSH terminal in front.
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct RemoveDockerObject {
pub object: DockerObject,
/// What Docker knows it by: the ID, or a volume's name.
pub id: String,
pub name: String,
}
/// Put a command on the input line of the SSH terminal in front, in place
/// of what is typed there, and with `run` run it.
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct EnterCommand {
pub command: String,
pub run: bool,
}
/// One tab of the center area, by the identity of what it shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CenterTab {
@@ -213,6 +370,32 @@ pub enum CenterTab {
LocalTerminal(LocalTerminalId),
/// There is at most one settings tab.
Settings,
Editor(EditorId),
}
/// A tool of the right sidebar, in the order of the switch beside it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ToolKind {
#[default]
Snippets,
History,
Docker,
Services,
Processes,
Connections,
Monitor,
}
impl ToolKind {
pub const ALL: [ToolKind; 7] = [
ToolKind::Snippets,
ToolKind::History,
ToolKind::Docker,
ToolKind::Services,
ToolKind::Processes,
ToolKind::Connections,
ToolKind::Monitor,
];
}
/// Which tabs of a tab bar a batch close takes, relative to one tab.
@@ -273,17 +456,35 @@ pub enum ExplorerCommand {
Forward {
remote: bool,
},
/// Enter or double-click: open the directory under the cursor.
/// Enter or double-click: open the directory under the cursor, or edit
/// the file there.
Open {
remote: bool,
},
/// F4 and 编辑: open a file in the editor, `path` or else the one under
/// the cursor (WinSCP's Edit).
Edit {
remote: bool,
path: Option<String>,
},
/// 预览: show an image or a Markdown file, `path` or else the one under
/// the cursor.
Preview {
remote: bool,
path: Option<String>,
},
/// WinSCP's 打开目录/书签 dialog: type a directory or pick a bookmark.
OpenDirectory {
remote: bool,
},
/// 复制路径 on the path label: the directory shown.
CopyPath {
remote: bool,
},
/// 复制路径 on a row: the selected rows' full paths, one per line.
CopySelectedPaths {
remote: bool,
},
/// Move keyboard focus to a pane's file list.
FocusPane {
remote: bool,
@@ -405,6 +606,50 @@ impl ExplorerAction {
&self.command
}
}
/// Commands of an editor tab, shared by its toolbar, tab menu, dialogs and
/// key bindings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditorCommand {
/// ⌘S: write the file back, unless it changed since it was read.
Save,
/// 覆盖: write it back although it changed; `close` once that worked.
Overwrite {
close: bool,
},
/// Read the file again, asking first when there are changes.
Reload,
/// Read it again, throwing the changes away.
ReloadConfirmed,
/// 保存 in the close question: save, then close if that worked.
SaveAndClose,
/// 放弃修改 in the close question: close without saving.
CloseConfirmed,
CopyPath,
}
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct EditorAction {
editor: EditorId,
command: EditorCommand,
}
impl EditorAction {
pub fn new(editor: EditorId, command: EditorCommand) -> Self {
Self { editor, command }
}
pub fn editor(&self) -> EditorId {
self.editor
}
pub fn command(&self) -> EditorCommand {
self.command
}
}
/// An editor key binding; the workspace sends it to the editor that holds
/// focus.
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct EditorShortcut(pub EditorCommand);
/// A file-list key binding. The binding carries the pane side, and the
/// workspace sends it to the explorer that holds focus.
#[derive(Action, Clone, PartialEq, Eq)]
+59 -3
View File
@@ -47,7 +47,37 @@ icon_assets!(
RectangleEllipsis,
FileKey,
UserRoundKey,
CircleArrowUp
CircleArrowUp,
CodeXml,
RotateCcwClock,
Activity,
Monitor,
AppWindow,
Keyboard,
RotateCcw,
Network,
RadioTower,
ArrowDownLeft,
ArrowUpRight,
ListFilter,
CircleStop,
OctagonX,
ServerCog,
RotateCw,
CircleCheck,
Ban,
CircleDot,
FileText,
FilePenLine,
Save,
Eye,
EyeOff,
ZoomIn,
ZoomOut,
Zap,
ExternalLink,
Highlighter,
GripVertical
]
);
@@ -93,13 +123,28 @@ os_icons! {
"icons/os/netbsd.svg" => "netbsd",
}
/// Brand marks that Lucide, which draws no brands, cannot stand in for:
/// Docker's whale on the right sidebar's Docker tool. From Simple Icons (CC0),
/// single-path and monochrome like the operating-system marks.
const BRAND_ICONS: &[(&str, &[u8])] = &[(
DOCKER_ICON,
include_bytes!("../../assets/icons/brand/docker.svg"),
)];
/// Docker's whale, for `Icon::path`.
pub const DOCKER_ICON: &str = "icons/brand/docker.svg";
/// Asset source for the application: the default component bundle plus the
/// extra icons above.
/// extra icons and the marks above.
pub struct AppAssets;
impl AssetSource for AppAssets {
fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
if let Some((_, bytes)) = OS_ICONS.iter().find(|(name, _)| *name == path) {
if let Some((_, bytes)) = OS_ICONS
.iter()
.chain(BRAND_ICONS)
.find(|(name, _)| *name == path)
{
return Ok(Some(Cow::Borrowed(bytes)));
}
if let Some(bytes) = ExtraIcons.load(path)? {
@@ -114,6 +159,7 @@ impl AssetSource for AppAssets {
paths.extend(
OS_ICONS
.iter()
.chain(BRAND_ICONS)
.map(|(name, _)| *name)
.filter(|name| name.starts_with(path))
.map(SharedString::from),
@@ -142,6 +188,16 @@ mod tests {
}
}
#[test]
fn the_docker_tool_has_dockers_whale() {
let bytes = AppAssets
.load(super::DOCKER_ICON)
.unwrap()
.expect("in the bundle");
assert!(bytes.starts_with(b"<svg"));
assert!(String::from_utf8_lossy(&bytes).contains("<title>Docker</title>"));
}
#[test]
fn the_bundle_lists_the_operating_system_icons() {
let listed = AppAssets.list("icons/os/").unwrap();
+92 -221
View File
@@ -4,18 +4,28 @@ mod actions;
mod app_icon;
mod assets;
mod paths;
mod quit;
mod shortcuts;
mod window_hiding;
pub use actions::*;
pub use app_icon::show_logo_when_unbundled;
pub use assets::{AppAssets, CatalogIcon};
pub use assets::{AppAssets, CatalogIcon, DOCKER_ICON};
pub use paths::{
cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path, updates_dir,
analytics_path, cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir,
settings_path, updates_dir, window_state_path,
};
pub use quit::{quit_held_back, set_quit_guard};
pub use shortcuts::{
Refusal, SHORTCUTS, Shortcut, ShortcutGroup, ShortcutOverrides, apply_shortcuts, check,
fixed_bindings, key_caps, key_text,
};
pub use window_hiding::{bring_forward, hide_when_closed};
use gpui_kit::component::{Theme, dock::ToggleZoom};
use gpui_kit::component::Theme;
use gpui_kit::*;
use crate::terminal::{TERMINAL_FIND_KEY_CONTEXT, TERMINAL_KEY_CONTEXT, terminal_key_bindings};
use crate::i18n::t;
/// Key context of the host panel, for bindings that only apply there.
pub const HOST_PANEL_CONTEXT: &str = "HostPanel";
@@ -25,248 +35,109 @@ pub const FORWARD_PANEL_CONTEXT: &str = "ForwardPanel";
/// Key context of the credential list.
pub const CREDENTIAL_PANEL_CONTEXT: &str = "CredentialPanel";
/// Key contexts of the two SFTP file lists.
/// Key context of an editor tab.
pub const EDITOR_CONTEXT: &str = "FileEditor";
/// Key context of the image in a preview.
pub const IMAGE_PREVIEW_CONTEXT: &str = "ImagePreview";
pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList";
pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList";
/// What the window says when the local database cannot be opened: the run
/// goes on, keeping its changes in memory only.
pub fn database_unavailable(error: &anyhow::Error) -> SharedString {
t!("app.database_unavailable", error = error)
}
/// Initialize GPUI Kit and everything global to the application.
pub fn init(cx: &mut App) {
gpui_kit::init(cx);
// Before ours: rebuilding the keymap starts from gpui-kit's own.
shortcuts::keep_kit_bindings(cx);
deepen_list_hover(cx);
gpui_kit::component::set_locale("zh-CN");
cx.bind_keys(key_bindings());
cx.on_action(|_: &Quit, cx: &mut App| cx.quit());
// gpui-kit's own text in the language ShellRS's is in: the saved one,
// which `main` set before anything was built.
crate::i18n::set_locale(crate::i18n::locale());
// The defaults; the settings bring the user's changes once read.
apply_shortcuts(&ShortcutOverrides::default(), cx);
cx.on_action(|_: &Quit, cx: &mut App| quit::quit(cx));
}
/// How much of a selected row's color hover gets in the dark appearance.
/// Selected rows there have the deepened hover color itself; hover with all
/// of it would look the same.
const DARK_HOVER_SHARE: f32 = 0.5;
/// Use a stronger version of the theme's neutral list hover color throughout
/// the app.
/// the app. Dark, the stronger one is for selected rows
/// (`selected_row_color`) and hover, in lists and tables, gets half of it.
pub(crate) fn deepen_list_hover(cx: &mut App) {
let theme = Theme::global_mut(cx);
let hover = theme.list_hover;
let deeper = hover.alpha(((hover.a + 0.2) * 1.2).min(1.0));
theme.list_hover = deeper;
theme.tokens.list_hover = deeper.into();
if theme.is_dark() {
let faint = deeper.alpha(deeper.a * DARK_HOVER_SHARE);
theme.list_hover = faint;
theme.tokens.list_hover = faint.into();
theme.table_hover = faint;
theme.tokens.table_hover = faint.into();
} else {
theme.list_hover = deeper;
theme.tokens.list_hover = deeper.into();
}
Theme::sync_base(cx);
}
/// Give the host tree three times the list hover's contrast against its sidebar.
/// The recent-host list keeps the shared hover color unchanged.
pub(crate) fn host_tree_selection_color(theme: &Theme) -> Hsla {
/// The deepened list hover: light the hover itself, dark the full color
/// hover has only a share of.
fn deepened_hover(theme: &Theme) -> Hsla {
let hover = theme.list_hover;
if theme.is_dark() {
hover.alpha((hover.a / DARK_HOVER_SHARE).min(1.0))
} else {
hover
}
}
/// The selected row of the sidebar's lists (hosts, port forwards,
/// credentials) and of the recent hosts, so that it stands out from hover.
/// Light, three times the list hover's contrast against the sidebar; dark,
/// where that glares, the deepened hover.
pub(crate) fn selected_row_color(theme: &Theme) -> Hsla {
let sidebar = theme.sidebar;
let hover = sidebar.blend(theme.list_hover);
let hover = sidebar.blend(deepened_hover(theme));
let mut selected = hover.alpha(1.0);
if theme.is_dark() {
return selected;
}
selected.l = (sidebar.l + (hover.l - sidebar.l) * 3.0).clamp(0.0, 1.0);
selected
}
fn key_bindings() -> Vec<KeyBinding> {
#[cfg(target_os = "macos")]
const PRIMARY: &str = "cmd";
#[cfg(not(target_os = "macos"))]
const PRIMARY: &str = "ctrl";
#[cfg(test)]
mod tests {
use gpui_kit::TestAppContext;
use gpui_kit::component::{ActiveTheme as _, Theme, ThemeMode};
let primary = |key: &str| format!("{PRIMARY}-{key}");
let mut bindings = vec![
KeyBinding::new(&primary("n"), NewHost, None),
KeyBinding::new(&primary("shift-n"), NewGroup, None),
KeyBinding::new(&primary("b"), ToggleHostPanel, None),
KeyBinding::new(&primary("k"), FocusSearch, None),
KeyBinding::new(&primary("t"), NewLocalTerminal, None),
KeyBinding::new(&primary("w"), CloseActiveTab, None),
KeyBinding::new(&primary(","), OpenSettings, None),
KeyBinding::new(&primary("="), ZoomIn, None),
KeyBinding::new(&primary("-"), ZoomOut, None),
KeyBinding::new(&primary("0"), ZoomReset, None),
KeyBinding::new(&primary("q"), Quit, None),
KeyBinding::new("shift-escape", ToggleZoom, None),
KeyBinding::new("enter", ConnectSelected, Some(HOST_PANEL_CONTEXT)),
KeyBinding::new("enter", ConnectSelected, Some(RECENT_HOSTS_CONTEXT)),
KeyBinding::new("enter", ToggleSelectedForward, Some(FORWARD_PANEL_CONTEXT)),
KeyBinding::new("up", SelectPreviousForward, Some(FORWARD_PANEL_CONTEXT)),
KeyBinding::new("down", SelectNextForward, Some(FORWARD_PANEL_CONTEXT)),
KeyBinding::new(
"enter",
EditSelectedCredential,
Some(CREDENTIAL_PANEL_CONTEXT),
),
KeyBinding::new(
"up",
SelectPreviousCredential,
Some(CREDENTIAL_PANEL_CONTEXT),
),
KeyBinding::new("down", SelectNextCredential, Some(CREDENTIAL_PANEL_CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-c", CopyTerminal, Some(TERMINAL_KEY_CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-v", PasteTerminal, Some(TERMINAL_KEY_CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-shift-c", CopyTerminal, Some(TERMINAL_KEY_CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-shift-v", PasteTerminal, Some(TERMINAL_KEY_CONTEXT)),
// In a terminal ⌘K clears, as in other macOS terminals; everywhere
// else it still focuses the host search. Inside a terminal, Ctrl
// with a letter belongs to the shell, so other platforms add Shift, as
// they do for copy and paste.
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-k", ClearTerminal, Some(TERMINAL_KEY_CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-shift-k", ClearTerminal, Some(TERMINAL_KEY_CONTEXT)),
KeyBinding::new(
"escape",
DismissTerminalFind,
Some(TERMINAL_FIND_KEY_CONTEXT),
),
];
// The find keys work from the terminal and from inside the find bar.
#[cfg(target_os = "macos")]
let find_keys = ["cmd-f", "cmd-g", "cmd-shift-g"];
#[cfg(not(target_os = "macos"))]
let find_keys = ["ctrl-shift-f", "f3", "shift-f3"];
for context in [TERMINAL_KEY_CONTEXT, TERMINAL_FIND_KEY_CONTEXT] {
bindings.extend([
KeyBinding::new(find_keys[0], FindInTerminal, Some(context)),
KeyBinding::new(find_keys[1], FindNextInTerminal, Some(context)),
KeyBinding::new(find_keys[2], FindPreviousInTerminal, Some(context)),
]);
}
bindings.extend(terminal_key_bindings());
bindings.extend(file_list_key_bindings());
bindings
}
use super::{deepen_list_hover, deepened_hover};
/// WinSCP's file-panel keys, bound once per pane with the side baked into the
/// command. The movement keys are bound one level deeper, at the table, so
/// they replace `DataTable`'s own single-row selection keys.
fn file_list_key_bindings() -> Vec<KeyBinding> {
use crate::explorer::CursorMotion;
#[cfg(target_os = "macos")]
const PRIMARY: &str = "cmd";
#[cfg(not(target_os = "macos"))]
const PRIMARY: &str = "ctrl";
let mut bindings = Vec::new();
for (context, remote) in [
(LOCAL_FILE_LIST_CONTEXT, false),
(REMOTE_FILE_LIST_CONTEXT, true),
] {
let table = format!("{context} > DataTable");
let bind = |keys: &str, command: ExplorerCommand, context: &str| {
KeyBinding::new(keys, ExplorerShortcut(command), Some(context))
};
// Several keys per command: the last one registered is the one
// tooltips show, so WinSCP's key goes last.
#[cfg(target_os = "macos")]
bindings.extend([
bind("cmd-up", ExplorerCommand::Up { remote }, context),
bind("cmd-[", ExplorerCommand::Back { remote }, context),
bind("cmd-]", ExplorerCommand::Forward { remote }, context),
bind("cmd-backspace", ExplorerCommand::Delete { remote }, context),
bind("cmd-i", ExplorerCommand::Properties { remote }, context),
// ⌘H hides the app on macOS; Finder's home is ⌘⇧H.
bind("cmd-shift-h", ExplorerCommand::Home { remote }, context),
// Finder's 前往文件夹.
bind(
"cmd-shift-g",
ExplorerCommand::OpenDirectory { remote },
context,
),
]);
#[cfg(not(target_os = "macos"))]
bindings.push(bind("ctrl-h", ExplorerCommand::Home { remote }, context));
bindings.extend([
bind("backspace", ExplorerCommand::Up { remote }, context),
bind(
&format!("{PRIMARY}-\\"),
ExplorerCommand::Root { remote },
context,
),
bind(
&format!("{PRIMARY}-r"),
ExplorerCommand::Refresh { remote },
context,
),
bind("alt-left", ExplorerCommand::Back { remote }, context),
bind("alt-right", ExplorerCommand::Forward { remote }, context),
// WinSCP's Ctrl+B already toggles the host panel here.
bind(
&format!("{PRIMARY}-d"),
ExplorerCommand::AddBookmark { remote, path: None },
context,
),
bind("f2", ExplorerCommand::Rename { remote }, context),
bind(
"f7",
ExplorerCommand::New {
remote,
kind: crate::explorer::NewEntryKind::Folder,
},
context,
),
bind("delete", ExplorerCommand::Delete { remote }, context),
bind("f8", ExplorerCommand::Delete { remote }, context),
bind("f9", ExplorerCommand::Properties { remote }, context),
bind("f5", ExplorerCommand::Transfer { remote }, context),
bind(
"space",
ExplorerCommand::ToggleSelection { remote },
context,
),
bind(
"insert",
ExplorerCommand::ToggleSelection { remote },
context,
),
bind(
&format!("{PRIMARY}-a"),
ExplorerCommand::SelectAll { remote },
context,
),
// WinSCP's 打开目录/书签.
bind(
&format!("{PRIMARY}-o"),
ExplorerCommand::OpenDirectory { remote },
context,
),
bind("enter", ExplorerCommand::Open { remote }, context),
#[cfg(target_os = "macos")]
bind("cmd-down", ExplorerCommand::Open { remote }, context),
bind(
"tab",
ExplorerCommand::FocusPane { remote: !remote },
&table,
),
bind(
"shift-tab",
ExplorerCommand::FocusPane { remote: !remote },
&table,
),
KeyBinding::new("left", NoAction {}, Some(&table)),
KeyBinding::new("right", NoAction {}, Some(&table)),
]);
for (key, motion) in [
("up", CursorMotion::Up),
("down", CursorMotion::Down),
("pageup", CursorMotion::PageUp),
("pagedown", CursorMotion::PageDown),
("home", CursorMotion::Home),
("end", CursorMotion::End),
] {
for extend in [false, true] {
let keys = if extend {
format!("shift-{key}")
} else {
key.to_string()
};
bindings.push(bind(
&keys,
ExplorerCommand::MoveCursor {
remote,
motion,
extend,
},
&table,
));
#[gpui_kit::test]
fn dark_hover_is_fainter_than_a_selected_row(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_kit::component::init(cx);
for mode in [ThemeMode::Light, ThemeMode::Dark] {
Theme::change(mode, None, cx);
deepen_list_hover(cx);
let theme = cx.theme();
let selected = deepened_hover(theme);
assert!(selected.a > 0.9, "{mode:?}: {selected:?}");
match mode {
ThemeMode::Light => assert_eq!(theme.list_hover, selected),
ThemeMode::Dark => {
assert!((theme.list_hover.a * 2. - selected.a).abs() < 0.01);
assert_eq!(theme.table_hover, theme.list_hover);
}
}
}
}
});
}
bindings
}
+14
View File
@@ -50,6 +50,20 @@ pub fn settings_path() -> std::io::Result<PathBuf> {
Ok(dir.join("settings.json"))
}
/// The main window's last size, maximized state and place, for the next
/// launch (设置 › 应用 › 窗口). Kept apart from the settings: it changes
/// whenever the window moves.
pub fn window_state_path() -> PathBuf {
data_dir().join("window.json")
}
/// What 匿名使用统计 keeps between runs: whether this installation was
/// reported, and the uses not sent yet. Beside the database, so a fresh
/// data directory is a fresh installation.
pub fn analytics_path() -> PathBuf {
data_dir().join("analytics.json")
}
/// Where the running app listens for the `shellrs` command. The command
/// and the app both ask here, so both follow `SHELLRS_DATA_DIR`.
pub fn cli_socket_path() -> PathBuf {
+63
View File
@@ -0,0 +1,63 @@
//! ⌘Q asks first when quitting would lose something: an editor's changes
//! that are not saved yet.
//!
//! The guard is a global rather than a listener on the workspace's element:
//! after a dialog closes nothing is focused, and then an action reaches only
//! the root of the window, never the workspace, while a global listener
//! hears it wherever focus is.
use std::rc::Rc;
use gpui_kit::*;
type Ask = Rc<dyn Fn(&mut Window, &mut App) -> bool>;
/// Asks the window whether a quit may go ahead. `ask` returns true when it
/// held the quit back, having asked the user (and quitting itself if they
/// agree).
struct QuitGuard {
window: AnyWindowHandle,
ask: Ask,
}
impl Global for QuitGuard {}
/// Put a quit through `ask`, run in `window`, from now on.
pub fn set_quit_guard(
window: AnyWindowHandle,
ask: impl Fn(&mut Window, &mut App) -> bool + 'static,
cx: &mut App,
) {
cx.set_global(QuitGuard {
window,
ask: Rc::new(ask),
});
}
/// The `Quit` action: quit, unless the guard holds it back.
pub(super) fn quit(cx: &mut App) {
let Some(guard) = cx.try_global::<QuitGuard>() else {
cx.quit();
return;
};
let (window, ask) = (guard.window, guard.ask.clone());
// The action is dispatched while the window is being updated, so it can
// only be asked once that is over.
cx.defer(move |cx| {
let held = window
.update(cx, |_, window, cx| ask(window, cx))
.unwrap_or(false);
if !held {
cx.quit();
}
});
}
/// Whether the guard holds a quit back, asked from inside the window: when
/// its close button would quit (Windows and Linux).
pub fn quit_held_back(window: &mut Window, cx: &mut App) -> bool {
match cx.try_global::<QuitGuard>().map(|guard| guard.ask.clone()) {
Some(ask) => ask(window, cx),
None => false,
}
}
+1116
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
//! On macOS the window's close button hides ShellRS instead of closing it.
//!
//! ShellRS has one window, and the workspace in it owns everything that
//! runs: connections, transfers, port forwards, the external CLI. Closing
//! it would stop all of that yet leave the app in the Dock with no window,
//! and the Dock icon could not bring one back. So, like Mac terminals,
//! closing hides the app: the Dock icon, ⌘Tab or opening ShellRS again
//! shows the window as it was, and ⌘Q quits. Windows and Linux quit when
//! the window closes, as GPUI does there by default, but like ⌘Q only once
//! the quit guard lets them (unsaved changes in an editor are asked about).
use gpui_kit::{App, Window};
/// Make the window's close button hide ShellRS on macOS. Elsewhere closing
/// quits, after asking the quit guard.
#[cfg(target_os = "macos")]
pub fn hide_when_closed(window: &Window, cx: &App) {
window.on_window_should_close(cx, |_, cx| {
cx.hide();
false
});
}
#[cfg(not(target_os = "macos"))]
pub fn hide_when_closed(window: &Window, cx: &App) {
window.on_window_should_close(cx, |window, cx| !super::quit::quit_held_back(window, cx));
}
/// Show the window again, in front of other apps: when the Dock icon is
/// clicked, or ShellRS is opened again while it runs.
pub fn bring_forward(window: &mut Window, cx: &mut App) {
// Activating alone may leave a hidden app's windows hidden.
#[cfg(target_os = "macos")]
if let Some(main_thread) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(main_thread).unhide(None);
}
cx.activate(true);
window.activate_window();
}
+90 -13
View File
@@ -1,25 +1,27 @@
---
name: shellrs
description: This skill should be used when the user asks to "connect to a server", "SSH into a machine", "run a command on a remote host", "check a server", "list servers" or "list SSH sessions", "upload/download files to a server", or mentions ShellRS or the `shellrs` CLI. Use `shellrs` instead of running `ssh`, `scp`, or `sftp` directly.
description: This skill should be used when the user asks to "connect to a server", "SSH into a machine", "run a command on a remote host", "check a server", "list servers" or "list SSH sessions", "upload/download or sync files to a server", "add, change or remove a saved host or credential", or mentions ShellRS or the `shellrs` CLI. Use `shellrs` instead of running `ssh`, `scp`, or `sftp` directly.
---
# ShellRS CLI
Use the `shellrs` CLI to list the user's saved SSH hosts, run one-off commands, and transfer files through the user's running ShellRS desktop app. ShellRS holds the credentials and connects with the saved configuration.
Use the `shellrs` CLI to manage the user's saved SSH hosts and credentials, run one-off commands, and transfer files through the user's running ShellRS desktop app. ShellRS holds the credentials and connects with the saved configuration.
Prefer `shellrs` over running `ssh`, `scp`, or `sftp` directly.
Run `shellrs --help` or `shellrs <command> --help` for details. `list` prints JSON when piped or given `--json`.
Run `shellrs --help` or `shellrs <command> --help` for details. Every command prints JSON when piped or given `--json`.
## Common Workflow
```bash
shellrs list --query <keyword> --json
shellrs hosts list -q <keyword> --json
shellrs hosts show <host-id> --json
shellrs exec <host-id> "<command>"
shellrs upload <host-id> <local-path> <remote-path>
shellrs download <host-id> <remote-path> <local-path>
shellrs sync <host-id> <local-dir> <remote-dir> [--delete]
```
`<host-id>` is the 16-character `id` from `shellrs list` (the same ID that ShellRS copies with 复制 ID).
`<host-id>` is the 16-character `id` from `shellrs hosts list`, the same ID that ShellRS copies with Copy ID (复制 ID).
For a simple command, pass one complete remote shell command string as `<command>`.
Pass a command through stdin when it contains quotes, pipes, redirects, JSON, `$`, backticks, or nested shell code, and quote the heredoc delimiter so the local shell leaves it alone:
@@ -30,6 +32,23 @@ shellrs exec <host-id> --stdin <<'EOF'
EOF
```
## Hosts Opened From a Bastion Host
A host listed with `"temporary": true` may have been opened by a bastion host's link that allows one login. Then `exec`, `upload` and `download`, which log in again, fail with `missing_credential` or `connect_failed`, and the message says so. Run the command in the host's terminal open in ShellRS instead:
```bash
shellrs exec <host-id> --terminal "<command>"
shellrs exec <host-id> --terminal --stdin <<'EOF'
<command>
EOF
```
- It types into the user's own terminal tab, in sight of the user. It sends Ctrl-C first, which interrupts whatever runs there: use it only on a tab the user handed to you, not on one they are working in.
- The command runs where that terminal is now: its user, folder and machine, after any `su`, `sudo -i` or nested `ssh`. With `--json`, the result's `context` says where: `{"user", "host", "cwd", "shell", "interp"}`.
- stderr comes as stdout. One command at a time per terminal. Only bash, dash and busybox ash.
- Programs that ask for a password wait in the user's tab: use `sudo -n` and other non-interactive flags. Give your own tool a timeout; when the command is given up, it gets Ctrl-C.
- Files cannot be transferred this way; write a small text file with a heredoc in the command.
## File Transfer
```bash
@@ -37,35 +56,85 @@ shellrs upload <host-id> ./dist /tmp/dist
shellrs upload <host-id> ./app.tar.gz /tmp/release.tar.gz
shellrs download <host-id> /var/log/nginx ./logs/nginx
shellrs download <host-id> /tmp/app.tar.gz ./latest.tar.gz
shellrs sync <host-id> ./dist /opt/app --delete
```
`sync` copies what the local folder holds into the remote folder, making the remote folder when it is not there (its parent must exist). Files with the same size and modification time are skipped, so running it again copies only what changed. `--delete` first removes what is in the remote folder and not in the local one. It only goes from this machine to the host.
## Manage Hosts and Credentials
```bash
shellrs hosts list
shellrs hosts show <host-id>
shellrs hosts create < host.json
shellrs hosts update <host-id> < changes.json
shellrs hosts delete <host-id>
shellrs credentials list
shellrs credentials show <credential-id>
shellrs credentials create < credential.json
shellrs credentials update <credential-id> < changes.json
shellrs credentials delete <credential-id>
```
`create` and `update` read one JSON object from stdin; `update` changes only the fields given. `shellrs hosts create --help` and `shellrs credentials create --help` list the fields. What `show --json` prints can be edited and passed back to `update` as it is. For example:
```bash
shellrs hosts create <<'EOF'
{"name": "web-01", "host": "10.0.1.12", "group": "生产/web", "auth": "credential", "credential": "<credential-id>"}
EOF
```
- `group` is a path such as `生产/web`; groups that are not there are made.
- A host logs in with `"auth": "password"` (its own password), `"credential"` (a saved credential, by its ID; the host takes the credential's user) or `"no_password"` (the SSH agent and the default keys).
- `route` is `{"type": "direct"}`, `{"type": "jump", "hosts": ["<host-id>", ...]}` or `{"type": "proxy", "kind": "http", "host": "...", "port": 3128}`.
- A credential is `"kind": "password"`, `"key"` (with `key_path`, a key file on this machine, or `private_key`, the key's text for ShellRS to keep) or `"agent"`.
- `hosts delete` refuses a host whose tabs are open in ShellRS, with `host_in_use`; `--force` closes them.
## Windows
The same commands work in PowerShell, cmd, and Git Bash. Local paths may be Windows paths (`.\dist`, `C:\Users\me\app.tar.gz`); remote paths are still POSIX paths.
The same commands work in PowerShell, cmd, and Git Bash. Local paths may be Windows paths (`.\dist`, `C:\Users\me\app.tar.gz`); remote paths are still POSIX paths. In Git Bash, use the heredoc form above.
In PowerShell, pass multi-line commands and commands containing quotes through stdin with a here-string instead of a heredoc. Windows PowerShell 5.1 breaks double quotes inside arguments, so do not rely on them there:
In PowerShell, prefer `exec --json`: build the request as an object, pipe it in, and read the result as JSON. Nothing in the command needs quoting (Windows PowerShell 5.1 breaks double quotes inside arguments), and the result is plain ASCII, so the console's code page cannot garble remote output:
```powershell
@{ host = '<host-id>'; command = 'grep -c "error" /var/log/app.log' } | ConvertTo-Json -Compress | shellrs exec --json | ConvertFrom-Json
```
`exec --json` reads `{"host": "<host-id>", "command": "<command>"}` from stdin and, once the command ends, prints `{"exit_code": N, "stdout": "...", "stderr": "..."}`, or `{"error": {"code": "...", "message": "..."}}` when it could not run it. The exit code is the same as without `--json`, in `$LASTEXITCODE`. Windows PowerShell 5.1 pipes text to programs as ASCII: write non-ASCII characters in the command as `\uXXXX` escapes.
`--json` prints nothing until the command ends. To see the output as it comes (a long build, a large log), pass the command through stdin with a here-string instead, after making PowerShell read and write UTF-8:
```powershell
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
@'
<command>
'@ | shellrs exec <host-id> --stdin
```
Before running `shellrs` in PowerShell, make it read and write UTF-8, or non-ASCII text (such as the Chinese names of saved hosts and remote output) turns into `?` or mojibake:
For `--terminal`, add `terminal = $true`:
```powershell
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
@{ host = '<host-id>'; command = 'df -h'; terminal = $true } | ConvertTo-Json -Compress | shellrs exec --json | ConvertFrom-Json
```
The exit code is in `$LASTEXITCODE`.
The `@'` must end its line and the `'@` must start one. Set the same encodings before the other commands, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake.
To create or update a host or credential from Windows PowerShell 5.1, pipe the JSON in the same way, writing non-ASCII characters in it as `\uXXXX` escapes (`"group": "\u751f\u4ea7"` for 生产):
```powershell
@{ name = 'web-01'; host = '10.0.1.12'; user = 'deploy' } | ConvertTo-Json -Compress | shellrs hosts create
```
## Rules
- Do not ask the user for SSH passwords, private keys, or passphrases. ShellRS holds those credentials.
- Only when the user gives you a password, key or passphrase to save in ShellRS, put it in the JSON on stdin (`password`, `private_key`, `passphrase`), never in a command argument. ShellRS keeps it in the system keychain and never prints it back: `show` only says whether one is saved.
- Create, change or delete hosts and credentials only when the user asks you to. Ask before `hosts delete --force`: it closes the host's open tabs. Deleting a credential makes its hosts log in on their own.
- `shellrs exec` takes a host id and one complete remote shell command string, opens a temporary SSH connection, runs the command, and closes the connection. Its stdout and stderr are the remote command's, and its exit code is the remote command's exit code.
- The remote command gets no stdin, so interactive programs (editors, pagers, password prompts) will not work. Use non-interactive flags such as `--yes`, `-y`, or `--no-pager`.
- Long-running tasks should use tmux, nohup, or systemd on the remote host.
- `shellrs upload` takes a host id, a local file or folder path, and a remote destination path. `shellrs download` takes a host id, a remote file or folder path, and a local destination path. Folders are copied recursively, and existing files are overwritten.
- `shellrs sync` takes a host id, a local folder and a remote folder, and copies the local folder's contents into the remote one; `--delete` removes remote files absent locally.
- Destination paths follow scp: if the destination exists and is a directory, the source keeps its name inside that directory; otherwise the destination path is the final file or folder path, and its parent directory must exist.
## Errors
@@ -73,8 +142,16 @@ The exit code is in `$LASTEXITCODE`.
Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. Exit code 255 means `shellrs` could not run the command at all.
- `not_running`: ShellRS is not open. Ask the user to open ShellRS.
- `not_enabled`: ask the user to turn on 启用外部 CLI in ShellRS under 设置 → 外部 CLI.
- `host_not_found`: run `shellrs list` again and use an `id` from its output.
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password.
- `not_enabled`: ask the user to turn on Enable external CLI (启用外部 CLI) in ShellRS under Settings → External CLI (设置 → 外部 CLI).
- `host_not_found`: run `shellrs hosts list` again and use an `id` from its output. `credential_not_found`: the same with `shellrs credentials list`.
- `bad_request` from `create` or `update`: the message says which field is wrong, in the words the ShellRS forms use.
- `host_in_use`: the host's tabs are open in ShellRS. Ask the user whether to close them (`--force`).
- `save_failed`: the change was made, but not all of it could be written to disk or the keychain. Tell the user the message.
- `version_mismatch`: the `shellrs` command and the running ShellRS are different versions. Ask the user to restart or update ShellRS.
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password. For a host opened from a bastion host, use `--terminal` (see above).
- `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it.
- `no_terminal` (`--terminal`): the host has no connected terminal in ShellRS. Ask the user to open or reconnect it.
- `terminal_busy` (`--terminal`): a full-screen program has the terminal, another `--terminal` command still runs there, the shell did not answer, or it did not start the command (a bastion host's command filter may hold it back). Tell the user the message.
- `unsupported_shell` (`--terminal`): the terminal runs a shell other than bash, dash or busybox ash. Tell the user.
- `too_long` (`--terminal`): split the command into shorter ones.
- `connect_failed` saying there is no permission to connect to ShellRS: the agent's sandbox blocks local inter-process communication. Ask the user to allow it, or run the command outside the sandbox.
+70 -19
View File
@@ -10,6 +10,8 @@ use std::{
use super::protocol::{CliError, ErrorCode, TransferCounters, TransferSummary};
use super::server::{CliBackend, CliTarget};
use crate::connection::{ConnectionPromptKind, ConnectionPromptReply};
use crate::i18n::t;
use crate::secrets::{SecretRef, SharedSecretStore};
use crate::sftp::{
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider,
TransferAnswer, TransferChoice, TransferPhase, TransferProgress, TransferQuestionKind,
@@ -25,13 +27,24 @@ const PROGRESS_INTERVAL: Duration = Duration::from_millis(200);
pub struct SshCliBackend {
connector: SshConnector,
sftp: SharedSftpTransportProvider,
secrets: SharedSecretStore,
}
impl SshCliBackend {
/// The same connector as the terminals, so host trust goes through one
/// file and lock, and the same SFTP provider as the SFTP tab.
pub fn new(connector: SshConnector, sftp: SharedSftpTransportProvider) -> Self {
Self { connector, sftp }
/// file and lock, and the same SFTP provider as the SFTP tab. `secrets`
/// is the host store's, which holds the passwords of the hosts not
/// saved too.
pub fn new(
connector: SshConnector,
sftp: SharedSftpTransportProvider,
secrets: SharedSecretStore,
) -> Self {
Self {
connector,
sftp,
secrets,
}
}
/// Run one transfer on a connection of its own, answering every
@@ -60,7 +73,7 @@ impl SshCliBackend {
Ok(SftpEvent::Prompt(prompt)) => run.refuse(&commands, &prompt),
Ok(SftpEvent::Disconnected(reason)) => return Err(run.failure(reason)),
Ok(_) => {}
Err(_) => return Err(run.failure("SFTP 连接意外结束".into())),
Err(_) => return Err(run.failure(t!("cli.transfer.sftp_ended").into())),
}
}
let _ = commands.send_blocking(command);
@@ -128,19 +141,31 @@ impl CliBackend for SshCliBackend {
Ok(ExecExit::Signal(signal)) => {
let _ = output(
ExecStream::Stderr,
format!("shellrs: 远程命令被信号 {signal} 终止\n").as_bytes(),
format!("shellrs: {}\n", t!("cli.exec.signal", signal = signal)).as_bytes(),
);
Ok(signal_exit_code(&signal))
}
Err(error) => Err(CliError::new(
match error.kind {
ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown,
ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged,
ExecErrorKind::MissingCredential => ErrorCode::MissingCredential,
ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed,
},
error.message,
)),
Err(error) => {
// A bastion host's link logs in once; its terminal is open.
let message = if target.login().shell_only
&& matches!(
error.kind,
ExecErrorKind::MissingCredential | ExecErrorKind::Connect
) {
t!("cli.exec.try_terminal", message = error.message).to_string()
} else {
error.message
};
Err(CliError::new(
match error.kind {
ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown,
ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged,
ExecErrorKind::MissingCredential => ErrorCode::MissingCredential,
ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed,
},
message,
))
}
}
}
@@ -166,6 +191,28 @@ impl CliBackend for SshCliBackend {
.map_err(|error| CliError::new(ErrorCode::BadRequest, error.to_string()))?;
self.transfer(target, SftpCommand::Download(request), progress)
}
fn sync(
&self,
target: &CliTarget,
source: &Path,
destination: &str,
delete: bool,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
if !source.is_dir() {
return Err(CliError::new(
ErrorCode::BadRequest,
t!("cli.transfer.no_local_folder", path = source.display()),
));
}
let request = UploadRequest::sync(source.to_path_buf(), remote_path(destination)?, delete);
self.transfer(target, SftpCommand::Upload(request), progress)
}
fn is_saved(&self, secret: &SecretRef) -> bool {
self.secrets.get(secret).is_ok_and(|value| value.is_some())
}
}
/// What one transfer ran into along the way.
@@ -191,15 +238,15 @@ impl Transfer {
let refused = match prompt.kind() {
ConnectionPromptKind::UnknownHost(_) => CliError::new(
ErrorCode::HostKeyUnknown,
"尚未信任这台主机的密钥:请先在 ShellRS 中连接一次这台主机",
t!("cli.transfer.host_key_unknown"),
),
ConnectionPromptKind::HostKeyChanged(_) => CliError::new(
ErrorCode::HostKeyChanged,
"主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份",
t!("cli.transfer.host_key_changed"),
),
ConnectionPromptKind::Authentication(_) => CliError::new(
ErrorCode::MissingCredential,
"这台主机没有保存可用的密码或口令:请先在 ShellRS 中连接一次这台主机并保存密码",
t!("cli.transfer.missing_credential"),
),
};
self.refused.get_or_insert(refused);
@@ -222,6 +269,7 @@ impl Transfer {
bytes: state.completed_bytes(),
skipped: state.skipped() as u64,
failed: state.failed() as u64,
deleted: state.deleted() as u64,
failures: std::mem::take(&mut self.failures),
}),
_ => Err(self.refused.take().unwrap_or_else(|| {
@@ -229,7 +277,7 @@ impl Transfer {
ErrorCode::TransferFailed,
self.notice
.take()
.unwrap_or_else(|| "传输未完成".to_string()),
.unwrap_or_else(|| t!("cli.transfer.unfinished").to_string()),
)
})),
}
@@ -252,7 +300,10 @@ fn remote_path(path: &str) -> Result<RemotePath, CliError> {
let path = path.trim();
let path = match path {
"" => {
return Err(CliError::new(ErrorCode::BadRequest, "远程路径不能为空"));
return Err(CliError::new(
ErrorCode::BadRequest,
t!("cli.transfer.empty_remote_path"),
));
}
"~" => ".".to_string(),
_ if path.starts_with("~/") => format!(".{}", &path[1..]),
+643 -76
View File
@@ -8,13 +8,18 @@ use std::{
time::Duration,
};
use gpui_kit::SharedString;
use unicode_width::UnicodeWidthStr as _;
use super::link::OpenLink;
use super::protocol::{
Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request, TransferCounters,
TransferSummary, parse_json, read_frame, write_json,
AuthChoice, CredentialDeleted, CredentialDetails, CredentialKindChoice, Envelope, ErrorCode,
FrameKind, HostDeleted, HostDetails, HostInfo, PROTOCOL_VERSION, ProxyChoice, Reply, Request,
RouteDetails, TerminalContext, TransferCounters, TransferSummary, parse_json, read_frame,
write_json,
};
use crate::host::HostOs;
use crate::i18n::{t, tn};
/// Exit code when the command could not do what was asked at all, as ssh
/// uses it.
@@ -28,31 +33,92 @@ pub struct Console<'a> {
pub stderr: &'a mut dyn Write,
/// JSON instead of text, asked for or implied by a pipe.
pub json: bool,
/// `exec --json`: the output gathered into one ASCII JSON object, and
/// errors in JSON as well.
pub exec_json: bool,
/// Progress lines only make sense on a terminal.
pub stderr_is_terminal: bool,
}
impl Console<'_> {
pub fn error(&mut self, code: ErrorCode, message: &str) -> i32 {
let _ = writeln!(self.stderr, "shellrs: [{}] {message}", code.as_str());
if self.exec_json {
let error = serde_json::json!({
"error": { "code": code.as_str(), "message": message }
});
let _ = writeln!(self.stdout, "{}", ascii_json(&error));
} else {
let _ = writeln!(self.stderr, "shellrs: [{}] {message}", code.as_str());
}
FAILURE_EXIT
}
}
/// What `exec --json` prints once the command ends. Output that is not
/// UTF-8 has U+FFFD in place of what is not.
#[derive(serde::Serialize)]
struct ExecResult<'a> {
exit_code: i32,
stdout: std::borrow::Cow<'a, str>,
stderr: std::borrow::Cow<'a, str>,
/// Where an `exec --terminal` command ran.
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<TerminalContext>,
}
/// `value` as JSON in ASCII alone: every other character escaped, which
/// only ever happens inside strings. PowerShell and the Windows console
/// read it right whatever code page they assume.
pub(super) fn ascii_json(value: &impl serde::Serialize) -> String {
let json = serde_json::to_string(value).unwrap_or_default();
let mut ascii = String::with_capacity(json.len());
for character in json.chars() {
if character.is_ascii() {
ascii.push(character);
} else {
for unit in character.encode_utf16(&mut [0; 2]) {
ascii.push_str(&format!("\\u{unit:04x}"));
}
}
}
ascii
}
/// What the request did, for the words its answer is printed with.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Asked {
Show,
Create,
Update,
Sync,
Other,
}
impl Asked {
fn of(request: &Request) -> Self {
match request {
Request::ShowHost { .. } | Request::ShowCredential { .. } => Asked::Show,
Request::CreateHost { .. } | Request::CreateCredential { .. } => Asked::Create,
Request::UpdateHost { .. } | Request::UpdateCredential { .. } => Asked::Update,
Request::Sync { .. } => Asked::Sync,
_ => Asked::Other,
}
}
}
/// Send `request` to the app listening at `endpoint` and print the answer;
/// the exit code.
pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 {
let result = connect(endpoint).and_then(|stream| talk(&stream, request, console));
match result {
Ok(code) => code,
Err(Failure::NotRunning) => console.error(
ErrorCode::NotRunning,
"ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」",
),
Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, message),
Err(Failure::NotRunning) => {
console.error(ErrorCode::NotRunning, &t!("cli.client.not_running"))
}
Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, &message),
Err(Failure::Broken(error)) => console.error(
ErrorCode::ConnectFailed,
&format!("与 ShellRS 的连接意外中断:{error}"),
&t!("cli.client.broken", error = error),
),
}
}
@@ -61,14 +127,14 @@ pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 {
/// forward. One too busy to answer is running all the same.
const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(2);
/// Ask the ShellRS listening at `endpoint` to bring its window forward;
/// whether there is one. `false` means nothing is running there and the
/// caller is free to start.
/// Ask the ShellRS listening at `endpoint` to bring its window forward,
/// and to open `open` there; whether there is one. `false` means nothing is
/// running there and the caller is free to start.
///
/// Whatever takes the connection counts as running, whether or not it
/// understands the request: an older ShellRS answers with an error, and a
/// second copy on the same data would be worse than a window left behind.
pub fn activate_running_app(endpoint: &Path) -> bool {
pub fn activate_running_app(endpoint: &Path, open: Option<OpenLink>) -> bool {
let Ok(stream) = connect(endpoint) else {
return false;
};
@@ -78,7 +144,7 @@ pub fn activate_running_app(endpoint: &Path) -> bool {
let asked = std::thread::Builder::new()
.name("shellrs-activate".into())
.spawn(move || {
let _ = done.send(ask_to_come_forward(&stream));
let _ = done.send(ask_to_come_forward(&stream, open));
});
if asked.is_ok() {
let _ = heard.recv_timeout(ACTIVATION_TIMEOUT);
@@ -88,7 +154,7 @@ pub fn activate_running_app(endpoint: &Path) -> bool {
/// Send the request and wait for the answer, whatever it says: by then the
/// app has the request.
fn ask_to_come_forward<S>(stream: S) -> io::Result<()>
fn ask_to_come_forward<S>(stream: S, open: Option<OpenLink>) -> io::Result<()>
where
S: Copy + io::Read + Write,
{
@@ -97,7 +163,7 @@ where
&mut writer,
&Envelope {
version: PROTOCOL_VERSION,
request: Request::Activate,
request: Request::Activate { open },
},
)?;
read_frame(&mut BufReader::new(stream)).map(|_| ())
@@ -106,7 +172,7 @@ where
enum Failure {
NotRunning,
/// Not allowed to talk to the app, or not willing to.
Refused(&'static str),
Refused(SharedString),
Broken(io::Error),
}
@@ -116,19 +182,20 @@ impl From<io::Error> for Failure {
}
}
/// What a sandbox that forbids local connections looks like.
const NO_PERMISSION: &str =
"没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信";
#[cfg(unix)]
fn connect(socket: &Path) -> Result<std::os::unix::net::UnixStream, Failure> {
std::os::unix::net::UnixStream::connect(socket).map_err(|error| match error.kind() {
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused => Failure::NotRunning,
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()),
_ => error.into(),
})
}
/// What a sandbox that forbids local connections looks like.
fn no_permission() -> SharedString {
t!("cli.client.no_permission")
}
/// Open the app's pipe, and make sure it is the app's before saying
/// anything: the name is the machine's, so someone else could hold it.
#[cfg(windows)]
@@ -137,20 +204,18 @@ fn connect(pipe: &Path) -> Result<std::fs::File, Failure> {
let pipe = pipe_windows::open(pipe).map_err(|error| match error.kind() {
io::ErrorKind::NotFound => Failure::NotRunning,
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()),
_ => error.into(),
})?;
if !pipe_windows::owned_by_current_user(&pipe)? {
return Err(Failure::Refused(
"外部 CLI 的管道不属于当前用户,已拒绝连接",
));
return Err(Failure::Refused(t!("cli.client.foreign_pipe")));
}
Ok(pipe)
}
#[cfg(not(any(unix, windows)))]
fn connect(_: &Path) -> Result<std::fs::File, Failure> {
Err(Failure::Refused("此系统暂不支持外部 CLI"))
Err(Failure::Refused(t!("cli.unsupported")))
}
/// Send the request and print the replies until the last one.
@@ -158,6 +223,7 @@ fn talk<S>(stream: S, request: Request, console: &mut Console) -> Result<i32, Fa
where
S: Copy + io::Read + Write,
{
let asked = Asked::of(&request);
let mut writer = stream;
write_json(
&mut writer,
@@ -169,11 +235,16 @@ where
let mut reader = BufReader::new(stream);
// The progress line on screen, to be blanked before anything else.
let mut progress = ProgressLine::default();
// What `exec --json` holds until the command ends.
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let mut context = None;
loop {
let Some((kind, payload)) = read_frame(&mut reader)? else {
return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into());
};
match kind {
FrameKind::Stdout if console.exec_json => stdout.extend_from_slice(&payload),
FrameKind::Stderr if console.exec_json => stderr.extend_from_slice(&payload),
FrameKind::Stdout => {
console.stdout.write_all(&payload)?;
console.stdout.flush()?;
@@ -197,9 +268,53 @@ where
print_hosts(&hosts, console)?;
return Ok(0);
}
Reply::TransferDone(summary) => return Ok(print_summary(&summary, console)?),
Reply::TransferDone(summary) => {
return Ok(print_summary(&summary, asked == Asked::Sync, console)?);
}
Reply::Host(host) => {
print_host(&host, asked, console)?;
return Ok(0);
}
Reply::HostDeleted(deleted) => {
print_host_deleted(&deleted, console)?;
return Ok(0);
}
Reply::Credentials { credentials } => {
print_credentials(&credentials, console)?;
return Ok(0);
}
Reply::Credential(credential) => {
print_credential(&credential, asked, console)?;
return Ok(0);
}
Reply::CredentialDeleted(deleted) => {
print_credential_deleted(&deleted, console)?;
return Ok(0);
}
// Only `--json` prints where the command ran.
Reply::Context(found) => context = Some(found),
Reply::Exit { code } if console.exec_json => {
let result = ExecResult {
exit_code: code,
stdout: String::from_utf8_lossy(&stdout),
stderr: String::from_utf8_lossy(&stderr),
context: context.take(),
};
writeln!(console.stdout, "{}", ascii_json(&result))?;
return Ok(code);
}
Reply::Exit { code } => return Ok(code),
Reply::Activated => return Ok(0),
Reply::Error {
code: ErrorCode::BadRequest,
message,
} if message.contains("unknown variant") => {
// What an app from before the request was added
// says: serde names what it did not know.
return Ok(
console.error(ErrorCode::VersionMismatch, &t!("cli.client.older_app"))
);
}
Reply::Error { code, message } => return Ok(console.error(code, &message)),
}
}
@@ -238,41 +353,28 @@ impl ProgressLine {
}
}
fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
if console.json {
serde_json::to_writer_pretty(&mut *console.stdout, hosts).map_err(io::Error::other)?;
return writeln!(console.stdout);
}
if hosts.is_empty() {
return writeln!(console.stderr, "没有匹配的主机");
}
let rows: Vec<[String; 5]> = hosts
.iter()
.map(|host| {
[
host.id.clone(),
host.name.clone(),
host.group.clone().unwrap_or_default(),
host.address(),
host.os
.as_deref()
.and_then(HostOs::from_stored)
.map(|os| os.label().to_string())
.unwrap_or_default(),
]
})
.collect();
let header = ["ID", "名称", "分组", "地址", "系统"].map(String::from);
let widths: Vec<usize> = (0..5)
fn print_json(value: &impl serde::Serialize, console: &mut Console) -> io::Result<()> {
serde_json::to_writer_pretty(&mut *console.stdout, value).map_err(io::Error::other)?;
writeln!(console.stdout)
}
/// Rows lined up in columns, wide characters counted as two.
fn print_table<const N: usize>(
header: [SharedString; N],
rows: &[[String; N]],
console: &mut Console,
) -> io::Result<()> {
let header = header.map(String::from);
let widths: Vec<usize> = (0..N)
.map(|column| {
std::iter::once(&header)
.chain(&rows)
.chain(rows)
.map(|row| row[column].width())
.max()
.unwrap_or(0)
})
.collect();
for row in std::iter::once(&header).chain(&rows) {
for row in std::iter::once(&header).chain(rows) {
let line: Vec<String> = row
.iter()
.zip(&widths)
@@ -283,23 +385,347 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
Ok(())
}
fn print_summary(summary: &TransferSummary, console: &mut Console) -> io::Result<i32> {
/// Labelled lines, the labels lined up.
fn print_fields(fields: &[(SharedString, String)], console: &mut Console) -> io::Result<()> {
let width = fields
.iter()
.map(|(label, _)| label.width())
.max()
.unwrap_or(0);
for (label, value) in fields {
let padding = " ".repeat(width - label.width());
writeln!(console.stdout, "{label}{padding} {value}")?;
}
Ok(())
}
/// `label` saying whether its password is saved, when that is known.
fn with_saved(label: SharedString, saved: Option<bool>) -> String {
let text = match saved {
Some(true) => t!("cli.show.saved", label = label),
Some(false) => t!("cli.show.not_saved", label = label),
None => label,
};
text.to_string()
}
fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Result<()> {
if console.json {
serde_json::to_writer_pretty(&mut *console.stdout, summary).map_err(io::Error::other)?;
writeln!(console.stdout)?;
return print_json(host, console);
}
match asked {
Asked::Create => {
return writeln!(
console.stdout,
"{}",
t!("cli.host.created", name = host.name, id = host.id)
);
}
Asked::Update => {
return writeln!(
console.stdout,
"{}",
t!("cli.host.saved", name = host.name, id = host.id)
);
}
_ => {}
}
let auth = match host.auth {
AuthChoice::Password => with_saved(t!("cli.auth.password"), host.password_saved),
AuthChoice::Credential => t!(
"cli.auth.credential",
id = host.credential.as_deref().unwrap_or_default()
)
.to_string(),
AuthChoice::NoPassword => t!("cli.auth.no_password").to_string(),
};
let route = match &host.route {
RouteDetails::Direct => t!("cli.route.direct").to_string(),
RouteDetails::Jump { hosts } => {
let deleted = t!("cli.route.deleted_host");
let hosts = hosts
.iter()
.map(|hop| hop.as_deref().unwrap_or(deleted.as_str()))
.collect::<Vec<_>>()
.join(" → ");
t!("cli.route.jump", hosts = hosts).to_string()
}
RouteDetails::Proxy {
kind,
host,
port,
user,
password_saved,
} => {
let user = user
.as_deref()
.map(|user| format!("{user}@"))
.unwrap_or_default();
let address = format!("{user}{host}:{port}");
let proxy = match kind {
ProxyChoice::Http => t!("cli.route.http", address = address),
ProxyChoice::Socks5 => t!("cli.route.socks5", address = address),
};
let proxy = match password_saved {
Some(true) => t!("cli.route.password_saved", proxy = proxy),
Some(false) => t!("cli.route.password_not_saved", proxy = proxy),
None => proxy,
};
proxy.to_string()
}
};
let mut fields = vec![
(t!("cli.field.id"), host.id.clone()),
(t!("cli.field.name"), host.name.clone()),
(
t!("cli.field.group"),
if host.temporary {
t!("cli.group.not_saved").to_string()
} else {
host.group.clone().unwrap_or_default()
},
),
(
t!("cli.field.address"),
format!("{}@{}:{}", host.user, host.host, host.port),
),
(t!("cli.field.auth"), auth),
(t!("cli.field.route"), route),
];
if let Some(os) = host.os.as_deref().and_then(HostOs::from_stored) {
fields.push((t!("cli.field.os"), os.label().to_string()));
}
if !host.notes.is_empty() {
fields.push((t!("cli.field.notes"), host.notes.clone()));
}
print_fields(&fields, console)
}
fn print_host_deleted(deleted: &HostDeleted, console: &mut Console) -> io::Result<()> {
if console.json {
return print_json(deleted, console);
}
let name = &deleted.host.name;
let mut line = if deleted.forwards > 0 {
tn!(
"cli.host.deleted_with_forwards",
deleted.forwards,
name = name
)
} else {
writeln!(
console.stdout,
"已传输 {} 个文件,共 {}{}",
t!("cli.host.deleted", name = name)
};
if deleted.jump_users > 0 {
line = tn!(
"cli.host.deleted_jump_users",
deleted.jump_users,
deleted = line
);
}
writeln!(console.stdout, "{line}")
}
fn kind_label(kind: CredentialKindChoice) -> SharedString {
match kind {
CredentialKindChoice::Password => t!("cli.kind.password"),
CredentialKindChoice::Key => t!("cli.kind.key"),
CredentialKindChoice::Agent => t!("cli.kind.agent"),
}
}
fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) -> io::Result<()> {
if console.json {
return print_json(&credentials, console);
}
if credentials.is_empty() {
return writeln!(console.stderr, "{}", t!("cli.credentials.none"));
}
let rows: Vec<[String; 5]> = credentials
.iter()
.map(|credential| {
[
credential.id.clone(),
credential.name.clone(),
kind_label(credential.kind).to_string(),
credential.user.clone(),
credential.hosts.len().to_string(),
]
})
.collect();
print_table(
[
t!("cli.column.id"),
t!("cli.column.name"),
t!("cli.column.kind"),
t!("cli.column.user"),
t!("cli.column.hosts"),
],
&rows,
console,
)
}
fn print_credential(
credential: &CredentialDetails,
asked: Asked,
console: &mut Console,
) -> io::Result<()> {
if console.json {
return print_json(credential, console);
}
match asked {
Asked::Create => {
return writeln!(
console.stdout,
"{}",
t!(
"cli.credential.created",
name = credential.name,
id = credential.id
)
);
}
Asked::Update => {
return writeln!(
console.stdout,
"{}",
t!(
"cli.credential.saved",
name = credential.name,
id = credential.id
)
);
}
_ => {}
}
let mut fields = vec![
(t!("cli.field.id"), credential.id.clone()),
(t!("cli.field.name"), credential.name.clone()),
(
t!("cli.field.kind"),
with_saved(kind_label(credential.kind), credential.password_saved),
),
(t!("cli.field.user"), credential.user.clone()),
];
if credential.kind == CredentialKindChoice::Key {
let key = match (&credential.key_path, credential.kept) {
(Some(path), _) => path.clone(),
(None, _) => t!("cli.credential.kept_key").to_string(),
};
fields.push((t!("cli.field.private_key"), key));
if let Some(saved) = credential.passphrase_saved {
let saved = if saved {
t!("cli.credential.passphrase_saved")
} else {
t!("cli.credential.passphrase_not_saved")
};
fields.push((t!("cli.field.passphrase"), saved.to_string()));
}
}
fields.push((t!("cli.field.hosts"), credential.hosts.join(", ")));
print_fields(&fields, console)
}
fn print_credential_deleted(deleted: &CredentialDeleted, console: &mut Console) -> io::Result<()> {
if console.json {
return print_json(deleted, console);
}
let mut line = t!("cli.credential.deleted", name = deleted.credential.name);
if !deleted.released.is_empty() {
let names: Vec<&str> = deleted
.released
.iter()
.map(|host| host.name.as_str())
.collect();
let separator = t!("common.list.separator");
line = tn!(
"cli.credential.deleted_released",
names.len(),
deleted = line,
hosts = names.join(separator.as_str())
);
}
writeln!(console.stdout, "{line}")
}
fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
if console.json {
return print_json(&hosts, console);
}
if hosts.is_empty() {
return writeln!(console.stderr, "{}", t!("cli.hosts.none"));
}
let rows: Vec<[String; 5]> = hosts
.iter()
.map(|host| {
[
host.id.clone(),
host.name.clone(),
// 临时连接 and 外部连接 alike: an agent needs no telling
// them apart, only that the host is not saved.
if host.temporary {
t!("cli.group.not_saved").to_string()
} else {
host.group.clone().unwrap_or_default()
},
host.address(),
host.os
.as_deref()
.and_then(HostOs::from_stored)
.map(|os| os.label().to_string())
.unwrap_or_default(),
]
})
.collect();
print_table(
[
t!("cli.column.id"),
t!("cli.column.name"),
t!("cli.column.group"),
t!("cli.column.address"),
t!("cli.column.os"),
],
&rows,
console,
)
}
/// A sync's skipped items are the ones that had not changed.
fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) -> io::Result<i32> {
if console.json {
print_json(summary, console)?;
} else {
let mut line = tn!(
"cli.summary.transferred",
summary.files,
format_bytes(summary.bytes),
match (summary.skipped, summary.failed) {
(0, 0) => String::new(),
(skipped, 0) => format!(",跳过 {skipped} 个"),
(0, failed) => format!(",失败 {failed} 个"),
(skipped, failed) => format!(",跳过 {skipped} 个,失败 {failed} 个"),
}
)?;
size = format_bytes(summary.bytes)
);
if summary.skipped > 0 {
line = if sync {
t!(
"cli.summary.unchanged",
summary = line,
count = summary.skipped
)
} else {
t!(
"cli.summary.skipped",
summary = line,
count = summary.skipped
)
};
}
if summary.deleted > 0 {
line = t!(
"cli.summary.deleted",
summary = line,
count = summary.deleted
);
}
if summary.failed > 0 {
line = t!("cli.summary.failed", summary = line, count = summary.failed);
}
writeln!(console.stdout, "{line}")?;
for failure in &summary.failures {
writeln!(console.stderr, "{failure}")?;
}
@@ -308,13 +734,14 @@ fn print_summary(summary: &TransferSummary, console: &mut Console) -> io::Result
}
fn progress_line(counters: TransferCounters) -> String {
format!(
"{}/{} 个文件,{} / {}",
counters.files,
counters.total_files,
format_bytes(counters.bytes),
format_bytes(counters.total_bytes)
t!(
"cli.progress",
files = counters.files,
total_files = counters.total_files,
bytes = format_bytes(counters.bytes),
total_bytes = format_bytes(counters.total_bytes)
)
.to_string()
}
fn format_bytes(bytes: u64) -> String {
@@ -330,3 +757,143 @@ fn format_bytes(bytes: u64) -> String {
}
format!("{value:.1} {}", UNITS[unit])
}
#[cfg(test)]
mod tests {
use super::*;
/// What `print` writes to stdout, as on a terminal.
fn printed(print: impl FnOnce(&mut Console) -> io::Result<()>) -> String {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
print(&mut Console {
stdout: &mut stdout,
stderr: &mut stderr,
json: false,
exec_json: false,
stderr_is_terminal: false,
})
.unwrap();
String::from_utf8(stdout).unwrap()
}
fn host(name: &str) -> HostDetails {
HostDetails {
id: "Jwg5rHvXCxw89paM".into(),
name: name.into(),
group: None,
host: "10.0.0.9".into(),
port: 22,
user: "root".into(),
auth: AuthChoice::Password,
credential: None,
password_saved: None,
route: RouteDetails::Direct,
notes: String::new(),
os: None,
temporary: false,
}
}
fn deleted_host(forwards: u64, jump_users: u64) -> String {
let deleted = HostDeleted {
host: host("web"),
forwards,
jump_users,
};
printed(|console| print_host_deleted(&deleted, console))
}
fn deleted_credential(released: &[&str]) -> String {
let info = |name: &&str| HostInfo {
id: String::new(),
name: name.to_string(),
group: None,
user: "root".into(),
host: "10.0.0.9".into(),
port: 22,
os: None,
temporary: false,
};
let deleted = CredentialDeleted {
credential: CredentialDetails {
id: String::new(),
name: "deploy".into(),
kind: CredentialKindChoice::Password,
user: "deploy".into(),
key_path: None,
kept: false,
hosts: Vec::new(),
password_saved: None,
passphrase_saved: None,
},
released: released.iter().map(info).collect(),
};
printed(|console| print_credential_deleted(&deleted, console))
}
fn summary(files: u64, skipped: u64, sync: bool) -> String {
let summary = TransferSummary {
files,
bytes: 2048,
skipped,
failed: 1,
deleted: 0,
failures: Vec::new(),
};
printed(|console| print_summary(&summary, sync, console).map(drop))
}
#[test]
fn what_was_done_reads_as_one_line_in_either_language() {
crate::i18n::isolate_thread();
crate::i18n::set_locale("zh-CN");
assert_eq!(deleted_host(0, 0), "已删除主机「web」\n");
assert_eq!(
deleted_host(2, 3),
"已删除主机「web」,连同它的 2 条端口转发规则;\
3 台主机经它跳转,现在那一跳是「已删除的主机」\n"
);
assert_eq!(
deleted_credential(&["web", "db"]),
"已删除凭据「deploy」;2 台主机改为自己登录:web、db\n"
);
assert_eq!(
summary(2, 40, true),
"已传输 2 个文件,共 2.0 KB,未变 40 个,失败 1 个\n"
);
crate::i18n::set_locale("en");
assert_eq!(deleted_host(0, 0), "Deleted host \"web\"\n");
assert_eq!(
deleted_host(1, 1),
"Deleted host \"web\" and its port forwarding rule; \
1 host jumped through it and now has a deleted jump host\n"
);
assert_eq!(
deleted_credential(&["web", "db"]),
"Deleted credential \"deploy\"; 2 hosts now log in on their own: web, db\n"
);
assert_eq!(
summary(1, 4, false),
"Transferred 1 file (2.0 KB), 4 skipped, 1 failed\n"
);
}
#[test]
fn an_english_show_lines_up_its_labels() {
crate::i18n::isolate_thread();
crate::i18n::set_locale("en");
let mut shown = host("web");
shown.password_saved = Some(true);
let shown = printed(|console| print_host(&shown, Asked::Show, console));
assert!(
shown.contains("\nAuthentication Password (saved)\n"),
"{shown}"
);
assert!(shown.contains("\nConnection Direct\n"), "{shown}");
assert!(
shown.starts_with("ID Jwg5rHvXCxw89paM\n"),
"{shown}"
);
}
}
+30 -11
View File
@@ -7,6 +7,10 @@
use std::path::Path;
use std::{fs, io, path::PathBuf};
use gpui_kit::SharedString;
use crate::i18n::t;
/// The skill that teaches an agent to use `shellrs`.
pub const SKILL: &str = include_str!("SKILL.md");
@@ -23,29 +27,32 @@ pub enum AgentKind {
Codex,
ClaudeCode,
OpenCode,
WorkBuddy,
}
impl AgentKind {
/// In the order the settings page lists them.
pub const ALL: [AgentKind; 4] = [
pub const ALL: [AgentKind; 5] = [
AgentKind::Generic,
AgentKind::Codex,
AgentKind::ClaudeCode,
AgentKind::OpenCode,
AgentKind::WorkBuddy,
];
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
AgentKind::Generic => "通用 Agent",
AgentKind::Codex => "Codex",
AgentKind::ClaudeCode => "Claude Code",
AgentKind::OpenCode => "OpenCode",
AgentKind::Generic => t!("cli.agent.generic"),
AgentKind::Codex => "Codex".into(),
AgentKind::ClaudeCode => "Claude Code".into(),
AgentKind::OpenCode => "OpenCode".into(),
AgentKind::WorkBuddy => "WorkBuddy".into(),
}
}
pub fn description(self) -> Option<&'static str> {
pub fn description(self) -> Option<SharedString> {
match self {
AgentKind::Generic => Some("安装到 Agent Skills 标准目录,供支持该目录的 Agent 使用。"),
AgentKind::Generic => Some(t!("cli.agent.generic_description")),
_ => None,
}
}
@@ -57,6 +64,7 @@ impl AgentKind {
AgentKind::Codex => "codex",
AgentKind::ClaudeCode => "claude",
AgentKind::OpenCode => "opencode",
AgentKind::WorkBuddy => "workbuddy",
}
}
@@ -68,6 +76,7 @@ impl AgentKind {
AgentKind::Codex => &[".codex", "skills"],
AgentKind::ClaudeCode => &[".claude", "skills"],
AgentKind::OpenCode => &[".config", "opencode", "skills"],
AgentKind::WorkBuddy => &[".workbuddy", "skills"],
}
}
}
@@ -272,7 +281,7 @@ fn link_binary(paths: &IntegrationPaths) -> io::Result<()> {
#[cfg(not(unix))]
Err(io::Error::new(
io::ErrorKind::Unsupported,
"此系统暂不支持安装 shellrs 命令",
t!("cli.install.unsupported").to_string(),
))
}
@@ -295,7 +304,7 @@ fn remove_link(paths: &IntegrationPaths) -> io::Result<()> {
fn occupied(link: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} 已被其他程序占用,ShellRS 不会改动它", link.display()),
t!("cli.install.occupied", path = link.display()).to_string(),
)
}
@@ -318,7 +327,10 @@ fn run_as_administrator(command: &str) -> io::Result<()> {
let message = String::from_utf8_lossy(&output.stderr);
// AppleScript's "User canceled." is error -128.
if message.contains("-128") {
return Err(io::Error::new(io::ErrorKind::Interrupted, "已取消"));
return Err(io::Error::new(
io::ErrorKind::Interrupted,
t!("cli.install.cancelled").to_string(),
));
}
Err(io::Error::other(message.trim().to_string()))
}
@@ -441,6 +453,13 @@ mod tests {
.join("shellrs")
.join("SKILL.md")
);
assert_eq!(
paths.skill_file(AgentKind::WorkBuddy),
home.join(".workbuddy")
.join("skills")
.join("shellrs")
.join("SKILL.md")
);
}
#[test]
+2 -1
View File
@@ -12,6 +12,7 @@ use std::{
};
use super::install::{BinaryStatus, IntegrationPaths, UserPath};
use crate::i18n::t;
pub(super) fn binary_status(paths: &IntegrationPaths) -> BinaryStatus {
let source = fs::metadata(&paths.exe).ok();
@@ -56,7 +57,7 @@ pub(super) fn install_binary(paths: &IntegrationPaths) -> io::Result<()> {
if !paths.exe.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("找不到 {}", paths.exe.display()),
t!("cli.install.missing", path = paths.exe.display()).to_string(),
));
}
let folder = folder(paths);
+280
View File
@@ -0,0 +1,280 @@
//! The links ShellRS is opened with, the way bastion hosts open Xshell:
//! `ShellRS ssh://user@host:port`, or `ShellRS -url ssh://… -newtab 名称`;
//! and WinSCP: `ShellRS sftp://user@host:port`, or with
//! `/sessionname=名称` in front.
//!
//! Only told apart from the `shellrs` command's arguments here; the link
//! itself is read by the app (`host::SshLink`), which says what is wrong
//! with it in a notification. Opened again, ShellRS hands the link to the
//! one already running, inside [`super::Request::Activate`].
use std::ffi::OsString;
use std::fmt;
use serde::{Deserialize, Serialize};
/// A link to connect to, as the caller wrote it.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenLink {
/// May hold a password. Empty when the arguments named no link, so the
/// app can say so: a second ShellRS on Windows has no console to.
pub url: String,
/// What to call the tab: Xshell's `-newtab`, WinSCP's `/sessionname`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab: Option<String>,
}
impl fmt::Debug for OpenLink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenLink")
.field("url", &without_password(&self.url))
.field("tab", &self.tab)
.finish()
}
}
/// `url` with its password replaced, for logs. Never the text itself when
/// it cannot be read: where the password ends is unknown then.
fn without_password(url: &str) -> String {
if url.is_empty() {
return String::new();
}
match url::Url::parse(url) {
Ok(mut parsed) => {
if parsed.password().is_some() {
let _ = parsed.set_password(Some("***"));
}
parsed.to_string()
}
Err(_) => "<unreadable link>".into(),
}
}
/// Xshell's options. `-newwin` asks for a window of its own, which means
/// nothing to ShellRS's one window.
const URL_OPTION: &str = "-url";
const TAB_OPTION: &str = "-newtab";
const WINDOW_OPTION: &str = "-newwin";
/// WinSCP's switches, written `/name[=value]` or `-name[=value]`:
/// `/sessionname=名称` names the tab, the others mean nothing to ShellRS
/// (`/newinstance` asks for a window of its own, `/ini=nul` not to read
/// WinSCP's settings, `/privatekey=` and `/hostkey=` are ShellRS's to
/// know).
const WINSCP_SWITCHES: &[&str] = &[
SESSION_NAME,
"newinstance",
"privatekey",
"hostkey",
"passphrase",
"rawsettings",
"ini",
"log",
"timeout",
];
const SESSION_NAME: &str = "sessionname";
/// The link `args` (without the program) open, or `None` when they are the
/// `shellrs` command's. Only the first argument decides, so `shellrs exec
/// web "curl http://x"` stays a command: a link launch starts with one of
/// Xshell's options, one of WinSCP's switches or the link itself.
pub fn link_arguments(args: &[OsString]) -> Option<OpenLink> {
let args: Vec<String> = args
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
let first = args.first()?;
if !is_option(first) && winscp_switch(first).is_none() && !looks_like_link(first) {
return None;
}
let mut given = None;
let mut bare = None;
let mut tab = None;
let mut args = args.into_iter().peekable();
while let Some(arg) = args.next() {
let option = arg.to_lowercase();
if option == URL_OPTION {
let url = args.next();
given = given.or(url);
} else if option == TAB_OPTION {
// Its name, unless the next argument is something else.
if args
.peek()
.is_some_and(|next| !next.starts_with('-') && !looks_like_link(next))
{
tab = tab_name(&args.next().unwrap_or_default()).or(tab);
}
} else if let Some((switch, value)) = winscp_switch(&arg) {
if switch == SESSION_NAME {
tab = value.and_then(tab_name).or(tab);
}
} else if option == WINDOW_OPTION || arg.starts_with('-') {
// Ignored, like any option ShellRS does not know.
} else if bare.is_none() && looks_like_link(&arg) {
bare = Some(arg);
}
}
Some(OpenLink {
url: given.or(bare).unwrap_or_default(),
tab,
})
}
fn is_option(arg: &str) -> bool {
[URL_OPTION, TAB_OPTION, WINDOW_OPTION].contains(&arg.to_lowercase().as_str())
}
/// The name and value of one of WinSCP's switches, the name in lower case.
fn winscp_switch(arg: &str) -> Option<(String, Option<&str>)> {
let switch = arg.strip_prefix(['/', '-'])?;
let (name, value) = match switch.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (switch, None),
};
let name = name.to_lowercase();
WINSCP_SWITCHES
.contains(&name.as_str())
.then_some((name, value))
}
/// A tab's name as given, without the spaces or quotes around it; `None`
/// when that leaves nothing.
fn tab_name(given: &str) -> Option<String> {
let name = given.trim().trim_matches('"').trim();
(!name.is_empty()).then(|| name.to_string())
}
/// `scheme://…`, whatever the scheme: the app says which ones it opens.
fn looks_like_link(arg: &str) -> bool {
let Some((scheme, _)) = arg.split_once("://") else {
return false;
};
let mut chars = scheme.chars();
chars
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
#[cfg(test)]
mod tests {
use super::*;
fn link(args: &[&str]) -> Option<OpenLink> {
let args: Vec<OsString> = args.iter().map(OsString::from).collect();
link_arguments(&args)
}
fn open(url: &str, tab: Option<&str>) -> Option<OpenLink> {
Some(OpenLink {
url: url.into(),
tab: tab.map(str::to_string),
})
}
#[test]
fn a_link_on_its_own_opens() {
let url = "ssh://b478e26f-811b-4a90-81c3-74929127898a@172.16.0.28:12024";
assert_eq!(link(&[url]), open(url, None));
}
#[test]
fn xshells_options_are_understood_in_any_case_and_order() {
let url = "ssh://deploy:secret@10.0.0.9:2222";
assert_eq!(
link(&["-url", url, "-newtab", "生产库"]),
open(url, Some("生产库"))
);
assert_eq!(
link(&["-NewTab", " 生产库 ", "-newwin", "-URL", url]),
open(url, Some("生产库"))
);
// `-newtab` without a name.
assert_eq!(link(&["-newtab", url]), open(url, None));
assert_eq!(link(&["-newwin", url, "-newtab"]), open(url, None));
// `-url` wins over a bare link, and the first of each counts.
assert_eq!(
link(&["ssh://a@b", "-url", url, "-url", "ssh://c@d"]),
open(url, None)
);
// Unknown options are passed over, their values with them unless
// they look like a link.
assert_eq!(link(&["-newwin", "-folder", "x", url]), open(url, None));
}
#[test]
fn winscps_switches_are_understood_too() {
// How JumpServer opens WinSCP: the link alone.
let url = "sftp://b478e26f:secret@172.16.0.28:12024";
assert_eq!(link(&[url]), open(url, None));
assert_eq!(
link(&["/sessionname=生产库", url]),
open(url, Some("生产库"))
);
assert_eq!(
link(&[
"/newinstance",
"/ini=nul",
"-SessionName=\"文件 服务器\"",
url
]),
open(url, Some("文件 服务器"))
);
assert_eq!(
link(&[
"/rawsettings",
"Compression=1",
url,
"/hostkey=ssh-ed25519 255 x"
]),
open(url, None)
);
assert_eq!(link(&["/sessionname=生产库"]), open("", Some("生产库")));
}
#[test]
fn options_without_a_link_still_open_so_the_app_can_say_why() {
assert_eq!(link(&["-newtab", "生产库"]), open("", Some("生产库")));
assert_eq!(link(&["-url"]), open("", None));
}
#[test]
fn the_commands_arguments_are_left_to_the_command() {
for args in [
&[][..],
&["list"],
&["exec", "web", "curl http://example.com"],
&["upload", "web", "./a", "/tmp/"],
&["--help"],
&["-h"],
&["--version"],
&["ssh-not-a-link"],
&["://nothing-before"],
&["/tmp/sessionname"],
&["/usr/bin/sftp://x"],
] {
assert_eq!(link(args), None, "{args:?}");
}
}
#[test]
fn debug_hides_the_password() {
let shown = format!(
"{:?}",
OpenLink {
url: "ssh://deploy:hunter2@10.0.0.9".into(),
tab: None,
}
);
assert!(!shown.contains("hunter2"), "{shown}");
assert!(shown.contains("deploy"), "{shown}");
let unreadable = format!(
"{:?}",
OpenLink {
url: "ssh://deploy:hunter2@[bad".into(),
tab: None,
}
);
assert!(!unreadable.contains("hunter2"), "{unreadable}");
}
}
+1114
View File
File diff suppressed because it is too large. Load diff
+406 -42
View File
@@ -9,6 +9,8 @@ mod install;
#[cfg(any(windows, test))]
mod install_windows;
mod integration;
mod link;
mod manage;
#[cfg(windows)]
mod pipe_windows;
mod protocol;
@@ -24,6 +26,8 @@ use std::{
use clap::{Parser, Subcommand};
use crate::i18n::t;
pub use backend::SshCliBackend;
pub use client::{Console, FAILURE_EXIT, PARTIAL_EXIT, activate_running_app};
pub use install::{
@@ -32,20 +36,67 @@ pub use install::{
update_outdated_binary, update_outdated_skills,
};
pub use integration::{CliIntegration, IntegrationStatus};
pub use protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
pub use server::{CliBackend, CliServer, CliTarget};
pub use link::{OpenLink, link_arguments};
pub use manage::CliChange;
pub(crate) use manage::{
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
find_credential, find_host, find_saved_host, host_details, host_info, host_secrets,
plan_credential, plan_host, with_saved_credential_secrets, with_saved_passwords,
};
pub use protocol::{
AuthChoice, CliError, CredentialDeleted, CredentialDetails, CredentialFields,
CredentialKindChoice, ErrorCode, HostDeleted, HostDetails, HostFields, HostInfo, ProxyChoice,
Reply, Request, RouteDetails, RouteFields, Secret, TerminalContext, TransferCounters,
TransferSummary,
};
pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse};
const AFTER_HELP: &str = "\
Hosts are named by the 16-character ID that `shellrs list` prints (the one ShellRS copies with 复制 ID).
ShellRS must be running, with 设置 → 外部 CLI → 启用外部 CLI turned on.
// `--help` is English whatever the interface language, so it names the
// settings it points to in both.
const AFTER_HELP: &str = concat!(
"Hosts are named by the 16-character ID that `shellrs hosts list` prints, ",
"the one ShellRS copies with Copy ID (复制 ID).\n", // i18n: keep
"ShellRS must be running, with Settings → External CLI → Enable external CLI ",
"(设置 → 外部 CLI → 启用外部 CLI) turned on.\n", // i18n: keep
"\n",
"Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;\n",
"255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).",
);
Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;
255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).";
const HOST_FIELDS: &str = "\
Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given.
name Required to create.
host The address (IP or host name). Required to create. Also read as `address`.
port Default 22.
user Default root. A host using a credential logs in as the credential's user.
group A group path such as \"Production/Databases\", made when it is not there; null for the top level.
auth \"password\", \"credential\" or \"no_password\".
credential A credential ID from `shellrs credentials list`, for auth \"credential\" (implied).
password The host's own password, saved to the keychain and never printed; null deletes it.
Changing host, port or user without it keeps the saved one.
route {\"type\": \"direct\"}
{\"type\": \"jump\", \"hosts\": [ID, ...]} (saved hosts, in order)
{\"type\": \"proxy\", \"kind\": \"http\" | \"socks5\", \"host\": ..., \"port\": ...,
\"user\": ..., \"password\": ...}
notes Free text.
What `shellrs hosts show <ID> --json` prints can be edited and passed back as it is.";
/// Run commands and move files on the SSH hosts saved in ShellRS.
const CREDENTIAL_FIELDS: &str = "\
Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given.
name Required to create.
kind \"password\" (the default), \"key\" (implied by a key) or \"agent\".
user Default root.
password A password credential's, saved to the keychain and never printed; null deletes it.
key_path A private key file on this machine (relative to the current directory, or ~/...).
private_key The private key's text instead, for ShellRS to keep in a file of its own.
passphrase The key's passphrase, saved to the keychain; null deletes it.
What `shellrs credentials show <ID> --json` prints can be edited and passed back as it is.";
/// Run commands, move files and manage the SSH hosts saved in ShellRS.
///
/// ShellRS holds the passwords and keys and makes the connections; this
/// command never sees them.
/// command never reads them. One given to it to save goes to the keychain
/// and is never printed back.
#[derive(Debug, Parser)]
#[command(name = "shellrs", version, after_help = AFTER_HELP)]
struct Cli {
@@ -55,14 +106,15 @@ struct Cli {
#[derive(Debug, Subcommand)]
enum Command {
/// List the saved hosts.
List {
/// Only hosts whose name, host, user, group or ID matches.
#[arg(short, long)]
query: Option<String>,
/// Print JSON. Implied when stdout is not a terminal.
#[arg(long)]
json: bool,
/// Show, create, change or delete saved hosts.
Hosts {
#[command(subcommand)]
command: HostsCommand,
},
/// Show, create, change or delete saved credentials.
Credentials {
#[command(subcommand)]
command: CredentialsCommand,
},
/// Run one command on a saved host and print its output.
///
@@ -70,15 +122,31 @@ enum Command {
/// and closes it. The command gets no stdin. The exit code is the
/// remote command's.
Exec {
/// Host ID, from `shellrs list`.
id: String,
/// Host ID, from `shellrs hosts list`.
#[arg(required_unless_present = "json")]
id: Option<String>,
/// One complete remote shell command, quoted as one argument.
#[arg(required_unless_present = "stdin")]
#[arg(required_unless_present_any = ["stdin", "json"])]
command: Option<String>,
/// Read the command from stdin instead, for commands with quotes,
/// pipes, `$` or several lines.
#[arg(long, conflicts_with = "command")]
stdin: bool,
/// Read {"host": ID, "command": COMMAND} from stdin and print
/// {"exit_code", "stdout", "stderr"} once the command ends, errors as
/// {"error": {"code", "message"}}. The output is ASCII, everything
/// else escaped: no shell quoting, and no console code page can
/// garble it. {"terminal": true} is the same as --terminal.
#[arg(long, conflicts_with_all = ["id", "command", "stdin"])]
json: bool,
/// Type the command into the host's terminal open in ShellRS
/// instead of logging in again, for a bastion host that allows one
/// login. It sends Ctrl-C first, which interrupts whatever runs
/// there. Its stderr comes as stdout; with --json, the result also
/// has "context": the user, host, folder and shell it ran in. Bash,
/// dash and busybox ash only.
#[arg(long)]
terminal: bool,
},
/// Copy a local file or folder to a saved host.
///
@@ -87,7 +155,7 @@ enum Command {
/// copy's path. Folders are copied recursively; existing files are
/// overwritten.
Upload {
/// Host ID, from `shellrs list`.
/// Host ID, from `shellrs hosts list`.
id: String,
/// Local file or folder.
local: PathBuf,
@@ -101,7 +169,7 @@ enum Command {
///
/// Destination rules are scp's, as for `upload`.
Download {
/// Host ID, from `shellrs list`.
/// Host ID, from `shellrs hosts list`.
id: String,
/// Remote file or folder. `~` is the login directory.
remote: String,
@@ -111,11 +179,125 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Copy what a local folder holds into a folder on a saved host,
/// skipping files that have not changed.
///
/// The remote folder is made when it is not there (its parent must
/// be). A file of the same size and modification time is left alone;
/// any other is copied over. Only this way: nothing comes back.
Sync {
/// Host ID, from `shellrs hosts list`.
id: String,
/// Local folder, whose contents are copied.
local: PathBuf,
/// Remote folder. `~` is the login directory.
remote: String,
/// First delete what is in the remote folder and not in the local
/// one.
#[arg(long)]
delete: bool,
/// Print the summary as JSON. Implied when stdout is not a terminal.
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Subcommand)]
enum HostsCommand {
/// List the saved hosts, and the ones connected to without saving
/// while their tabs are open.
List {
/// Only hosts whose name, host, user, group or ID matches.
#[arg(short, long)]
query: Option<String>,
/// Print JSON. Implied when stdout is not a terminal.
#[arg(long)]
json: bool,
},
/// Show one host in full. Passwords are never shown, only whether one
/// is saved.
Show {
/// Host ID, from `shellrs hosts list`.
id: String,
#[arg(long)]
json: bool,
},
/// Save a new host, described by JSON on stdin.
#[command(after_help = HOST_FIELDS)]
Create {
#[arg(long)]
json: bool,
},
/// Change a saved host: the fields of the JSON on stdin.
#[command(after_help = HOST_FIELDS)]
Update {
/// Host ID, from `shellrs hosts list`.
id: String,
#[arg(long)]
json: bool,
},
/// Delete a saved host, with its port forwarding rules.
Delete {
/// Host ID, from `shellrs hosts list`.
id: String,
/// Delete it even with its tabs open in ShellRS, closing them.
#[arg(long)]
force: bool,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Subcommand)]
enum CredentialsCommand {
/// List the credentials.
List {
/// Only credentials whose name, user, kind or ID matches.
#[arg(short, long)]
query: Option<String>,
#[arg(long)]
json: bool,
},
/// Show one credential. Passwords and keys are never shown, only
/// whether they are saved.
Show {
/// Credential ID, from `shellrs credentials list`.
id: String,
#[arg(long)]
json: bool,
},
/// Save a new credential, described by JSON on stdin.
#[command(after_help = CREDENTIAL_FIELDS)]
Create {
#[arg(long)]
json: bool,
},
/// Change a credential: the fields of the JSON on stdin.
#[command(after_help = CREDENTIAL_FIELDS)]
Update {
/// Credential ID, from `shellrs credentials list`.
id: String,
#[arg(long)]
json: bool,
},
/// Delete a credential. The hosts using it log in on their own from
/// then on.
Delete {
/// Credential ID, from `shellrs credentials list`.
id: String,
#[arg(long)]
json: bool,
},
}
/// The command line entry: `args` without the program name. Returns the
/// process exit code.
pub fn main(args: Vec<OsString>) -> i32 {
// In the language the app's window is in, as what the app sends back.
if let Ok(path) = crate::app::settings_path() {
let (settings, _) = crate::settings::SettingsStore::load(path);
crate::i18n::set_locale(settings.settings().language.resolved());
}
let cli = match Cli::try_parse_from(std::iter::once(OsString::from("shellrs")).chain(args)) {
Ok(cli) => cli,
Err(error) => {
@@ -132,6 +314,7 @@ pub fn main(args: Vec<OsString>) -> i32 {
stdout: &mut stdout,
stderr: &mut stderr,
json,
exec_json: false,
stderr_is_terminal,
};
let code = match request(cli.command, &mut console) {
@@ -147,27 +330,39 @@ pub fn main(args: Vec<OsString>) -> i32 {
/// cannot be sent.
fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
Ok(match command {
Command::List { query, json } => {
console.json |= json;
Request::List { query }
}
Command::Exec { id, command, stdin } => {
let command = if stdin {
let mut text = String::new();
if let Err(error) = std::io::stdin().read_to_string(&mut text) {
return Err(
console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}"))
);
}
text
Command::Exec {
id,
command,
stdin,
json,
terminal,
} => {
// From here on, errors are JSON too.
console.exec_json = json;
let request = if json {
exec_request(&read_stdin(console)?)
.map_err(|message| console.error(ErrorCode::BadRequest, &message))?
} else {
command.unwrap_or_default()
ExecJson {
host: id.unwrap_or_default(),
command: if stdin {
read_stdin(console)?
} else {
command.unwrap_or_default()
},
terminal: false,
}
};
let command = normalize_command(&command);
let command = normalize_command(&request.command);
if command.trim().is_empty() {
return Err(console.error(ErrorCode::BadRequest, "命令不能为空"));
return Err(console.error(ErrorCode::BadRequest, &t!("cli.exec.empty")));
}
let host = request.host;
if terminal || request.terminal {
Request::ExecInTerminal { host, command }
} else {
Request::Exec { host, command }
}
Request::Exec { host: id, command }
}
Command::Upload {
id,
@@ -195,6 +390,170 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
destination: absolute(local, console)?,
}
}
Command::Sync {
id,
local,
remote,
delete,
json,
} => {
console.json |= json;
Request::Sync {
host: id,
source: absolute(local, console)?,
destination: remote,
delete,
}
}
Command::Hosts { command } => hosts_request(command, console)?,
Command::Credentials { command } => credentials_request(command, console)?,
})
}
fn hosts_request(command: HostsCommand, console: &mut Console) -> Result<Request, i32> {
Ok(match command {
HostsCommand::List { query, json } => {
console.json |= json;
Request::List { query }
}
HostsCommand::Show { id, json } => {
console.json |= json;
Request::ShowHost { host: id }
}
HostsCommand::Create { json } => {
console.json |= json;
Request::CreateHost {
fields: host_fields(console, "shellrs hosts create < host.json")?,
}
}
HostsCommand::Update { id, json } => {
console.json |= json;
let example = format!("shellrs hosts update {id} < changes.json");
Request::UpdateHost {
fields: host_fields(console, &example)?,
host: id,
}
}
HostsCommand::Delete { id, force, json } => {
console.json |= json;
Request::DeleteHost { host: id, force }
}
})
}
fn credentials_request(command: CredentialsCommand, console: &mut Console) -> Result<Request, i32> {
Ok(match command {
CredentialsCommand::List { query, json } => {
console.json |= json;
Request::ListCredentials { query }
}
CredentialsCommand::Show { id, json } => {
console.json |= json;
Request::ShowCredential { credential: id }
}
CredentialsCommand::Create { json } => {
console.json |= json;
Request::CreateCredential {
fields: credential_fields(console, "shellrs credentials create < credential.json")?,
}
}
CredentialsCommand::Update { id, json } => {
console.json |= json;
let example = format!("shellrs credentials update {id} < changes.json");
Request::UpdateCredential {
fields: credential_fields(console, &example)?,
credential: id,
}
}
CredentialsCommand::Delete { id, json } => {
console.json |= json;
Request::DeleteCredential { credential: id }
}
})
}
/// The JSON object on stdin. Not from a terminal: nothing would say what
/// to type, or that it waits for it.
fn read_json(console: &mut Console, example: &str) -> Result<serde_json::Value, i32> {
if io::stdin().is_terminal() {
return Err(console.error(
ErrorCode::BadRequest,
&t!("cli.json.from_terminal", example = example),
));
}
let text = read_stdin(console)?;
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
serde_json::from_str(text).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.json.not_json", error = error),
)
})
}
fn host_fields(console: &mut Console, example: &str) -> Result<HostFields, i32> {
let value = read_json(console, example)?;
manage::host_fields(value).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.json.invalid", error = error),
)
})
}
/// A key file named relative to here, or to the home directory, is sent
/// as the absolute path the app can find.
fn credential_fields(console: &mut Console, example: &str) -> Result<CredentialFields, i32> {
let value = read_json(console, example)?;
let mut fields = manage::credential_fields(value).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.json.invalid", error = error),
)
})?;
if let Some(path) = fields.key_path.take() {
let path = match path.strip_prefix("~") {
Ok(rest) => dirs::home_dir().map(|home| home.join(rest)).unwrap_or(path),
Err(_) => path,
};
fields.key_path = Some(absolute(path, console)?);
}
Ok(fields)
}
fn read_stdin(console: &mut Console) -> Result<String, i32> {
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.stdin.unreadable", error = error),
)
})?;
Ok(text)
}
/// What `exec --json` reads.
#[derive(Debug, PartialEq, serde::Deserialize)]
struct ExecJson {
host: String,
command: String,
/// `--terminal`.
#[serde(default)]
terminal: bool,
}
/// What an `exec --json` request asks for.
fn exec_request(text: &str) -> Result<ExecJson, String> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
serde_json::from_str(text).map_err(|error| {
t!(
"cli.exec.bad_json",
form = r#"{"host": ..., "command": ...}"#,
error = error
)
.to_string()
})
}
@@ -213,7 +572,11 @@ fn absolute(path: PathBuf, console: &mut Console) -> Result<PathBuf, i32> {
std::path::absolute(&path).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&format!("无法解析路径 {}:{error}", path.display()),
&t!(
"cli.path.unresolvable",
path = path.display(),
error = error
),
)
})
}
@@ -287,8 +650,9 @@ impl<W: Write> Write for ConsoleText<W> {
}
}
/// Whether the process was started as the command rather than the app:
/// with arguments, apart from the one macOS adds when launching an app.
/// The arguments the process was started with, apart from the one macOS
/// adds when launching an app; `None` without any. The command's, unless
/// [`link_arguments`] finds a link to open in them.
pub fn command_line_arguments() -> Option<Vec<OsString>> {
let args: Vec<OsString> = std::env::args_os()
.skip(1)
+428 -7
View File
@@ -11,11 +11,19 @@ use std::{
path::PathBuf,
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned};
use zeroize::Zeroizing;
use super::link::OpenLink;
/// Bumped when a request or reply changes shape. The command and the app
/// come from the same build, so they differ only while an older copy still
/// runs, or on Windows while the copy on the PATH has not been updated.
///
/// New requests and replies are added without bumping it, so a command
/// and an app of different builds still agree on what both know; an app
/// asked for a request it does not know answers `bad_request`, which the
/// command explains.
pub const PROTOCOL_VERSION: u32 = 2;
/// Frames larger than this are refused, so a confused peer cannot make the
@@ -102,6 +110,13 @@ pub struct Envelope {
pub request: Request,
}
/// The version alone, read before the request: a request this build does
/// not know is then a version mismatch rather than a puzzle.
#[derive(Deserialize)]
pub struct VersionOnly {
pub version: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Request {
@@ -112,6 +127,14 @@ pub enum Request {
host: String,
command: String,
},
/// `exec --terminal`: type `command` into the host's open terminal
/// instead of logging in again, for a bastion host that allows one
/// login. Its output comes as stdout, then [`Reply::Context`], then
/// [`Reply::Exit`].
ExecInTerminal {
host: String,
command: String,
},
/// `source` is absolute: the app's working directory is not the
/// caller's.
Upload {
@@ -124,10 +147,59 @@ pub enum Request {
source: String,
destination: PathBuf,
},
/// Bring the app's window forward. Not the `shellrs` command's: ShellRS
/// sends it when it is opened while it is already running, so it is
/// answered whatever 启用外部 CLI says.
Activate,
/// Copy what the local folder `source` holds into `destination` on the
/// host, skipping what has not changed; with `delete`, remove what is
/// there and not here first.
Sync {
host: String,
source: PathBuf,
destination: String,
#[serde(default)]
delete: bool,
},
ShowHost {
host: String,
},
CreateHost {
fields: HostFields,
},
UpdateHost {
host: String,
fields: HostFields,
},
/// `force` closes the host's tabs; without it, a host with tabs open
/// is refused.
DeleteHost {
host: String,
#[serde(default)]
force: bool,
},
ListCredentials {
query: Option<String>,
},
ShowCredential {
credential: String,
},
CreateCredential {
fields: CredentialFields,
},
UpdateCredential {
credential: String,
fields: CredentialFields,
},
DeleteCredential {
credential: String,
},
/// Bring the app's window forward, and open `open` in it. Not the
/// `shellrs` command's: ShellRS sends it when it is opened while it is
/// already running, so it is answered whatever 启用外部 CLI says.
///
/// Without a link it is `{"type":"activate"}`, as it always was. A
/// ShellRS from before links ignores `open` and only comes forward.
Activate {
#[serde(default, skip_serializing_if = "Option::is_none")]
open: Option<OpenLink>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -143,6 +215,16 @@ pub enum Reply {
Exit {
code: i32,
},
/// Where an `exec --terminal` command runs, once it started.
Context(TerminalContext),
/// A host as it is now: shown, created or changed.
Host(HostDetails),
HostDeleted(HostDeleted),
Credentials {
credentials: Vec<CredentialDetails>,
},
Credential(CredentialDetails),
CredentialDeleted(CredentialDeleted),
/// The app heard [`Request::Activate`].
Activated,
Error {
@@ -151,7 +233,7 @@ pub enum Reply {
},
}
/// A saved host as the CLI lists it.
/// A host as the CLI lists it: a saved one, or a temporary one.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostInfo {
/// The host's public ID, the one 复制 ID copies.
@@ -164,6 +246,12 @@ pub struct HostInfo {
pub port: u16,
/// The detected operating system, as stored (`ubuntu`, `macos`, …).
pub os: Option<String>,
/// Connected to without being saved (a 临时连接, or an 外部连接 a bastion
/// host's link opened): listed while its tab is open, under one ID for
/// as long; opened again, it is another host with another ID. Missing
/// from an older app's answer, which listed saved hosts only.
#[serde(default)]
pub temporary: bool,
}
impl HostInfo {
@@ -172,6 +260,19 @@ impl HostInfo {
}
}
/// Where an `exec --terminal` command ran: the terminal may have gone to
/// another user, folder or machine since it was opened.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalContext {
pub user: String,
pub host: String,
pub cwd: String,
/// The interactive shell the command was typed into.
pub shell: String,
/// What ran the command: `bash`, or `sh` where there is none.
pub interp: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransferCounters {
pub files: u64,
@@ -184,8 +285,13 @@ pub struct TransferCounters {
pub struct TransferSummary {
pub files: u64,
pub bytes: u64,
/// For a sync, the files left alone because they had not changed.
pub skipped: u64,
pub failed: u64,
/// What a sync with `--delete` removed from the host. Missing from an
/// older app's answer.
#[serde(default)]
pub deleted: u64,
/// Why each failed item failed.
pub failures: Vec<String>,
}
@@ -207,6 +313,20 @@ pub enum ErrorCode {
ConnectFailed,
TransferFailed,
BadRequest,
CredentialNotFound,
/// A host with tabs open, asked to be deleted without `--force`.
HostInUse,
/// A change was made, but could not all be written to disk or the
/// keychain.
SaveFailed,
/// `exec --terminal`: the host has no connected terminal.
NoTerminal,
/// `exec --terminal`: the terminal could not take the command.
TerminalBusy,
/// `exec --terminal`: the shell is not bash, dash or busybox ash.
UnsupportedShell,
/// `exec --terminal`: the command line is longer than the shell takes.
TooLong,
}
impl ErrorCode {
@@ -222,6 +342,13 @@ impl ErrorCode {
ErrorCode::ConnectFailed => "connect_failed",
ErrorCode::TransferFailed => "transfer_failed",
ErrorCode::BadRequest => "bad_request",
ErrorCode::CredentialNotFound => "credential_not_found",
ErrorCode::HostInUse => "host_in_use",
ErrorCode::SaveFailed => "save_failed",
ErrorCode::NoTerminal => "no_terminal",
ErrorCode::TerminalBusy => "terminal_busy",
ErrorCode::UnsupportedShell => "unsupported_shell",
ErrorCode::TooLong => "too_long",
}
}
}
@@ -242,6 +369,262 @@ impl CliError {
}
}
/// A password, passphrase or private key on its way to the app, never
/// the other way. `Debug` leaves it out, so a request can be printed or
/// compared in a test without it showing.
#[derive(Clone, PartialEq, Eq)]
pub struct Secret(Zeroizing<String>);
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Self(Zeroizing::new(value.into()))
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("Secret(***)")
}
}
impl Serialize for Secret {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Secret {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer).map(Self::new)
}
}
/// For a field that can be left out, set or set to `null`, three different
/// things: `None` when left out, `Some(None)` for `null`.
fn present<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: Deserializer<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
/// How a host logs in, as the CLI spells it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthChoice {
Password,
Credential,
NoPassword,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProxyChoice {
#[default]
Http,
Socks5,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CredentialKindChoice {
Password,
Key,
Agent,
}
/// A saved host in full, as `hosts show` prints it. `hosts create` and
/// `hosts update` read the same names back, so what one prints the other
/// takes. Never a secret: only whether one is saved, and only when shown.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostDetails {
pub id: String,
pub name: String,
/// The full group path, such as `生产/数据库`; `None` at the root.
pub group: Option<String>,
pub host: String,
pub port: u16,
pub user: String,
pub auth: AuthChoice,
/// The credential's ID, for a host that logs in with one.
pub credential: Option<String>,
/// For a host with its own password, whether one is saved. Looked up
/// only for `hosts show`, and for the host a change returns.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_saved: Option<bool>,
pub route: RouteDetails,
pub notes: String,
pub os: Option<String>,
#[serde(default)]
pub temporary: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RouteDetails {
Direct,
/// The jump hosts' IDs in order; `None` for one that was deleted.
Jump {
hosts: Vec<Option<String>>,
},
Proxy {
kind: ProxyChoice,
host: String,
port: u16,
user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
password_saved: Option<bool>,
},
}
/// What `hosts create` sets and `hosts update` changes: every field may be
/// left out (`update` leaves it as it is), and those that can be empty may
/// be `null`. What `hosts show` prints and nobody sets is taken out before
/// these are read.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostFields {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, alias = "address", skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// `null` for the root.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub group: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthChoice>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub credential: Option<Option<String>>,
/// `null` deletes the saved one.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub password: Option<Option<Secret>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<RouteFields>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
/// A route as `hosts create` and `hosts update` take it: always whole.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum RouteFields {
Direct,
Jump {
hosts: Vec<Option<String>>,
},
Proxy {
#[serde(default)]
kind: ProxyChoice,
host: String,
port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
user: Option<String>,
/// `null` deletes the saved one.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
password: Option<Option<Secret>>,
},
}
/// Deleted, with what went with it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostDeleted {
pub host: HostDetails,
/// Its port forwarding rules, deleted with it.
pub forwards: u64,
/// Other hosts that went through it, which now have a deleted jump
/// host in its place.
pub jump_users: u64,
}
/// A saved credential, as `credentials show` prints it. Never a secret,
/// nor the path of a key ShellRS keeps: only whether one is saved.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CredentialDetails {
pub id: String,
pub name: String,
pub kind: CredentialKindChoice,
pub user: String,
/// The user's own key file; `None` for a key ShellRS keeps (`kept`).
pub key_path: Option<String>,
/// The key was pasted or generated, and ShellRS keeps it itself.
#[serde(default)]
pub kept: bool,
/// The IDs of the saved hosts that log in with it.
#[serde(default)]
pub hosts: Vec<String>,
/// Looked up only for `credentials show`, and for the credential a
/// change returns.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_saved: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub passphrase_saved: Option<bool>,
}
/// What `credentials create` sets and `credentials update` changes, as
/// for [`HostFields`].
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialFields {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<CredentialKindChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// A password credential's; `null` deletes the saved one.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub password: Option<Option<Secret>>,
/// A key file on this machine, absolute by the time it reaches the app.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_path: Option<PathBuf>,
/// The key's text, for ShellRS to keep.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private_key: Option<Secret>,
/// The key's; `null` deletes the saved one.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub passphrase: Option<Option<Secret>>,
}
/// Deleted; the hosts that logged in with it now log in on their own.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CredentialDeleted {
pub credential: CredentialDetails,
pub released: Vec<HostInfo>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -293,11 +676,49 @@ mod tests {
#[test]
fn error_codes_are_spelled_as_documented() {
for code in [ErrorCode::NotEnabled, ErrorCode::HostKeyUnknown] {
for code in [
ErrorCode::NotEnabled,
ErrorCode::HostKeyUnknown,
ErrorCode::NoTerminal,
ErrorCode::TerminalBusy,
ErrorCode::UnsupportedShell,
ErrorCode::TooLong,
] {
assert_eq!(
serde_json::to_string(&code).unwrap(),
format!("\"{}\"", code.as_str())
);
}
}
#[test]
fn activating_without_a_link_is_what_it_always_was() {
let plain = Envelope {
version: PROTOCOL_VERSION,
request: Request::Activate { open: None },
};
assert_eq!(
serde_json::to_string(&plain).unwrap(),
r#"{"version":2,"request":{"type":"activate"}}"#
);
let read: Envelope = parse_json(br#"{"version":2,"request":{"type":"activate"}}"#).unwrap();
assert_eq!(read, plain);
let with_link = Request::Activate {
open: Some(OpenLink {
url: "ssh://token@10.0.0.9:2222".into(),
tab: Some("堡垒机".into()),
}),
};
let wire = serde_json::to_vec(&with_link).unwrap();
assert_eq!(parse_json::<Request>(&wire).unwrap(), with_link);
// How a ShellRS from before links read it: still a plain Activate.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Before {
Activate,
}
assert_eq!(parse_json::<Before>(&wire).unwrap(), Before::Activate);
}
}
+492 -57
View File
@@ -3,24 +3,39 @@
//! backend's, so tests can put a fake behind the same server.
use std::{
collections::VecDeque,
io::{self, BufReader},
path::{Path, PathBuf},
sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
Arc, Mutex, RwLock,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc,
},
thread::JoinHandle,
time::Duration,
};
#[cfg(unix)]
use tokio::sync::watch;
use super::link::OpenLink;
use super::manage::{
CliChange, CredentialSecrets, HostSecrets, credential_details, credential_matches,
credential_not_found, credential_secrets, host_details, host_info, host_not_found,
host_secrets, with_saved_credential_secrets, with_saved_passwords,
};
use super::protocol::{
CliError, Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request,
TransferCounters, TransferSummary, parse_json, read_frame, write_frame, write_json,
CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo,
PROTOCOL_VERSION, Reply, Request, TerminalContext, TransferCounters, TransferSummary,
VersionOnly, parse_json, read_frame, write_frame, write_json,
};
use crate::host::{Host, HostLogin, HostStore, matches_query};
use crate::i18n::t;
use crate::secrets::SecretRef;
use crate::ssh::ExecStream;
use crate::terminal::{
BusyReason, RunEvent, RunFailure, RunHandle, RunProgress, RunTiming, TerminalRun,
};
/// Does what a CLI request asks. Every method blocks: each request has a
/// thread of its own.
@@ -55,6 +70,22 @@ pub trait CliBackend: Send + Sync + 'static {
destination: &Path,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError>;
/// Bring the folder `destination` on the host in line with the local
/// folder `source`: what changed is copied, what did not is left alone,
/// and with `delete` what is not in `source` goes.
fn sync(
&self,
target: &CliTarget,
source: &Path,
destination: &str,
delete: bool,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError>;
/// Whether the keychain (or, for a host not saved, memory) holds this
/// entry. Only asked when one host or credential is shown.
fn is_saved(&self, secret: &SecretRef) -> bool;
}
/// A saved host as the CLI sees it, with the login it connects with
@@ -62,25 +93,20 @@ pub trait CliBackend: Send + Sync + 'static {
#[derive(Clone, Debug)]
pub struct CliTarget {
info: HostInfo,
details: HostDetails,
secrets: HostSecrets,
host: Host,
login: HostLogin,
}
impl CliTarget {
/// `group` is the host's full group path, if it has one.
pub fn new(host: &Host, login: HostLogin, group: Option<String>) -> Self {
fn of(host: &Host, store: &HostStore) -> Self {
Self {
info: HostInfo {
id: host.public_id.to_string(),
name: host.name.to_string(),
group,
user: host.user.to_string(),
host: host.address.to_string(),
port: host.port,
os: host.os.map(|os| os.as_str().to_string()),
},
info: host_info(host, store),
details: host_details(host, store),
secrets: host_secrets(host, store),
host: host.clone(),
login,
login: store.login_of(host),
}
}
@@ -93,22 +119,16 @@ impl CliTarget {
&self.login
}
/// Every host in `store`, with its group path.
/// Every host in `store`, with its group path, then the ones connected
/// to without saving them. A request logs in to those afresh too, with
/// the password kept in memory; a bastion host may refuse a login it
/// gave out for one use.
pub fn all(store: &HostStore) -> Vec<Self> {
store
.hosts()
.iter()
.map(|host| {
let names = host
.group
.map(|id| store.group_names(id))
.unwrap_or_default();
Self::new(
host,
store.login_of(host),
(!names.is_empty()).then(|| names.join("/")),
)
})
.chain(store.temporary_hosts())
.map(|host| Self::of(host, store))
.collect()
}
@@ -125,14 +145,116 @@ impl CliTarget {
}
}
/// A saved credential as the CLI sees it.
#[derive(Clone, Debug)]
struct CliCredential {
details: CredentialDetails,
secrets: CredentialSecrets,
}
/// How long a change may wait for the app to take it up. Only the wait in
/// line counts: once taken, the change is being made and its outcome is
/// waited for however long the keychain takes, or an agent would try
/// again and make it twice.
const QUEUE_TIMEOUT: Duration = if cfg!(test) {
Duration::from_millis(300)
} else {
Duration::from_secs(10)
};
/// How an `exec --terminal` run is paced; quick in the tests.
const RUN_TIMING: RunTiming = if cfg!(test) {
RunTiming {
settle: Duration::ZERO,
probe: Duration::from_millis(300),
start: Duration::from_millis(300),
tick: Duration::from_millis(20),
}
} else {
RunTiming::STANDARD
};
/// An `exec --terminal` run waiting for the app to find the host's
/// terminal.
struct PendingRun {
id: u64,
host: String,
run: RunHandle,
}
/// A change waiting for the app, with where its outcome goes.
struct PendingChange {
id: u64,
change: CliChange,
reply: ChangeReply,
}
/// Where the app sends the outcome of a change it took up. Dropping it
/// unsent tells the request it will not come.
pub struct ChangeReply(mpsc::Sender<Result<Reply, CliError>>);
impl ChangeReply {
pub fn send(self, outcome: Result<Reply, CliError>) {
let _ = self.0.send(outcome);
}
}
/// What the listener and the request threads share with the app.
struct Shared {
enabled: AtomicBool,
targets: RwLock<Vec<CliTarget>>,
credentials: RwLock<Vec<CliCredential>>,
backend: Arc<dyn CliBackend>,
/// Set when ShellRS was opened again while this one runs, until the
/// app has brought its window forward.
activation: AtomicBool,
/// Set when ShellRS was opened again while this one runs, with the
/// links it was opened with, until the app has come forward and opened
/// them.
activation: Mutex<Option<Vec<OpenLink>>>,
/// Changes to hosts and credentials, oldest first, until the app takes
/// them up: only the UI thread can change the store.
changes: Mutex<VecDeque<PendingChange>>,
/// `exec --terminal` runs, oldest first, until the app takes them up:
/// only the UI thread can reach a terminal.
runs: Mutex<VecDeque<PendingRun>>,
next_change: AtomicU64,
/// What kinds of command were served, for 匿名使用统计, until the app
/// takes them.
usage: Mutex<Vec<CliUse>>,
}
/// The kind of a `shellrs` command, as 匿名使用统计 counts them: nothing
/// of what it asked for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CliUse {
Exec,
ExecTerminal,
Upload,
Download,
Sync,
Hosts,
Credentials,
}
impl CliUse {
fn of(request: &Request) -> Option<Self> {
match request {
Request::Exec { .. } => Some(Self::Exec),
Request::ExecInTerminal { .. } => Some(Self::ExecTerminal),
Request::Upload { .. } => Some(Self::Upload),
Request::Download { .. } => Some(Self::Download),
Request::Sync { .. } => Some(Self::Sync),
Request::List { .. }
| Request::ShowHost { .. }
| Request::CreateHost { .. }
| Request::UpdateHost { .. }
| Request::DeleteHost { .. } => Some(Self::Hosts),
Request::ListCredentials { .. }
| Request::ShowCredential { .. }
| Request::CreateCredential { .. }
| Request::UpdateCredential { .. }
| Request::DeleteCredential { .. } => Some(Self::Credentials),
Request::Activate { .. } => None,
}
}
}
/// Listens on the CLI socket until dropped. Always listening, even with
@@ -151,8 +273,13 @@ impl CliServer {
let shared = Arc::new(Shared {
enabled: AtomicBool::new(false),
targets: RwLock::new(Vec::new()),
credentials: RwLock::new(Vec::new()),
backend,
activation: AtomicBool::new(false),
activation: Mutex::new(None),
changes: Mutex::new(VecDeque::new()),
runs: Mutex::new(VecDeque::new()),
next_change: AtomicU64::new(0),
usage: Mutex::new(Vec::new()),
});
let listener = listen(endpoint, shared.clone())?;
Ok(Self {
@@ -165,19 +292,94 @@ impl CliServer {
self.shared.enabled.store(enabled, Ordering::Release);
}
pub fn set_targets(&self, targets: Vec<CliTarget>) {
/// What the requests see of the hosts and credentials: `store` as it
/// is now. Called whenever it changes.
pub fn set_hosts(&self, store: &HostStore) {
let credentials = store
.credentials()
.iter()
.map(|credential| CliCredential {
details: credential_details(credential, store),
secrets: credential_secrets(credential),
})
.collect();
*self
.shared
.targets
.write()
.unwrap_or_else(|error| error.into_inner()) = targets;
.unwrap_or_else(|error| error.into_inner()) = CliTarget::all(store);
*self
.shared
.credentials
.write()
.unwrap_or_else(|error| error.into_inner()) = credentials;
}
/// Whether ShellRS was opened again since this was last asked. The
/// request threads cannot reach the window, so the app asks here on a
/// timer and brings its window forward when the answer is yes.
pub fn take_activation(&self) -> bool {
self.shared.activation.swap(false, Ordering::AcqRel)
/// The oldest change waiting, for the app to make and answer. One at a
/// time: the next is only taken once this one is answered.
pub fn take_change(&self) -> Option<(CliChange, ChangeReply)> {
self.shared
.changes
.lock()
.unwrap_or_else(|error| error.into_inner())
.pop_front()
.map(|pending| (pending.change, pending.reply))
}
/// The oldest `exec --terminal` run waiting, with the host it is for,
/// for the app to hand to that host's terminal, or say why it cannot.
pub fn take_run(&self) -> Option<(String, RunHandle)> {
self.shared
.runs
.lock()
.unwrap_or_else(|error| error.into_inner())
.pop_front()
.map(|pending| (pending.host, pending.run))
}
/// Whether ShellRS was opened again since this was last asked, and the
/// links it was opened with, oldest first: `Some` and empty for a plain
/// re-open. The request threads cannot reach the window, so the app asks
/// here on a timer, then comes forward and opens them.
pub fn take_activation(&self) -> Option<Vec<OpenLink>> {
self.shared
.activation
.lock()
.unwrap_or_else(|error| error.into_inner())
.take()
}
/// The kinds of command served since this was last asked, oldest first.
/// Asked on the same timer as `take_activation`.
pub fn take_usage(&self) -> Vec<CliUse> {
std::mem::take(
&mut *self
.shared
.usage
.lock()
.unwrap_or_else(|error| error.into_inner()),
)
}
}
impl Drop for CliServer {
/// The app is going: whatever waits for it to take up a change or a
/// run hears that it never will.
fn drop(&mut self) {
self.shared
.changes
.lock()
.unwrap_or_else(|error| error.into_inner())
.clear();
for pending in self
.shared
.runs
.lock()
.unwrap_or_else(|error| error.into_inner())
.drain(..)
{
pending.run.fail(RunFailure::NotConnected);
}
}
}
@@ -386,12 +588,15 @@ struct Listener;
fn listen(_: PathBuf, _: Arc<Shared>) -> io::Result<Listener> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"此系统暂不支持外部 CLI",
t!("cli.unsupported").to_string(),
))
}
fn another_app() -> io::Error {
io::Error::new(io::ErrorKind::AddrInUse, "另一个 ShellRS 已在提供外部 CLI")
io::Error::new(
io::ErrorKind::AddrInUse,
t!("cli.server.another_app").to_string(),
)
}
/// Read one request and answer it.
@@ -418,27 +623,46 @@ fn respond(
writer: &mut impl io::Write,
shared: &Shared,
) -> Result<Reply, CliError> {
let envelope: Envelope = parse_json(payload)
.map_err(|error| CliError::new(ErrorCode::BadRequest, error.to_string()))?;
let bad_request = |error: io::Error| CliError::new(ErrorCode::BadRequest, error.to_string());
let envelope = parse_json::<Envelope>(payload);
// Before the checks below: coming forward means the same in every
// version, and it is ShellRS being opened again, not the external CLI.
if envelope.request == Request::Activate {
shared.activation.store(true, Ordering::Release);
if let Ok(Envelope {
request: Request::Activate { open },
..
}) = &envelope
{
shared
.activation
.lock()
.unwrap_or_else(|error| error.into_inner())
.get_or_insert_with(Vec::new)
.extend(open.clone());
return Ok(Reply::Activated);
}
if envelope.version != PROTOCOL_VERSION {
// The version before the request, which another version may spell in
// a way this one cannot read.
let version = parse_json::<VersionOnly>(payload).map_err(bad_request)?;
if version.version != PROTOCOL_VERSION {
return Err(CliError::new(
ErrorCode::VersionMismatch,
"shellrs 命令与正在运行的 ShellRS 版本不同:\
请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI",
t!("cli.server.version_mismatch"),
));
}
let envelope = envelope.map_err(bad_request)?;
if !shared.enabled.load(Ordering::Acquire) {
return Err(CliError::new(
ErrorCode::NotEnabled,
"ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」",
t!("cli.server.not_enabled"),
));
}
if let Some(usage) = CliUse::of(&envelope.request) {
shared
.usage
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(usage);
}
match envelope.request {
Request::List { query } => {
let targets = shared
@@ -465,6 +689,10 @@ fn respond(
})?;
Ok(Reply::Exit { code })
}
Request::ExecInTerminal { host, command } => {
let target = find(shared, &host)?;
run_in_terminal(shared, writer, &target.info.id, command)
}
Request::Upload {
host,
source,
@@ -497,8 +725,220 @@ fn respond(
})
.map(Reply::TransferDone)
}
Request::Sync {
host,
source,
destination,
delete,
} => {
let target = find(shared, &host)?;
if !source.is_absolute() {
return Err(not_absolute(&source));
}
shared
.backend
.sync(&target, &source, &destination, delete, &mut |counters| {
write_json(writer, &Reply::Progress(counters))
})
.map(Reply::TransferDone)
}
Request::ShowHost { host } => {
let target = find(shared, &host)?;
let saved = |secret: &SecretRef| shared.backend.is_saved(secret);
Ok(Reply::Host(with_saved_passwords(
target.details,
&target.secrets,
saved,
)))
}
Request::ListCredentials { query } => {
let credentials = shared
.credentials
.read()
.unwrap_or_else(|error| error.into_inner())
.iter()
.filter(|credential| {
query
.as_deref()
.is_none_or(|query| credential_matches(&credential.details, query))
})
.map(|credential| credential.details.clone())
.collect();
Ok(Reply::Credentials { credentials })
}
Request::ShowCredential { credential } => {
let found = shared
.credentials
.read()
.unwrap_or_else(|error| error.into_inner())
.iter()
.find(|known| known.details.id == credential.trim())
.cloned()
.ok_or_else(|| credential_not_found(&credential))?;
let saved = |secret: &SecretRef| shared.backend.is_saved(secret);
Ok(Reply::Credential(with_saved_credential_secrets(
found.details,
&found.secrets,
saved,
)))
}
Request::CreateHost { fields } => change(shared, CliChange::CreateHost(fields)),
Request::UpdateHost { host, fields } => {
change(shared, CliChange::UpdateHost { host, fields })
}
Request::DeleteHost { host, force } => {
change(shared, CliChange::DeleteHost { host, force })
}
Request::CreateCredential { fields } => change(shared, CliChange::CreateCredential(fields)),
Request::UpdateCredential { credential, fields } => {
change(shared, CliChange::UpdateCredential { credential, fields })
}
Request::DeleteCredential { credential } => {
change(shared, CliChange::DeleteCredential { credential })
}
// Answered above, before anything was checked.
Request::Activate => Ok(Reply::Activated),
Request::Activate { .. } => Ok(Reply::Activated),
}
}
/// Hand a change to the app and wait for its outcome. Taken back, so
/// certainly not made, when the app does not take it up in time.
fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
let gone = || {
CliError::new(
ErrorCode::ConnectFailed,
t!("cli.server.closed_during_change"),
)
};
let (sender, outcome) = mpsc::channel();
let id = shared.next_change.fetch_add(1, Ordering::Relaxed);
shared
.changes
.lock()
.unwrap_or_else(|error| error.into_inner())
.push_back(PendingChange {
id,
change,
reply: ChangeReply(sender),
});
match outcome.recv_timeout(QUEUE_TIMEOUT) {
Ok(outcome) => return outcome,
Err(mpsc::RecvTimeoutError::Disconnected) => return Err(gone()),
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
{
let mut changes = shared
.changes
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(index) = changes.iter().position(|pending| pending.id == id) {
changes.remove(index);
return Err(CliError::new(
ErrorCode::ConnectFailed,
t!("cli.server.change_timed_out"),
));
}
}
// Taken up just now: being made.
outcome.recv().unwrap_or_else(|_| Err(gone()))
}
/// Hand a run to the app, which finds the host's terminal, and see it
/// through: the command's output as stdout, its context, its exit code.
/// Taken back, so certainly not typed, when the app does not take it up
/// in time. While the command prints nothing, an empty frame now and then
/// finds out whether the caller is still there; once it is not, the
/// command gets a ^C.
fn run_in_terminal(
shared: &Shared,
writer: &mut impl io::Write,
host: &str,
command: String,
) -> Result<Reply, CliError> {
let (run, events) = TerminalRun::new(command);
let id = shared.next_change.fetch_add(1, Ordering::Relaxed);
shared
.runs
.lock()
.unwrap_or_else(|error| error.into_inner())
.push_back(PendingRun {
id,
host: host.to_owned(),
run: run.handle(),
});
let first = match events.recv_timeout(QUEUE_TIMEOUT) {
Ok(event) => event,
Err(_) => {
let mut runs = shared
.runs
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(index) = runs.iter().position(|pending| pending.id == id) {
runs.remove(index);
return Err(CliError::new(
ErrorCode::ConnectFailed,
t!("cli.terminal.timed_out"),
));
}
drop(runs);
// Taken up just now: its answer is on the way.
events
.recv()
.map_err(|_| run_error(RunFailure::NotConnected, host))?
}
};
if let RunEvent::Failed(failure) = first {
return Err(run_error(failure, host));
}
let code = run
.drive(&events, RUN_TIMING, &mut |progress| match progress {
RunProgress::Context(context) => write_json(
writer,
&Reply::Context(TerminalContext {
user: context.user,
host: context.host,
cwd: context.cwd,
shell: context.shell,
interp: context.interp,
}),
),
RunProgress::Output(bytes) => write_frame(writer, FrameKind::Stdout, &bytes),
RunProgress::Idle => write_frame(writer, FrameKind::Stdout, &[]),
})
.map_err(|failure| run_error(failure, host))?;
Ok(Reply::Exit { code })
}
/// Why a run did not run, in the CLI's words.
fn run_error(failure: RunFailure, host: &str) -> CliError {
let busy = |message| CliError::new(ErrorCode::TerminalBusy, message);
match failure {
RunFailure::HostNotFound => host_not_found(host),
RunFailure::NoTerminal => {
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.no_terminal"))
}
RunFailure::NotConnected => {
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.not_connected"))
}
RunFailure::UnsupportedShell(shell) if shell.is_empty() => CliError::new(
ErrorCode::UnsupportedShell,
t!("cli.terminal.unknown_shell"),
),
RunFailure::UnsupportedShell(shell) => CliError::new(
ErrorCode::UnsupportedShell,
t!("cli.terminal.unsupported_shell", shell = shell),
),
RunFailure::TooLong { limit } => CliError::new(
ErrorCode::TooLong,
t!("cli.terminal.too_long", limit = limit),
),
RunFailure::Busy(BusyReason::FullScreen) => busy(t!("cli.terminal.full_screen")),
RunFailure::Busy(BusyReason::AnotherRun) => busy(t!("cli.terminal.another_run")),
RunFailure::Busy(BusyReason::NoAnswer) => busy(t!("cli.terminal.no_answer")),
RunFailure::Busy(BusyReason::NotStarted) => busy(t!("cli.terminal.not_started")),
RunFailure::Disconnected => {
CliError::new(ErrorCode::ConnectFailed, t!("cli.terminal.disconnected"))
}
}
}
@@ -510,17 +950,12 @@ fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
.iter()
.find(|target| target.info.id == id.trim())
.cloned()
.ok_or_else(|| {
CliError::new(
ErrorCode::HostNotFound,
format!("没有 ID 为 {id} 的主机:请用 shellrs list 查看"),
)
})
.ok_or_else(|| host_not_found(id))
}
fn not_absolute(path: &Path) -> CliError {
CliError::new(
ErrorCode::BadRequest,
format!("本地路径必须是绝对路径:{}", path.display()),
t!("cli.server.not_absolute", path = path.display()),
)
}
+810 -23
View File
@@ -9,14 +9,25 @@ use std::{
use clap::Parser as _;
use super::client::ascii_json;
use super::client::{self, Console};
use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
use super::server::{CliBackend, CliServer, CliTarget};
use super::{Cli, Command};
use super::{ConsoleText, normalize_command};
use super::link::OpenLink;
use super::manage::CliChange;
use super::protocol::{
AuthChoice, CliError, CredentialDetails, ErrorCode, HostDetails, HostFields, HostInfo, Reply,
Request, RouteDetails, Secret, TransferCounters, TransferSummary,
};
use super::server::{CliBackend, CliServer, CliTarget, CliUse};
use super::{Cli, Command, CredentialsCommand, HostsCommand};
use super::{ConsoleText, ExecJson, exec_request, normalize_command};
use crate::app::cli_endpoint;
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin};
use crate::host::{
AuthKind, CredentialDraft, CredentialKind, GroupDraft, Host, HostDraft, HostId, HostStore,
SshLink,
};
use crate::secrets::SecretRef;
use crate::ssh::ExecStream;
use crate::terminal::{RunFailure, RunHandle, TerminalTransportCommand};
/// Records what it was asked and answers from a script.
#[derive(Default)]
@@ -67,6 +78,7 @@ impl CliBackend for FakeBackend {
bytes: 20,
skipped: 0,
failed: 1,
deleted: 0,
failures: vec!["无法上传:permission denied".into()],
})
}
@@ -80,13 +92,47 @@ impl CliBackend for FakeBackend {
) -> Result<TransferSummary, CliError> {
Ok(TransferSummary::default())
}
fn sync(
&self,
target: &CliTarget,
source: &Path,
destination: &str,
delete: bool,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
self.calls.lock().unwrap().push(format!(
"sync {} {} {destination} {delete}",
target.host().name,
source.display()
));
Ok(TransferSummary {
files: 2,
bytes: 2048,
skipped: 40,
failed: 0,
deleted: 3,
failures: Vec::new(),
})
}
/// Only a host's own password, of what the fixture has.
fn is_saved(&self, secret: &SecretRef) -> bool {
matches!(secret, SecretRef::Password { user, .. } if user == "root")
}
}
fn host(id: u64, name: &str, host: &str) -> Host {
Host::new(
HostId(id),
HostDraft::new(name, host, 22, "root", AuthKind::Password, Some(GroupId(1))),
)
/// web-01 in 生产 and db-01 in 生产/数据库.
fn store() -> HostStore {
let mut store = HostStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let database = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
let draft = |name: &str, address: &str, group| {
HostDraft::new(name, address, 22, "root", AuthKind::Password, Some(group))
};
store.insert_unnotified(draft("web-01", "10.0.1.12", production));
store.insert_unnotified(draft("db-01", "10.0.2.5", database));
store
}
struct Fixture {
@@ -102,12 +148,9 @@ fn fixture() -> Fixture {
let socket = cli_endpoint(dir.path());
let backend = Arc::new(FakeBackend::default());
let server = CliServer::start(socket.clone(), backend.clone()).unwrap();
let web = host(1, "web-01", "10.0.1.12");
let db = host(2, "db-01", "10.0.2.5");
server.set_targets(vec![
CliTarget::new(&web, HostLogin::of(&web, None), Some("生产".into())),
CliTarget::new(&db, HostLogin::of(&db, None), Some("生产/数据库".into())),
]);
let store = store();
let web = store.hosts()[0].clone();
server.set_hosts(&store);
server.set_enabled(true);
Fixture {
_dir: dir,
@@ -138,6 +181,7 @@ fn run_on(
stdout: &mut stdout,
stderr: &mut stderr,
json,
exec_json: false,
stderr_is_terminal,
},
);
@@ -310,6 +354,8 @@ fn without_the_app_the_command_says_it_is_not_running() {
#[cfg(unix)]
{
let stale = dir.path().join("stale.sock");
// No process may start meanwhile and keep the socket open.
let _forks = crate::testing::no_forks();
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
let (code, _, stderr) = run(&stale, Request::List { query: None }, true);
assert_eq!(code, 255);
@@ -321,17 +367,38 @@ fn without_the_app_the_command_says_it_is_not_running() {
}
}
#[test]
fn the_kinds_of_command_served_are_counted_and_nothing_else() {
let fixture = fixture();
run(&fixture.socket, Request::List { query: None }, true);
run(
&fixture.socket,
Request::Exec {
host: id(&fixture.web),
command: "uname -a".into(),
},
false,
);
assert_eq!(fixture.server.take_usage(), [CliUse::Hosts, CliUse::Exec]);
assert_eq!(fixture.server.take_usage(), []);
// Refused, or ShellRS opened again: not a command served.
fixture.server.set_enabled(false);
run(&fixture.socket, Request::List { query: None }, true);
assert!(client::activate_running_app(&fixture.socket, None));
assert_eq!(fixture.server.take_usage(), []);
}
#[test]
fn opening_shellrs_again_brings_the_running_one_forward() {
let fixture = fixture();
// Whatever 启用外部 CLI says: this is not the external CLI.
fixture.server.set_enabled(false);
assert!(!fixture.server.take_activation());
assert_eq!(fixture.server.take_activation(), None);
assert!(client::activate_running_app(&fixture.socket));
assert!(fixture.server.take_activation());
assert!(client::activate_running_app(&fixture.socket, None));
assert_eq!(fixture.server.take_activation(), Some(Vec::new()));
// Heard once, acted on once.
assert!(!fixture.server.take_activation());
assert_eq!(fixture.server.take_activation(), None);
assert!(fixture.backend.calls.lock().unwrap().is_empty());
// The switch still guards everything else.
@@ -342,20 +409,110 @@ fn opening_shellrs_again_brings_the_running_one_forward() {
.unwrap()
.starts_with("shellrs: [not_enabled]")
);
assert!(!fixture.server.take_activation());
assert_eq!(fixture.server.take_activation(), None);
}
#[test]
fn opening_shellrs_with_links_hands_them_to_the_running_one_in_order() {
let fixture = fixture();
fixture.server.set_enabled(false);
let link = |url: &str, tab: Option<&str>| OpenLink {
url: url.into(),
tab: tab.map(str::to_string),
};
let first = link("ssh://token:p%40ss@172.16.0.28:12024", Some("堡垒机"));
let second = link("ssh://root@10.0.0.9", None);
assert!(client::activate_running_app(
&fixture.socket,
Some(first.clone())
));
assert!(client::activate_running_app(&fixture.socket, None));
assert!(client::activate_running_app(
&fixture.socket,
Some(second.clone())
));
assert_eq!(fixture.server.take_activation(), Some(vec![first, second]));
assert_eq!(fixture.server.take_activation(), None);
}
#[test]
fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
let mut store = HostStore::empty();
let saved = store.insert_unnotified(HostDraft::new(
"web",
"10.0.1.12",
22,
"root",
AuthKind::Password,
None,
));
let link = store.insert_external_unnotified(
SshLink::parse("ssh://token:secret@10.0.0.9:2222", Some("堡垒机")).unwrap(),
);
let targets = CliTarget::all(&store);
let ids: Vec<HostId> = targets.iter().map(|target| target.host().id).collect();
assert_eq!(ids, [saved, link]);
// With the link's password, which only memory holds.
assert_eq!(targets[1].login().password, SecretRef::temporary(link.0));
let fixture = fixture();
fixture.server.set_hosts(&store);
let (code, stdout, _) = run(&fixture.socket, Request::List { query: None }, true);
assert_eq!(code, 0);
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
let listed: Vec<(&str, bool)> = hosts
.iter()
.map(|host| (host.name.as_str(), host.temporary))
.collect();
assert_eq!(listed, [("web", false), ("堡垒机", true)]);
let temporary = store.host(link).unwrap().public_id.to_string();
assert_eq!(hosts[1].id, temporary);
assert_eq!(hosts[1].address(), "token@10.0.0.9:2222");
// Reached by that ID like a saved host.
let (code, _, _) = run(
&fixture.socket,
Request::Exec {
host: temporary,
command: "uptime".into(),
},
false,
);
assert_eq!(code, 3);
// A table says so where the group goes.
let (_, table, _) = run(&fixture.socket, Request::List { query: None }, false);
assert!(
table.lines().nth(2).unwrap().contains("(未保存)"),
"{table}"
);
}
#[test]
fn an_older_apps_list_reads_as_saved_hosts() {
let host: HostInfo = serde_json::from_str(
r#"{"id":"VmLkf1snMOuPKJ07","name":"web","group":null,"user":"root","host":"10.0.1.12","port":22,"os":null}"#,
)
.unwrap();
assert!(!host.temporary);
}
#[test]
fn with_nothing_running_shellrs_is_free_to_start() {
let dir = tempfile::tempdir().unwrap();
assert!(!client::activate_running_app(&cli_endpoint(dir.path())));
assert!(!client::activate_running_app(
&cli_endpoint(dir.path()),
None
));
// Nor does a socket left behind by a crash count as a running app.
#[cfg(unix)]
{
let stale = dir.path().join("stale.sock");
// No process may start meanwhile and keep the socket open.
let _forks = crate::testing::no_forks();
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
assert!(!client::activate_running_app(&stale));
assert!(!client::activate_running_app(&stale, None));
}
}
@@ -461,8 +618,34 @@ fn exec_takes_its_command_as_one_argument_or_from_stdin() {
// Neither, or both.
assert!(parse(&["shellrs", "exec", "ID"]).is_err());
assert!(parse(&["shellrs", "exec", "ID", "ls", "--stdin"]).is_err());
// JSON brings the host and the command with it, and nothing else does.
assert!(matches!(
parse(&["shellrs", "exec", "--json"]),
Ok(Command::Exec {
id: None,
command: None,
json: true,
..
})
));
assert!(parse(&["shellrs", "exec"]).is_err());
assert!(parse(&["shellrs", "exec", "ID", "--json"]).is_err());
assert!(parse(&["shellrs", "exec", "--json", "--stdin"]).is_err());
// Split words are a mistake, not a longer command.
assert!(parse(&["shellrs", "exec", "ID", "ls", "-la"]).is_err());
// Typed into the open terminal, in any of the forms.
assert!(matches!(
parse(&["shellrs", "exec", "ID", "--terminal", "ls"]),
Ok(Command::Exec { terminal: true, command: Some(command), .. }) if command == "ls"
));
assert!(matches!(
parse(&["shellrs", "exec", "--json", "--terminal"]),
Ok(Command::Exec {
terminal: true,
json: true,
..
})
));
}
#[test]
@@ -508,3 +691,607 @@ fn a_windows_console_gets_text_even_from_bytes_that_are_not_utf8() {
raw.finish().unwrap();
assert_eq!(raw.inner, b"\xff\xd6");
}
#[test]
fn exec_json_reads_the_host_and_the_command() {
let asked = |host: &str, command: &str, terminal| ExecJson {
host: host.into(),
command: command.into(),
terminal,
};
assert_eq!(
exec_request("\u{feff}{\"host\": \"ID\", \"command\": \"echo \\\"hi\\\" | wc -c\"}"),
Ok(asked("ID", "echo \"hi\" | wc -c", false))
);
// Escaped as an agent may write it in PowerShell 5.1, which pipes ASCII.
assert_eq!(
exec_request(r#"{"host":"ID","command":"grep \u751f\u4ea7 a.log"}"#),
Ok(asked("ID", "grep 生产 a.log", false))
);
assert_eq!(
exec_request(r#"{"host":"ID","command":"ls","terminal":true}"#),
Ok(asked("ID", "ls", true))
);
assert!(exec_request(r#"{"command":"ls"}"#).is_err());
assert!(exec_request("ls -la").is_err());
}
#[test]
fn exec_json_output_is_ascii_whatever_it_holds() {
assert_eq!(
ascii_json(&serde_json::json!({ "s": "生产 😀\n" })),
r#"{"s":"\u751f\u4ea7 \ud83d\ude00\n"}"#
);
}
/// `exec --json` against the fixture: the exit code, and stdout parsed.
fn run_exec_json(socket: &Path, request: Request) -> (i32, serde_json::Value, Vec<u8>) {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = client::run(
socket,
request,
&mut Console {
stdout: &mut stdout,
stderr: &mut stderr,
json: true,
exec_json: true,
stderr_is_terminal: false,
},
);
assert!(stdout.is_ascii(), "{}", String::from_utf8_lossy(&stdout));
(code, serde_json::from_slice(&stdout).unwrap(), stderr)
}
#[test]
fn exec_json_gathers_the_output_and_says_errors_in_json() {
let fixture = fixture();
let (code, result, stderr) = run_exec_json(
&fixture.socket,
Request::Exec {
host: id(&fixture.web),
command: "uname -a".into(),
},
);
assert_eq!(code, 3);
assert_eq!(
result,
serde_json::json!({ "exit_code": 3, "stdout": "out\n", "stderr": "\u{fffd}\n" })
);
assert!(stderr.is_empty());
fixture.server.set_enabled(false);
let (code, result, stderr) = run_exec_json(
&fixture.socket,
Request::Exec {
host: id(&fixture.web),
command: "true".into(),
},
);
assert_eq!(code, 255);
assert_eq!(result["error"]["code"], "not_enabled");
assert!(
result["error"]["message"]
.as_str()
.unwrap()
.contains("设置 → 外部 CLI")
);
assert!(stderr.is_empty());
}
#[test]
fn the_new_commands_read_their_arguments() {
let parse = |args: &[&str]| Cli::try_parse_from(args).map(|cli| cli.command);
assert!(matches!(
parse(&["shellrs", "hosts", "show", "ID", "--json"]),
Ok(Command::Hosts { command: HostsCommand::Show { id, json: true } }) if id == "ID"
));
assert!(matches!(
parse(&["shellrs", "hosts", "delete", "ID", "--force"]),
Ok(Command::Hosts {
command: HostsCommand::Delete { force: true, .. }
})
));
assert!(matches!(
parse(&["shellrs", "credentials", "list", "-q", "deploy"]),
Ok(Command::Credentials { command: CredentialsCommand::List { query: Some(query), .. } })
if query == "deploy"
));
assert!(matches!(
parse(&["shellrs", "sync", "ID", "./dist", "/srv/app", "--delete"]),
Ok(Command::Sync { delete: true, remote, .. }) if remote == "/srv/app"
));
// Hosts are listed under `hosts`, like the rest of what is done to them.
assert!(matches!(
parse(&["shellrs", "hosts", "list", "-q", "web"]),
Ok(Command::Hosts { command: HostsCommand::List { query: Some(query), .. } })
if query == "web"
));
assert!(parse(&["shellrs", "list"]).is_err());
// `credentials` has no --force: nothing of it stays open.
assert!(parse(&["shellrs", "credentials", "delete", "ID", "--force"]).is_err());
assert!(parse(&["shellrs", "sync", "ID", "./dist"]).is_err());
}
fn saved_host(name: &str) -> HostDetails {
HostDetails {
id: "Jwg5rHvXCxw89paM".into(),
name: name.into(),
group: None,
host: "10.0.0.9".into(),
port: 22,
user: "root".into(),
auth: AuthChoice::Password,
credential: None,
password_saved: Some(true),
route: RouteDetails::Direct,
notes: String::new(),
os: None,
temporary: false,
}
}
/// The app's end of a change: wait for it to come in, and hand it over
/// with what to answer it with.
fn take_change(server: &CliServer) -> (CliChange, super::server::ChangeReply) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Some(taken) = server.take_change() {
return taken;
}
assert!(std::time::Instant::now() < deadline, "no change came in");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[test]
fn a_change_waits_for_the_app_and_comes_back_as_it_made_it() {
let fixture = fixture();
let socket = fixture.socket.clone();
let fields = HostFields {
name: Some("db-02".into()),
host: Some("10.0.0.9".into()),
password: Some(Some(Secret::new("hunter2"))),
..HostFields::default()
};
let command = std::thread::spawn(move || run(&socket, Request::CreateHost { fields }, false));
let (change, reply) = take_change(&fixture.server);
let CliChange::CreateHost(fields) = change else {
panic!("{change:?}");
};
// The password is there to be saved, and nowhere it could be printed.
assert_eq!(
fields
.password
.as_ref()
.map(|password| password.as_ref().map(Secret::expose)),
Some(Some("hunter2"))
);
assert!(!format!("{fields:?}").contains("hunter2"));
reply.send(Ok(Reply::Host(saved_host("db-02"))));
let (code, stdout, _) = command.join().unwrap();
assert_eq!(code, 0);
assert_eq!(stdout, "已创建主机「db-02」,ID Jwg5rHvXCxw89paM\n");
// What went wrong, said the way every error is.
let socket = fixture.socket.clone();
let command = std::thread::spawn(move || {
run(
&socket,
Request::DeleteHost {
host: "x".into(),
force: false,
},
false,
)
});
let (_, reply) = take_change(&fixture.server);
reply.send(Err(CliError::new(
ErrorCode::HostInUse,
"主机「x」有打开的标签",
)));
let (code, _, stderr) = command.join().unwrap();
assert_eq!(code, 255);
assert_eq!(
String::from_utf8(stderr).unwrap(),
"shellrs: [host_in_use] 主机「x」有打开的标签\n"
);
}
#[test]
fn a_change_nobody_takes_up_is_taken_back_and_one_dropped_says_so() {
let fixture = fixture();
// The tests' wait in line is short; nothing takes it up.
let (code, _, stderr) = run(
&fixture.socket,
Request::DeleteCredential {
credential: "x".into(),
},
false,
);
assert_eq!(code, 255);
let stderr = String::from_utf8(stderr).unwrap();
assert!(stderr.contains("改动没有做"), "{stderr}");
assert!(fixture.server.take_change().is_none(), "taken back");
// Taken up, then the app goes without an answer.
let socket = fixture.socket.clone();
let command = std::thread::spawn(move || {
run(
&socket,
Request::DeleteCredential {
credential: "x".into(),
},
false,
)
});
let (_, reply) = take_change(&fixture.server);
drop(reply);
let (code, _, stderr) = command.join().unwrap();
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.contains("ShellRS 在处理改动时关闭了")
);
}
#[test]
fn with_the_switch_off_no_change_reaches_the_app() {
let fixture = fixture();
fixture.server.set_enabled(false);
let (code, _, stderr) = run(
&fixture.socket,
Request::CreateHost {
fields: HostFields::default(),
},
false,
);
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.starts_with("shellrs: [not_enabled]")
);
assert!(fixture.server.take_change().is_none());
}
#[test]
fn showing_a_host_or_a_credential_says_what_is_saved_and_never_what() {
let fixture = fixture();
let mut store = store();
let credential = store.insert_credential_unnotified(CredentialDraft::new(
"部署",
CredentialKind::Password,
"deploy",
));
let keychain_id = store
.credential(credential)
.unwrap()
.keychain_id
.to_string();
let db = store.hosts()[1].id;
let draft = store.host(db).unwrap().draft().with_credential(credential);
store.update_unnotified(db, draft);
fixture.server.set_hosts(&store);
let web_host = store.hosts()[0].clone();
let show = |host: &Host| {
let (code, stdout, _) = run(&fixture.socket, Request::ShowHost { host: id(host) }, true);
assert_eq!(code, 0);
serde_json::from_str::<HostDetails>(&stdout).unwrap()
};
let web = show(&web_host);
assert_eq!(
(web.auth, web.password_saved),
(AuthChoice::Password, Some(true))
);
assert_eq!(web.group.as_deref(), Some("生产"));
let db = show(store.host(db).unwrap());
assert_eq!(db.auth, AuthChoice::Credential);
assert_eq!(db.credential.as_deref(), Some(keychain_id.as_str()));
assert_eq!((db.user.as_str(), db.password_saved), ("deploy", None));
let (_, table, _) = run(
&fixture.socket,
Request::ShowHost {
host: id(&web_host),
},
false,
);
assert!(table.contains("认证 密码(已保存)"), "{table}");
assert!(table.contains("连接方式 直接连接"), "{table}");
let (code, stdout, _) = run(
&fixture.socket,
Request::ListCredentials {
query: Some("DEPLOY".into()),
},
true,
);
assert_eq!(code, 0);
let listed: Vec<CredentialDetails> = serde_json::from_str(&stdout).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].hosts, std::slice::from_ref(&db.id));
assert_eq!(listed[0].password_saved, None, "only looked up when shown");
let (_, stdout, _) = run(
&fixture.socket,
Request::ShowCredential {
credential: keychain_id.clone(),
},
true,
);
let shown: CredentialDetails = serde_json::from_str(&stdout).unwrap();
assert_eq!(shown.password_saved, Some(false));
let (code, _, stderr) = run(
&fixture.socket,
Request::ShowCredential {
credential: "nope".into(),
},
false,
);
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.starts_with("shellrs: [credential_not_found]")
);
}
#[test]
fn a_sync_says_what_had_not_changed_and_what_went() {
let fixture = fixture();
let (code, stdout, _) = run(
&fixture.socket,
Request::Sync {
host: id(&fixture.web),
source: local("dist"),
destination: "/srv/app".into(),
delete: true,
},
false,
);
assert_eq!(code, 0);
assert_eq!(
stdout,
"已传输 2 个文件,共 2.0 KB,未变 40 个,删除 3 个\n"
);
assert_eq!(
fixture.backend.calls.lock().unwrap().last().unwrap(),
&format!("sync web-01 {} /srv/app true", local("dist").display())
);
// Relative is the command's to resolve.
let (code, _, _) = run(
&fixture.socket,
Request::Sync {
host: id(&fixture.web),
source: "dist".into(),
destination: "/srv/app".into(),
delete: false,
},
false,
);
assert_eq!(code, 255);
}
/// What an app sees of a request it does not know, and of a version it
/// does not speak, sent the way a later command would.
#[cfg(unix)]
#[test]
fn a_request_from_a_later_command_is_told_apart_from_a_bad_one() {
use super::protocol::{parse_json, read_frame, write_json};
let fixture = fixture();
let answer = |envelope: serde_json::Value| {
let stream = std::os::unix::net::UnixStream::connect(&fixture.socket).unwrap();
write_json(&mut &stream, &envelope).unwrap();
let (_, payload) = read_frame(&mut &stream).unwrap().unwrap();
parse_json::<Reply>(&payload).unwrap()
};
let Reply::Error { code, message } =
answer(serde_json::json!({ "version": 2, "request": { "type": "teleport" } }))
else {
panic!("answered");
};
assert_eq!(code, ErrorCode::BadRequest);
// Which the command takes for an app older than itself.
assert!(message.contains("unknown variant"), "{message}");
let Reply::Error { code, .. } =
answer(serde_json::json!({ "version": 3, "request": { "type": "teleport" } }))
else {
panic!("answered");
};
assert_eq!(code, ErrorCode::VersionMismatch);
}
/// An app from before a command was added, as the command hears it.
#[cfg(unix)]
#[test]
fn an_app_that_does_not_know_the_command_is_said_to_be_older() {
use super::protocol::{read_frame, write_json};
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("old.sock");
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
let app = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
read_frame(&mut &stream).unwrap();
write_json(
&mut &stream,
&Reply::Error {
code: ErrorCode::BadRequest,
message: "unknown variant `show_host`, expected one of `list`, `exec`".into(),
},
)
.unwrap();
});
let (code, _, stderr) = run(&socket, Request::ShowHost { host: "x".into() }, false);
app.join().unwrap();
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.starts_with("shellrs: [version_mismatch] 正在运行的 ShellRS 不认识这个命令"),
);
}
/// The app's end of an `exec --terminal` run: wait for it to come in.
fn take_run(server: &CliServer) -> (String, RunHandle) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Some(taken) = server.take_run() {
return taken;
}
assert!(std::time::Instant::now() < deadline, "no run came in");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
/// A terminal taking `run` up, with a shell whose program is `shell`: it
/// answers the probe, then the command line with `output` and `code`,
/// slowly enough for the server to check on the caller meanwhile.
/// Everything typed, once nothing more is.
fn answer_run(
run: RunHandle,
shell: &'static str,
output: &'static str,
code: i32,
) -> std::thread::JoinHandle<String> {
let (input, typed) = std::sync::mpsc::channel();
assert!(run.accept(input));
std::thread::spawn(move || {
let mut all = String::new();
while let Ok(TerminalTransportCommand::Write(bytes)) =
typed.recv_timeout(std::time::Duration::from_millis(500))
{
let line = String::from_utf8(bytes).unwrap();
all.push_str(&line);
// ` sh -c '<script>' sh NONCE …`
let Some(at) = line.find("' sh ") else {
continue;
};
let nonce = &line[at + 5..at + 17];
if line.contains("6973;P;") {
run.feed(format!("\x1b]6973;P;{nonce};{shell}\x07").as_bytes());
} else if line.contains("6973;B;") {
let context: String = "deploy\nasset\n/home/deploy\nbash"
.bytes()
.map(|byte| format!("{byte:02x}"))
.collect();
run.feed(format!("\x1b]6973;B;{nonce};{context}\x07").as_bytes());
std::thread::sleep(std::time::Duration::from_millis(100));
run.feed(format!("{output}\x1b]6973;E;{nonce};{code}\x07").as_bytes());
}
}
all
})
}
#[test]
fn a_command_typed_into_the_terminal_passes_on_its_output_and_where_it_ran() {
let fixture = fixture();
let exec = |json| {
let socket = fixture.socket.clone();
let request = Request::ExecInTerminal {
host: id(&fixture.web),
command: "ls /srv".into(),
};
std::thread::spawn(move || {
if json {
let (code, result, _) = run_exec_json(&socket, request);
(code, result.to_string())
} else {
let (code, stdout, _) = run(&socket, request, false);
(code, stdout)
}
})
};
let command = exec(false);
let (host, run) = take_run(&fixture.server);
assert_eq!(host, id(&fixture.web));
let terminal = answer_run(run, "/usr/bin/bash", "a\r\nb\r\n", 2);
assert_eq!(command.join().unwrap(), (2, "a\nb\n".to_string()));
assert!(terminal.join().unwrap().contains(" 'ls /srv'\r"));
let command = exec(true);
let (_, run) = take_run(&fixture.server);
answer_run(run, "/bin/busybox", "a\r\n", 0);
let (code, result) = command.join().unwrap();
assert_eq!(code, 0);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!({
"exit_code": 0,
"stdout": "a\n",
"stderr": "",
"context": {
"user": "deploy",
"host": "asset",
"cwd": "/home/deploy",
"shell": "busybox",
"interp": "bash",
},
})
);
assert_eq!(
fixture.server.take_usage(),
[CliUse::ExecTerminal, CliUse::ExecTerminal]
);
}
#[test]
fn a_run_that_cannot_be_typed_says_why() {
let fixture = fixture();
let exec = || {
let socket = fixture.socket.clone();
let host = id(&fixture.web);
std::thread::spawn(move || {
let request = Request::ExecInTerminal {
host,
command: "true".into(),
};
let (code, _, stderr) = run(&socket, request, false);
(code, String::from_utf8(stderr).unwrap())
})
};
let refused = |command: std::thread::JoinHandle<(i32, String)>, code: &str| {
let (exit, stderr) = command.join().unwrap();
assert_eq!(exit, 255);
assert!(
stderr.starts_with(&format!("shellrs: [{code}] ")),
"{stderr}"
);
stderr
};
// No terminal for it.
let command = exec();
take_run(&fixture.server).1.fail(RunFailure::NoTerminal);
refused(command, "no_terminal");
// A shell it cannot type into: nothing but the probe was typed.
let command = exec();
let terminal = answer_run(take_run(&fixture.server).1, "/usr/bin/zsh", "", 0);
assert!(refused(command, "unsupported_shell").contains("zsh"));
assert!(!terminal.join().unwrap().contains("6973;B;"));
// Nothing answers the probe.
let command = exec();
let (input, _typed) = std::sync::mpsc::channel();
assert!(take_run(&fixture.server).1.accept(input));
refused(command, "terminal_busy");
// Nobody takes it up: taken back, never typed.
let command = exec();
refused(command, "connect_failed");
assert!(fixture.server.take_run().is_none());
// An unknown host never gets to the app.
let socket = fixture.socket.clone();
let request = Request::ExecInTerminal {
host: "nope".into(),
command: "true".into(),
};
assert_eq!(run(&socket, request, false).0, 255);
assert!(fixture.server.take_run().is_none());
}
+75 -21
View File
@@ -4,6 +4,8 @@ use std::time::Duration;
use zeroize::Zeroizing;
use crate::i18n::t;
/// One field requested by an SSH keyboard-interactive challenge.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConnectionPromptField {
@@ -72,24 +74,30 @@ impl UnknownHostPrompt {
/// What the trust dialog tells the user about the key.
pub fn description(&self) -> String {
format!(
"{}主机:{}:{}\n算法:{}\nSHA-256 指纹:{}\n\n请先确认该指纹来自可信渠道。",
jump_host_line(self.jump_host.as_deref()),
self.host,
self.port,
self.algorithm,
self.fingerprint,
)
let (host, port, algorithm, fingerprint) =
(&self.host, self.port, &self.algorithm, &self.fingerprint);
match &self.jump_host {
// A line above the rest names the jump host.
Some(jump_host) => t!(
"connection.unknown_host.jump_host_description",
jump_host = jump_host,
host = host,
port = port,
algorithm = algorithm,
fingerprint = fingerprint
),
None => t!(
"connection.unknown_host.description",
host = host,
port = port,
algorithm = algorithm,
fingerprint = fingerprint
),
}
.to_string()
}
}
/// The line that opens a question about a jump host, naming it.
fn jump_host_line(jump_host: Option<&str>) -> String {
jump_host
.map(|name| format!("跳板主机:{name}\n"))
.unwrap_or_default()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostKeyChangedPrompt {
host: String,
@@ -203,10 +211,15 @@ impl ConnectionPromptKind {
Self::HostKeyChanged(prompt) => prompt.jump_host = Some(name.to_string()),
Self::Authentication(prompt) => {
prompt.instructions = if prompt.instructions.trim().is_empty() {
format!("跳板主机「{name}」")
t!("connection.jump_host.instructions", name = name)
} else {
format!("跳板主机「{name}」:{}", prompt.instructions)
};
t!(
"connection.jump_host.with_instructions",
name = name,
instructions = prompt.instructions
)
}
.to_string();
}
}
self
@@ -230,7 +243,7 @@ impl ConnectionSecret {
impl std::fmt::Debug for ConnectionSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ConnectionSecret([已隐藏])")
formatter.write_str("ConnectionSecret(<redacted>)")
}
}
@@ -248,7 +261,7 @@ impl std::fmt::Debug for ConnectionPromptReply {
Self::TrustAndSave => formatter.write_str("TrustAndSave"),
Self::Answers(answers) => formatter
.debug_tuple("Answers")
.field(&format_args!("{} 个已隐藏答案", answers.len()))
.field(&format_args!("{} redacted", answers.len()))
.finish(),
Self::Cancel => formatter.write_str("Cancel"),
}
@@ -319,6 +332,17 @@ impl LoginTest {
pub fn login(&self) -> &crate::host::HostLogin {
&self.login
}
/// A login by password typed into the form with the password left
/// empty. 测试连接 then goes without one, as 「无密码」 would, rather than
/// stop at the missing password (the user's call).
pub fn password_left_empty(&self) -> bool {
self.login.method == crate::host::LoginMethod::Password
&& self
.typed
.as_ref()
.is_some_and(|typed| typed.password.is_none())
}
pub fn host(&self) -> &str {
&self.login.host
}
@@ -407,7 +431,7 @@ impl Latency {
pub fn label(self) -> String {
match self {
Latency::Measured(rtt) => format!("{} ms", rtt.as_millis()),
Latency::TimedOut => "超时".into(),
Latency::TimedOut => t!("connection.latency.timed_out").to_string(),
}
}
}
@@ -440,3 +464,33 @@ mod latency_tests {
assert_eq!(Latency::TimedOut.label(), "超时");
}
}
#[cfg(test)]
mod prompt_tests {
use super::ConnectionPromptKind;
fn description(jump_host: Option<&str>) -> String {
match ConnectionPromptKind::unknown_host("10.0.0.5", 22, "ssh-ed25519", "SHA256:abc")
.at_jump_host(jump_host)
{
ConnectionPromptKind::UnknownHost(prompt) => prompt.description(),
_ => unreachable!("an unknown host stays one"),
}
}
#[test]
fn a_new_host_key_is_described_line_by_line() {
assert_eq!(
description(Some("堡垒机")),
"跳板主机:堡垒机\n主机:10.0.0.5:22\n算法:ssh-ed25519\n\
SHA-256 指纹:SHA256:abc\n\n请先确认该指纹来自可信渠道。"
);
crate::i18n::isolate_thread();
crate::i18n::set_locale("en");
assert_eq!(
description(None),
"Host: 10.0.0.5:22\nAlgorithm: ssh-ed25519\nSHA-256 fingerprint: SHA256:abc\n\n\
Make sure the fingerprint matches one from a source you trust."
);
}
}
+101 -75
View File
@@ -15,23 +15,31 @@ use zeroize::Zeroizing;
use crate::host::{
CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, GeneratedKey, HostStore,
KeyAlgorithm, PastedKey, PastedKeyError, SecretFields, read_public_key,
KeyAlgorithm, PastedKey, PastedKeyError, SecretFields, join_sentences, read_public_key,
};
use crate::i18n::{t, tn};
use crate::secrets::SecretRef;
use crate::shared::{
Segment, SegmentedControl, commit_footer, dismiss_form_error, form_error_notification,
};
/// Where the SSH agent is found, as the form explains it.
#[cfg(windows)]
const AGENT_NOTE: &str = "登录时使用 Windows 的 OpenSSH Authentication Agent 服务中的密钥。";
#[cfg(not(windows))]
const AGENT_NOTE: &str = "登录时使用 SSH Agent(SSH_AUTH_SOCK 所指)中的密钥。";
fn agent_note() -> SharedString {
if cfg!(windows) {
t!("credential.dialog.agent_note_windows")
} else {
t!("credential.dialog.agent_note")
}
}
/// What an empty passphrase field means for a key that already exists, and
/// for one about to be generated.
const ASK_FOR_PASSPHRASE: &str = "留空则每次连接都询问";
const NO_PASSPHRASE: &str = "留空则不加密";
fn passphrase_placeholder(source: KeySource) -> SharedString {
match source {
KeySource::Generate => t!("credential.dialog.no_passphrase"),
KeySource::File | KeySource::Paste => t!("credential.dialog.ask_for_passphrase"),
}
}
/// Which credential dialog to open.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -57,11 +65,11 @@ enum KeySource {
impl KeySource {
const ALL: [KeySource; 3] = [KeySource::File, KeySource::Paste, KeySource::Generate];
fn label(self) -> &'static str {
fn label(self) -> SharedString {
match self {
KeySource::File => "本机文件",
KeySource::Paste => "粘贴",
KeySource::Generate => "生成新密钥",
KeySource::File => t!("credential.key_source.file"),
KeySource::Paste => t!("credential.key_source.paste"),
KeySource::Generate => t!("credential.key_source.generate"),
}
}
}
@@ -171,7 +179,7 @@ impl CredentialForm {
let name = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("例如 生产环境 root")
.placeholder(t!("credential.dialog.name_placeholder"))
.default_value(draft.name.clone())
});
let user = cx.new(|cx| {
@@ -192,13 +200,13 @@ impl CredentialForm {
}
if source == KeySource::Generate {
fields.update(cx, |fields, cx| {
fields.set_passphrase_placeholder(NO_PASSPHRASE, window, cx)
fields.set_passphrase_placeholder(passphrase_placeholder(source), window, cx)
});
}
let key_text = cx.new(|cx| {
TextareaState::new(window, cx)
.auto_grow(4, 8)
.placeholder("粘贴私钥,以 -----BEGIN 开头")
.placeholder(t!("credential.dialog.key_text_placeholder"))
});
let subscriptions = vec![
cx.observe(&fields, |this, _, cx| {
@@ -266,13 +274,9 @@ impl CredentialForm {
self.source = source;
// A passphrase belongs to one key; whatever the field held was for
// the key the form showed before.
let placeholder = match source {
KeySource::Generate => NO_PASSPHRASE,
KeySource::File | KeySource::Paste => ASK_FOR_PASSPHRASE,
};
self.fields.update(cx, |fields, cx| {
fields.clear_passphrase(window, cx);
fields.set_passphrase_placeholder(placeholder, window, cx);
fields.set_passphrase_placeholder(passphrase_placeholder(source), window, cx);
});
if source == KeySource::Generate {
self.generate(cx);
@@ -315,7 +319,9 @@ impl CredentialForm {
}
let generated = match made {
Ok(key) => Generated::Ready(Arc::new(key)),
Err(error) => Generated::Failed(format!("无法生成密钥:{error}").into()),
Err(error) => {
Generated::Failed(t!("credential.dialog.generate_failed", error = error))
}
};
this.generated = Some((algorithm, generated));
cx.notify();
@@ -379,7 +385,8 @@ impl CredentialForm {
// A pasted or generated key's file is only written once everything
// else checks out; until then the draft stands in for it.
let key_path = if key_from_text {
"(新私钥)".to_string()
// A stand-in, never shown or kept.
"(pasted key)".to_string()
} else {
key_file
};
@@ -417,13 +424,17 @@ impl CredentialForm {
let key = match &self.generated {
Some((algorithm, Generated::Ready(key))) if *algorithm == self.algorithm => key.clone(),
Some((_, Generated::Failed(error))) => return self.fail(error.clone(), window, cx),
_ => return self.fail("密钥还在生成,请稍候", window, cx),
_ => return self.fail(t!("credential.dialog.still_generating"), window, cx),
};
let comment = draft.name.clone();
if passphrase.is_empty() {
return match key.encode(&comment, "") {
Ok(text) => self.save(draft, Some(text), None, Some(None), window, cx),
Err(error) => self.fail(format!("无法保存私钥:{error}"), window, cx),
Err(error) => self.fail(
t!("credential.dialog.encode_failed", error = error),
window,
cx,
),
};
}
// Deriving the encryption key from a passphrase is slow on purpose,
@@ -443,7 +454,11 @@ impl CredentialForm {
let passphrase = Some(Some(passphrase.to_string()));
this.save(draft, Some(text), None, passphrase, window, cx)
}
Err(error) => this.fail(format!("无法加密私钥:{error}"), window, cx),
Err(error) => this.fail(
t!("credential.dialog.encrypt_failed", error = error),
window,
cx,
),
};
if saved {
// Closing it from here skips the dialog's `on_close`.
@@ -479,7 +494,13 @@ impl CredentialForm {
if let Some(text) = key_text {
match self.store.read(cx).save_private_key(editing, &text) {
Ok(path) => draft.key_path = Some(path),
Err(error) => return self.fail(format!("私钥未能保存:{error}"), window, cx),
Err(error) => {
return self.fail(
t!("credential.dialog.key_not_saved", error = error),
window,
cx,
);
}
}
}
let passphrase_change = passphrase_change
@@ -513,19 +534,19 @@ impl CredentialForm {
}
/// What editing the credential does to the hosts using it.
fn usage_note(&self) -> Option<String> {
fn usage_note(&self) -> Option<SharedString> {
if self.hosts == 0 {
return None;
}
let mut note = format!("有 {} 台主机使用此凭据。", self.hosts);
let mut note = vec![tn!("credential.dialog.usage", self.hosts)];
// A new key is no use to them until their servers know it.
if self.kind == CredentialKind::Key && self.source != KeySource::File {
note.push_str("换成新私钥后,要把它的公钥加到这些主机上,它们才能再登录。");
note.push(t!("credential.dialog.usage_new_key"));
}
if self.connected > 0 {
note.push_str("修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。");
note.push(t!("credential.dialog.usage_reconnects"));
}
Some(note)
join_sentences(note)
}
/// Whether saving now would delete the key ShellRS keeps for the
@@ -600,7 +621,7 @@ impl CredentialForm {
Segment::new(each.label()).when(each != KeySource::File && !can_keep, |segment| {
segment
.disabled(true)
.tooltip("本地数据库没有打开,这次运行无法保存私钥")
.tooltip(t!("credential.dialog.no_key_dir"))
})
}));
// A generated key has nothing to fill in under the choice.
@@ -630,17 +651,17 @@ impl CredentialForm {
};
let mut form = form.child(
Field::new()
.label("私钥")
.label(t!("credential.dialog.private_key"))
.required(true)
.child(v_flex().w_full().gap_2().child(sources).children(key))
.when(kept_here, |field| {
field.description("这把私钥由 ShellRS 保存,删除凭据时会一起删除。")
field.description(t!("credential.dialog.kept_key"))
}),
);
if source == KeySource::Generate {
form = form.child(
Field::new()
.label("算法")
.label(t!("credential.dialog.algorithm"))
.child(
SegmentedControl::new("credential-key-algorithm")
.selected_index(
@@ -656,41 +677,43 @@ impl CredentialForm {
.segments(KeyAlgorithm::ALL.map(|each| Segment::new(each.label()))),
)
.when(self.algorithm == KeyAlgorithm::Rsa, |field| {
field.description("只在服务器不支持 Ed25519 时选它(OpenSSH 6.5 以前)。")
field.description(t!("credential.dialog.rsa_note"))
}),
);
}
form = match self.public_key(cx) {
PublicKeyView::Line(line) => form.child(
Field::new()
.label("公钥")
.label(t!("credential.dialog.public_key"))
.child(public_key_box(line, cx))
.when(source == KeySource::Generate, |field| {
field.description(
"把公钥加到服务器的 ~/.ssh/authorized_keys,就能用这把密钥登录。",
)
field.description(t!("credential.dialog.public_key_note"))
}),
),
PublicKeyView::Generating => form.child(
Field::new().label("公钥").child(
div()
.id("credential-public-key-pending")
.test_support()
.text_sm()
.text_color(muted)
.child("正在生成密钥…"),
),
Field::new()
.label(t!("credential.dialog.public_key"))
.child(
div()
.id("credential-public-key-pending")
.test_support()
.text_sm()
.text_color(muted)
.child(t!("credential.dialog.generating")),
),
),
PublicKeyView::Failed(error) => form.child(
Field::new().label("公钥").child(
div()
.id("credential-generate-error")
.test_support()
.aria_label(error.clone())
.text_sm()
.text_color(theme.danger)
.child(error),
),
Field::new()
.label(t!("credential.dialog.public_key"))
.child(
div()
.id("credential-generate-error")
.test_support()
.aria_label(error.clone())
.text_sm()
.text_color(theme.danger)
.child(error),
),
),
PublicKeyView::Unknown => form,
};
@@ -699,7 +722,7 @@ impl CredentialForm {
if source != KeySource::Paste || encrypted_paste {
form = form.child(
Field::new()
.label("私钥口令")
.label(t!("credential.dialog.passphrase"))
.child(fields.passphrase_input("credential-passphrase")),
);
}
@@ -736,7 +759,7 @@ fn public_key_box(line: SharedString, cx: &App) -> impl IntoElement + use<> {
.child(
Clipboard::new("copy-public-key")
.value(line)
.tooltip("复制公钥"),
.tooltip(t!("credential.dialog.copy_public_key")),
)
}
@@ -747,13 +770,13 @@ impl Render for CredentialForm {
let keychain = self.fields.read(cx).keychain_available();
let secret_note = match kind {
CredentialKind::Agent => None,
_ if keychain => Some("密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。"),
_ => Some("系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。"),
_ if keychain => Some(t!("credential.dialog.keychain_note")),
_ => Some(t!("credential.dialog.no_keychain_note")),
};
let drops_kept_key = self.drops_kept_key(cx);
let form = Form::new()
.child(
Field::new().label("类型").child(
Field::new().label(t!("credential.dialog.kind")).child(
SegmentedControl::new("credential-kind")
.selected_index(CredentialKind::ALL.iter().position(|each| *each == kind))
.on_change(cx.listener(|this, ix: &usize, _, cx| {
@@ -766,19 +789,19 @@ impl Render for CredentialForm {
)
.child(
Field::new()
.label("名称")
.label(t!("credential.dialog.name"))
.required(true)
.child(Input::new(&self.name).id("credential-name").small()),
)
.child(
Field::new()
.label("用户名")
.label(t!("credential.dialog.user"))
.child(Input::new(&self.user).id("credential-user").small()),
);
let form = match kind {
CredentialKind::Password => form.child(
Field::new()
.label("密码")
.label(t!("credential.dialog.password"))
.child(self.fields.read(cx).password_input("credential-password")),
),
CredentialKind::Key => self.key_fields(form, cx),
@@ -793,22 +816,22 @@ impl Render for CredentialForm {
div()
.id("credential-agent-note")
.test_support()
.aria_label(AGENT_NOTE)
.aria_label(agent_note())
.text_sm()
.text_color(muted)
.child(AGENT_NOTE),
.child(agent_note()),
)
})
.when_some(secret_note, |view, note| {
view.child(div().text_sm().text_color(muted).child(note))
})
.when(drops_kept_key, |view| {
let note = "保存后,ShellRS 保存的原私钥会被删除。";
let note = t!("credential.dialog.drops_kept_key");
view.child(
div()
.id("credential-kept-key-note")
.test_support()
.aria_label(note)
.aria_label(note.clone())
.text_sm()
.text_color(cx.theme().warning)
.child(note),
@@ -832,7 +855,7 @@ impl Render for CredentialForm {
.test_support()
.text_sm()
.text_color(muted)
.child("正在用口令加密私钥…"),
.child(t!("credential.dialog.encrypting")),
)
})
}
@@ -848,13 +871,16 @@ pub fn open_credential_dialog(
) {
let form = cx.new(|cx| CredentialForm::new(dialog, store, window, cx));
let editing = matches!(dialog, CredentialDialog::Edit(_));
let title: SharedString = if editing {
"编辑凭据"
let title = if editing {
t!("credential.dialog.title_edit")
} else {
"新建凭据"
}
.into();
let commit_label: SharedString = if editing { "保存" } else { "创建" }.into();
t!("credential.dialog.title_new")
};
let commit_label = if editing {
t!("common.save")
} else {
t!("credential.dialog.create")
};
window.open_dialog(cx, {
let form = form.clone();
move |dialog, _, _| {
+46 -32
View File
@@ -8,7 +8,6 @@ use gpui_kit::component::{
list::ListItem,
menu::{ContextMenuExt as _, PopupMenu},
scroll::ScrollableElement as _,
tooltip::Tooltip,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
@@ -20,6 +19,8 @@ use crate::app::{
SelectNextCredential, SelectPreviousCredential,
};
use crate::host::{CredentialId, CredentialKind, HostStore, matches_credential_query};
use crate::i18n::{UiLocale, t, tn};
use crate::shared::{RowTooltip, RowTooltips};
/// The credential list the left dock shows in place of the hosts: every
/// saved login and how many hosts use it.
@@ -42,6 +43,8 @@ pub struct CredentialPanel {
/// The row a right click landed on, for the menu about to open; `None`
/// for the blank space below the rows.
menu_hit: Rc<Cell<Option<CredentialId>>>,
/// Rows' tooltips: the detail in full, and a key's whole path.
row_tooltips: RowTooltips<CredentialId>,
scroll_handle: ScrollHandle,
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
@@ -54,8 +57,8 @@ struct CredentialRow {
name: SharedString,
/// 「root · 密码 · 3 台主机」.
detail: SharedString,
/// The detail with the whole key path, for the tooltip.
tooltip: SharedString,
/// The whole path of a key's file, which `detail` cuts to its name.
key_path: Option<SharedString>,
}
impl CredentialPanel {
@@ -67,7 +70,7 @@ impl CredentialPanel {
) -> Self {
let search = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("搜索凭据")
.placeholder(t!("credential.panel.search"))
.clean_on_escape()
});
let known = store
@@ -78,6 +81,11 @@ impl CredentialPanel {
.collect();
let subscriptions = vec![
cx.observe(&store, |this, _, cx| this.on_store_changed(cx)),
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
this.search.update(cx, |search, cx| {
search.set_placeholder(t!("credential.panel.search"), window, cx)
});
}),
cx.subscribe(&search, |this, state, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
this.query = state.read(cx).value().to_string();
@@ -93,6 +101,7 @@ impl CredentialPanel {
selected: None,
known,
menu_hit: Rc::new(Cell::new(None)),
row_tooltips: RowTooltips::new("credential-tooltip", cx),
scroll_handle: ScrollHandle::new(),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
@@ -133,7 +142,7 @@ impl CredentialPanel {
h_flex()
.gap_1()
.child(Icon::new(CatalogIcon::KeyRound).small())
.child("凭据")
.child(t!("credential.panel.title"))
}
/// The dock's toolbar while this list is up.
@@ -142,8 +151,8 @@ impl CredentialPanel {
vec![
Button::new("new-credential")
.icon(IconName::Plus)
.tooltip("新建凭据…")
.accessibility_label("新建凭据…")
.tooltip(t!("credential.menu.new"))
.accessibility_label(t!("credential.menu.new"))
.on_click(move |_, window, cx| target.dispatch_action(&NewCredential, window, cx)),
]
}
@@ -159,19 +168,15 @@ impl CredentialPanel {
let hosts = store.hosts_using(credential.id).count();
let summary = credential.summary(store.key_dir());
let detail = match hosts {
0 => summary,
hosts => format!("{summary} · {hosts} 台主机"),
};
let tooltip = match credential.key_path.as_deref() {
Some(path) => format!("{} · {path}", credential.user),
None => detail.clone(),
0 => summary.into(),
hosts => tn!("credential.panel.detail", hosts, summary = summary),
};
CredentialRow {
id: credential.id,
kind: credential.kind,
name: credential.name.clone(),
detail: detail.into(),
tooltip: tooltip.into(),
detail,
key_path: credential.key_path.clone(),
}
})
.collect()
@@ -227,12 +232,12 @@ impl CredentialPanel {
let muted = cx.theme().muted_foreground;
let target = self.target.clone();
let (title, hint, buttons) = if !self.store.read(cx).credentials().is_empty() {
("没有匹配的凭据", None, None)
(t!("credential.panel.no_matches"), None, None)
} else {
let generate = target.clone();
(
"还没有凭据",
Some("保存一套用户名和密码、私钥或 SSH Agent,让多台主机共用。"),
t!("credential.panel.empty"),
Some(t!("credential.panel.empty_hint")),
Some(
h_flex()
.gap_2()
@@ -242,7 +247,7 @@ impl CredentialPanel {
Button::new("credential-empty-new")
.small()
.icon(IconName::Plus)
.label("新建凭据…")
.label(t!("credential.menu.new"))
.on_click(move |_, window, cx| {
target.dispatch_action(&NewCredential, window, cx)
}),
@@ -251,7 +256,7 @@ impl CredentialPanel {
Button::new("credential-empty-generate")
.small()
.icon(CatalogIcon::KeyRound)
.label("生成密钥…")
.label(t!("credential.menu.generate"))
.on_click(move |_, window, cx| {
generate.dispatch_action(&GenerateCredentialKey, window, cx)
}),
@@ -262,7 +267,7 @@ impl CredentialPanel {
v_flex()
.id("credential-empty")
.test_support()
.aria_label(title)
.aria_label(title.clone())
.items_center()
.gap_2()
.px_4()
@@ -283,9 +288,12 @@ impl CredentialPanel {
let theme = cx.theme();
let id = row.id;
let selected = self.selected == Some(id);
let tooltip = row.tooltip.clone();
let tooltip = self.row_tooltips.row(
id,
RowTooltip::new(row.detail.clone()).note(row.key_path.unwrap_or_default()),
);
ListItem::new(("credential-row", id.0))
let item = ListItem::new(("credential-row", id.0))
.w_full()
.px_2()
.py_1p5()
@@ -293,7 +301,7 @@ impl CredentialPanel {
.confirmed(selected)
.aria_selected(selected)
.when(selected, |item| {
item.bg(crate::app::host_tree_selection_color(theme))
item.bg(crate::app::selected_row_color(theme))
})
.child(
h_flex()
@@ -327,8 +335,9 @@ impl CredentialPanel {
.child(row.detail),
),
),
)
.tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
);
tooltip
.attach(item)
// A right click selects the row, as in Finder and Explorer, so
// the menu visibly belongs to it.
.on_mouse_down(
@@ -371,31 +380,31 @@ fn build_context_menu(hit: Option<(CredentialId, CredentialKind)>, menu: PopupMe
let Some((id, kind)) = hit else {
return menu
.menu_with_icon(
"新建凭据…",
t!("credential.menu.new"),
Icon::new(IconName::Plus),
Box::new(NewCredential),
)
.menu_with_icon(
"生成密钥…",
t!("credential.menu.generate"),
Icon::new(CatalogIcon::KeyRound),
Box::new(GenerateCredentialKey),
);
};
menu.menu_with_icon(
"编辑凭据…",
t!("credential.menu.edit"),
Icon::new(CatalogIcon::Pencil),
Box::new(EditCredential(id)),
)
.when(kind == CredentialKind::Key, |menu| {
menu.menu_with_icon(
"复制公钥",
t!("credential.menu.copy_public_key"),
Icon::new(CatalogIcon::ClipboardCopy),
Box::new(CopyCredentialPublicKey(id)),
)
})
.separator()
.menu_with_icon(
"删除",
t!("common.delete"),
Icon::new(CatalogIcon::Trash),
Box::new(DeleteCredential(id)),
)
@@ -439,6 +448,7 @@ impl Render for CredentialPanel {
div()
.id("credential-list")
.test_support()
.relative()
.flex_1()
.min_h_0()
.child(
@@ -449,7 +459,6 @@ impl Render for CredentialPanel {
.gap_0p5()
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
.vertical_scrollbar(&self.scroll_handle)
.map(|list| {
if rows.is_empty() {
list.child(self.render_empty(cx))
@@ -460,6 +469,10 @@ impl Render for CredentialPanel {
}
}),
)
// On the list, which does not scroll, rather than on
// the rows, which do: there the bar would scroll away
// with them.
.vertical_scrollbar(&self.scroll_handle)
.capture_any_mouse_down(move |event, _, _| {
if matches!(event.button, MouseButton::Left | MouseButton::Right) {
clear_hit.set(None);
@@ -475,5 +488,6 @@ impl Render for CredentialPanel {
build_context_menu(hit, menu)
}),
)
.child(self.row_tooltips.overlay())
}
}
+262
View File
@@ -0,0 +1,262 @@
//! A container's details: the dialog a click on it in the Docker tool
//! opens.
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _,
button::{Button, ButtonVariants as _},
dialog::DialogFooter,
h_flex,
tab::{Tab, TabBar},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::details::{Read, message, reading, render_sections, run};
use super::docker_panel::state_color;
use super::linux::{inspect_command, logs_command, parse_details};
use super::model::{Container, ContainerCommand, ContainerDetails, ContainerSubject, DockerObject};
use crate::app::{CatalogIcon, ControlContainers, RemoveDockerObject};
use crate::i18n::t;
use crate::shared::soft_tag;
use crate::terminal::TerminalView;
/// How wide the dialog is: long IDs, image names and environment lines.
const DIALOG_WIDTH: f32 = 46.;
/// Open the details of a container of the host of `terminal`, whose
/// connection reads them. `dispatch` is the workspace's focus handle: the
/// dialog's buttons close it and dispatch there.
pub fn open_container_dialog(
container: Container,
terminal: WeakEntity<TerminalView>,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut App,
) {
let view = cx.new(|cx| ContainerDetailsView::new(container.clone(), terminal, cx));
window.open_dialog(cx, move |dialog, window, cx| {
let color = state_color(container.state, cx);
let title = v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.child(div().truncate().child(container.name.clone()))
.child(soft_tag(container.state.label(), color)),
)
.child(
div()
.truncate()
.text_sm()
.font_weight(FontWeight::NORMAL)
.text_color(cx.theme().muted_foreground)
.child(container.image.clone()),
);
let subject = ContainerSubject::Container(container.name.clone());
let control = |command: ContainerCommand| {
let (dispatch, subject, id) = (dispatch.clone(), subject.clone(), container.id.clone());
let button = Button::new(SharedString::from(format!(
"container-dialog-{}",
command.verb()
)))
.icon(Icon::new(match command {
ContainerCommand::Start => CatalogIcon::Play,
ContainerCommand::Stop => CatalogIcon::Square,
ContainerCommand::Restart => CatalogIcon::RotateCw,
}))
.label(command.label())
.on_click(move |_, window, cx| {
// One dialog at a time: a confirmation takes its place.
window.close_dialog(cx);
dispatch.dispatch_action(
&ControlContainers {
subject: subject.clone(),
ids: vec![id.clone()],
command,
},
window,
cx,
);
});
match command {
ContainerCommand::Stop => button.danger(),
ContainerCommand::Start => button.primary(),
ContainerCommand::Restart => button.outline(),
}
};
let remove = {
let dispatch = dispatch.clone();
let action = RemoveDockerObject {
object: DockerObject::Container,
id: container.id.clone(),
name: container.name.clone(),
};
Button::new("container-dialog-remove")
.outline()
.icon(Icon::new(CatalogIcon::Trash))
.label(t!("common.delete"))
.on_click(move |_, window, cx| {
window.close_dialog(cx);
dispatch.dispatch_action(&action, window, cx);
})
};
let up = container.state.is_up();
let footer = DialogFooter::new()
.justify_start()
.child(control(if up {
ContainerCommand::Stop
} else {
ContainerCommand::Start
}))
.child(control(ContainerCommand::Restart))
// Docker removes a running container only by force.
.when(!up, |footer| footer.child(remove));
dialog
.title(title)
// Dialog geometry is an API boundary that takes `Pixels`; the
// width follows the interface zoom through the rem.
.w(rems(DIALOG_WIDTH).to_pixels(window.rem_size()))
.max_h(window.viewport_size().height * 0.9)
// Closed by its buttons or Escape, like every dialog.
.overlay_closable(false)
.child(view.clone())
.footer(footer)
});
}
/// The dialog's body: what `docker inspect` says about the container, and
/// its output.
pub struct ContainerDetailsView {
container: Container,
terminal: WeakEntity<TerminalView>,
details: Read<ContainerDetails>,
output: Read<String>,
/// 0 the details, 1 the output.
tab: usize,
output_scroll: ScrollHandle,
_details_task: Task<()>,
_output_task: Option<Task<()>>,
}
impl ContainerDetailsView {
fn new(
container: Container,
terminal: WeakEntity<TerminalView>,
cx: &mut Context<Self>,
) -> Self {
let details = run(
&terminal,
inspect_command(DockerObject::Container, &container.id),
cx,
|this, answer| {
this.details = match answer {
Ok(output) => match parse_details(&output) {
Some(details) => Read::Known(details),
None => Read::Unknown(t!("docker.details.container_gone").into()),
},
Err(why) => Read::Unknown(why),
};
},
);
Self {
container,
terminal,
details: Read::Reading,
output: Read::NotYet,
tab: 0,
output_scroll: ScrollHandle::new(),
_details_task: details,
_output_task: None,
}
}
fn select(&mut self, tab: usize, cx: &mut Context<Self>) {
self.tab = tab;
if tab == 1 && matches!(self.output, Read::NotYet) {
self.output = Read::Reading;
self._output_task = Some(run(
&self.terminal,
logs_command(&self.container.id),
cx,
|this, answer| {
this.output = match answer {
Ok(output) if output.trim().is_empty() => {
Read::Unknown(t!("docker.details.no_output").into())
}
Ok(output) => Read::Known(output.trim_end().to_owned()),
Err(why) => Read::Unknown(why),
};
// The newest lines are the last.
this.output_scroll.scroll_to_bottom();
},
));
}
cx.notify();
}
fn render_details(&self, cx: &App) -> AnyElement {
let details = match &self.details {
Read::Known(details) => details,
Read::Unknown(why) => return message("container-details-unknown", why.clone(), cx),
Read::Reading | Read::NotYet => return reading("container-details-reading", cx),
};
v_flex()
.id("container-details")
.test_support()
.child(render_sections("container", &details.sections(), cx))
.into_any_element()
}
fn render_output(&self, cx: &App) -> AnyElement {
match &self.output {
Read::Known(output) => div()
.id("container-output")
.test_support()
.aria_label(output.clone())
.h(rems(20.))
.overflow_y_scroll()
.track_scroll(&self.output_scroll)
.p_3()
.rounded(cx.theme().radius)
.bg(cx.theme().muted)
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.child(output.clone())
.into_any_element(),
Read::Unknown(why) => message("container-output-unknown", why.clone(), cx),
Read::Reading | Read::NotYet => reading("container-output-reading", cx),
}
}
}
impl Render for ContainerDetailsView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.child(
div().id("container-tabs").test_support().child(
TabBar::new("container-tab-bar")
.underline()
.small()
.selected_index(self.tab)
.on_click(cx.listener(|this, index: &usize, _, cx| this.select(*index, cx)))
.child(
Tab::new()
.icon(Icon::new(IconName::Info))
.label(t!("docker.details.tab_details")),
)
.child(
Tab::new()
.icon(Icon::new(CatalogIcon::FileText))
.label(t!("docker.details.tab_logs")),
),
),
)
.child(if self.tab == 0 {
self.render_details(cx)
} else {
self.render_output(cx)
})
}
}
+183
View File
@@ -0,0 +1,183 @@
//! What the Docker tool's details dialogs share: reading off the host, and
//! drawing what was read as sections under headings.
use gpui_kit::component::{ActiveTheme as _, Sizable as _, h_flex, spinner::Spinner, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::model::{DetailSection, SectionBody};
use crate::i18n::t;
use crate::terminal::{ExecResult, TerminalView, exec_answer};
/// Something read off the host for a dialog.
pub(super) enum Read<T> {
/// Not asked for yet: a container's output until its tab is opened.
NotYet,
Reading,
Known(T),
/// Why not: 「读取失败:命令超时」.
Unknown(String),
}
/// Run `command` on `terminal`'s connection, and hand the answer to
/// `record`: what it printed, or why it did not run.
pub(super) fn run<V: 'static>(
terminal: &WeakEntity<TerminalView>,
command: Option<String>,
cx: &mut Context<V>,
record: impl FnOnce(&mut V, Result<String, String>) + 'static,
) -> Task<()> {
let reply = command.and_then(|command| terminal.upgrade()?.read(cx).exec(command, cx));
cx.spawn(async move |this, cx| {
let answer: ExecResult = match reply {
None => Err(t!("tools.not_connected").into()),
Some(reply) => match exec_answer(reply, cx).await {
None => Err(t!("tools.not_connected").into()),
Some(Err(error)) => Err(t!("tools.read_failed", error = error).into()),
Some(Ok(output)) => Ok(output),
},
};
this.update(cx, |this, cx| {
record(this, answer);
cx.notify();
})
.ok();
})
}
/// The sections one under another, each under its heading; 「—」 for one
/// with nothing in it. Their lines are found as 「{prefix}-{section}:{label}」
/// (「container-basics:name」), a text section as 「{prefix}-{section}」.
pub(super) fn render_sections(
prefix: &'static str,
sections: &[DetailSection],
cx: &App,
) -> impl IntoElement {
v_flex().gap_4().children(sections.iter().map(|section| {
v_flex()
.gap_1p5()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(t!(section.title)),
)
.child(render_body(prefix, section, cx))
}))
}
fn render_body(prefix: &'static str, section: &DetailSection, cx: &App) -> AnyElement {
if section.body.is_empty() {
return div()
.px_3()
.py_2()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("—")
.into_any_element();
}
let line_id = |key: &str| SharedString::from(format!("{prefix}-{}:{key}", section.id));
let lines = |lines: Vec<AnyElement>| {
v_flex()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
.children(lines.into_iter().enumerate().map(|(index, line)| {
div()
.when(index > 0, |line| {
line.border_t_1().border_color(cx.theme().border)
})
.child(line)
}))
.into_any_element()
};
match &section.body {
SectionBody::Rows(rows) => lines(
rows.iter()
.map(|(label, value)| {
h_flex()
.id(line_id(label.id()))
.test_support()
.aria_label(value.clone())
.items_start()
.gap_3()
.px_3()
.py_2()
.text_sm()
.child(
div()
.w(rems(9.))
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(label.text()),
)
// Long IDs and names wrap rather than go off the edge.
.child(div().flex_1().min_w_0().child(value.clone()))
.into_any_element()
})
.collect(),
),
SectionBody::List(items) => lines(
items
.iter()
.map(|item| {
div()
.id(line_id(item))
.test_support()
.aria_label(item.clone())
.px_3()
.py_2()
.text_sm()
.child(item.clone())
.into_any_element()
})
.collect(),
),
SectionBody::Text(text) => {
let text = text.join("\n");
div()
.id(SharedString::from(format!("{prefix}-{}", section.id)))
.test_support()
.aria_label(text.clone())
.max_h(rems(12.))
.overflow_y_scroll()
.p_3()
.rounded(cx.theme().radius)
.bg(cx.theme().muted)
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.child(text)
.into_any_element()
}
}
}
pub(super) fn reading(id: &'static str, cx: &App) -> AnyElement {
h_flex()
.id(id)
.test_support()
.gap_2()
.py_8()
.justify_center()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child(t!("tools.reading"))
.into_any_element()
}
pub(super) fn message(id: &'static str, text: String, cx: &App) -> AnyElement {
div()
.id(id)
.test_support()
.aria_label(text.clone())
.py_8()
.text_sm()
.text_center()
.text_color(cx.theme().muted_foreground)
.child(text)
.into_any_element()
}
+878
View File
@@ -0,0 +1,878 @@
use std::collections::HashMap;
use std::rc::Rc;
use std::time::Duration;
use gpui_kit::component::{
ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex,
scroll::ScrollableElement as _,
spinner::Spinner,
tab::{Tab, TabBar},
tag::Tag,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::linux::{self, Parsed};
use super::model::{
Container, ContainerCommand, ContainerState, ContainerSubject, DockerObject, DockerRow,
DockerTab, DockerTable, ObjectSummary, Project,
};
use super::object_details::tone_color;
use crate::app::{
CatalogIcon, ControlContainers, RefreshDocker, RemoveDockerObject, ShowDockerObject,
ToggleDockerProject,
};
use crate::i18n::t;
use crate::shared::{soft_tag, tinted};
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
/// How long a terminal not connected is left before it is asked again.
const RETRY: Duration = Duration::from_secs(2);
/// Docker: the containers of the host of the SSH terminal in front, by
/// compose project, and its volumes, images and networks, read when the
/// panel comes on screen, on 刷新 and after a command. Containers start,
/// stop and restart, a project's all at once, and open for their details
/// and output; what nothing uses can be removed.
///
/// Each reading runs `linux::command` beside the shell, on the terminal's
/// own connection. What each terminal's host showed last is kept, so
/// coming back to a terminal shows its list at once while it is read
/// again. The tab stays as it is from one terminal to the next.
pub struct DockerPanel {
target: Option<ExecTarget>,
active: bool,
hosts: HashMap<RemoteTerminalId, HostState>,
/// The reading on its way; dropping it stops it.
reading: Option<Task<()>>,
/// Whether a reading is on its way.
loading: bool,
tab: DockerTab,
/// Compose projects folded or unfolded by hand, by name. The rest are
/// as [`Project::unfolded_at_first`] says.
unfolded: HashMap<String, bool>,
/// The list's lines differ in height (a project with its containers,
/// a heading, a container), so it is a list that measures them.
list: ListState,
/// The lines the list was last told about.
rows: Vec<DockerRow>,
/// The tab or the terminal changed: the list goes back to its top.
back_to_top: bool,
/// The workspace's focus handle: the panel's buttons dispatch on it, so
/// they work whatever is focused.
dispatch: FocusHandle,
}
#[derive(Default)]
struct HostState {
table: Option<Rc<DockerTable>>,
problem: Option<Problem>,
}
#[derive(Clone, Debug, PartialEq)]
enum Problem {
/// The terminal is not connected, so there is no connection to read on.
NotConnected,
/// No `docker` on the host.
Missing,
/// Not a shell that runs `sh` (cmd.exe).
Unsupported,
/// Docker would not answer: why.
Unreachable(String),
/// The command did not run, or did not finish.
Failed(String),
}
impl DockerPanel {
pub fn new(dispatch: FocusHandle) -> Self {
Self {
target: None,
active: false,
hosts: HashMap::new(),
reading: None,
loading: false,
tab: DockerTab::default(),
unfolded: HashMap::new(),
list: ListState::new(0, ListAlignment::Top, px(400.)),
rows: Vec::new(),
back_to_top: false,
dispatch,
}
}
/// Read `target`'s host when `active`, the panel showing it, and was
/// not before.
pub fn set_target(&mut self, target: Option<ExecTarget>, active: bool, cx: &mut Context<Self>) {
if self.target == target && self.active == active {
return;
}
if self.target != target {
self.back_to_top = true;
}
self.target = target;
self.active = active;
self.read(cx);
cx.notify();
}
/// Read the host again: 刷新, and after a command.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
self.read(cx);
cx.notify();
}
/// Fold a compose project away, or unfold it.
pub fn toggle_project(&mut self, name: &str, cx: &mut Context<Self>) {
let unfolded = self
.table()
.and_then(|table| {
table
.projects()
.iter()
.find(|project| project.name == name)
.map(|project| self.is_unfolded(project))
})
.unwrap_or(false);
self.unfolded.insert(name.to_owned(), !unfolded);
cx.notify();
}
/// A container of the host on screen, as the list last showed it.
pub fn container(&self, id: &str) -> Option<Container> {
self.table()?.container(id).cloned()
}
/// A volume, an image or a network of the host on screen, as its card
/// last showed it.
pub fn summary_of(&self, object: DockerObject, id: &str) -> Option<ObjectSummary> {
self.table()?.summary_of(object, id)
}
fn table(&self) -> Option<&DockerTable> {
let target = self.target.as_ref()?;
self.hosts.get(&target.terminal)?.table.as_deref()
}
fn is_unfolded(&self, project: &Project) -> bool {
self.unfolded
.get(&project.name)
.copied()
.unwrap_or_else(|| project.unfolded_at_first())
}
fn show(&mut self, tab: DockerTab, cx: &mut Context<Self>) {
if self.tab != tab {
self.tab = tab;
self.back_to_top = true;
cx.notify();
}
}
/// Start reading the target's host over, or stop when there is nothing
/// to show. A terminal not connected is asked again every little while,
/// so its list comes once it connects.
fn read(&mut self, cx: &mut Context<Self>) {
self.reading = None;
self.loading = false;
let Some(target) = self.target.clone().filter(|_| self.active) else {
return;
};
self.loading = true;
self.reading = Some(cx.spawn(async move |this, cx| {
loop {
let reply = this.update(cx, |_, cx| {
let view = target.view.upgrade()?;
view.read(cx).exec(linux::command(), cx)
});
let Ok(reply) = reply else { return };
let answer = match reply {
Some(reply) => exec_answer(reply, cx).await,
None => None,
};
let connected = answer.is_some();
if this
.update(cx, |this, cx| this.record(target.terminal, answer, cx))
.is_err()
|| connected
{
return;
}
cx.background_executor().timer(RETRY).await;
}
}));
}
fn record(
&mut self,
terminal: RemoteTerminalId,
answer: Option<ExecResult>,
cx: &mut Context<Self>,
) {
let host = self.hosts.entry(terminal).or_default();
host.problem = match answer {
None => Some(Problem::NotConnected),
Some(Err(error)) => Some(Problem::Failed(error)),
Some(Ok(output)) => match linux::parse(&output) {
Parsed::Table(table) => {
host.table = Some(Rc::new(table));
None
}
Parsed::Unreachable(why) => Some(Problem::Unreachable(why)),
Parsed::Missing(_) => Some(Problem::Missing),
Parsed::Unsupported => Some(Problem::Unsupported),
},
};
self.loading = false;
cx.notify();
}
/// Tell the list about `rows` when they are not the ones it has; back
/// at the top when the tab or the terminal changed.
fn sync_list(&mut self, rows: &[DockerRow]) {
if std::mem::take(&mut self.back_to_top) {
self.list.reset(rows.len());
} else if self.rows != rows {
self.list.splice(0..self.rows.len(), rows.len());
}
self.rows = rows.to_vec();
}
fn render_table(
&mut self,
table: Rc<DockerTable>,
problem: Option<Problem>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let rows = table.rows(self.tab, |project| self.is_unfolded(project));
self.sync_list(&rows);
let summary = table.summary();
let refresh = self.dispatch.clone();
let header =
v_flex()
.flex_shrink_0()
.px_3()
.pt_3()
.pb_2()
.gap_2()
.child(
h_flex()
.gap_2()
.child(
div()
.id("docker-summary")
.test_support()
.aria_label(summary.clone())
.flex_1()
.min_w_0()
.truncate()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(summary),
)
.child(
Button::new("docker-refresh")
.ghost()
.xsmall()
.icon(Icon::new(CatalogIcon::RefreshCw))
.loading(self.loading)
.tooltip(t!("tools.refresh"))
.accessibility_label(t!("tools.refresh"))
.on_click(move |_, window, cx| {
refresh.dispatch_action(&RefreshDocker, window, cx)
}),
),
)
.when_some(problem, |header, problem| {
header.child(render_problem(&problem, cx))
})
.child(
// gpui-kit's underlined tabs, as 系统服务's: each as wide as
// its label and count, and where they do not all fit the bar
// scrolls sideways rather than cut a label short, whatever
// the language ("Containers 12" has no room in a quarter of
// the panel).
div().id("docker-tabs").test_support().child(
TabBar::new("docker-tab-bar")
.underline()
.small()
.selected_index(
DockerTab::ALL
.iter()
.position(|tab| *tab == self.tab)
.unwrap_or(0),
)
.on_click(cx.listener(|this, index: &usize, _, cx| {
this.show(DockerTab::ALL[*index], cx)
}))
.children(DockerTab::ALL.iter().zip(table.counts()).map(
|(tab, count)| {
Tab::new()
.label(tab.label())
.aria_label(format!("{} {count}", tab.label()))
// Beside the label: the suffix would sit
// outside the label's box.
.child(
div()
.ml_1()
.text_color(cx.theme().muted_foreground)
.child(count.to_string()),
)
},
)),
),
);
let empty = match self.tab {
DockerTab::Containers => t!("docker.panel.no_containers"),
DockerTab::Volumes => t!("docker.panel.no_volumes"),
DockerTab::Images => t!("docker.panel.no_images"),
DockerTab::Networks => t!("docker.panel.no_networks"),
};
// The scrollbar goes on the list's box, which does not scroll: on
// the scrolled lines it would scroll away with them.
let list = div()
.id("docker-list")
.test_support()
.relative()
.flex_1()
.min_h_0()
.border_t_1()
.border_color(cx.theme().border)
.map(|list| {
if rows.is_empty() {
list.child(
div()
.id("docker-empty")
.test_support()
.aria_label(empty.clone())
.py_8()
.text_sm()
.text_center()
.text_color(cx.theme().muted_foreground)
.child(empty),
)
} else {
let rows = Rc::new(rows);
let dispatch = self.dispatch.clone();
list.child(
gpui_kit::list(self.list.clone(), move |index, _, cx| {
let last = index + 1 == rows.len();
let row = match &rows[index] {
DockerRow::Project { index, unfolded } => render_project(
&table,
&table.projects()[*index],
*unfolded,
&dispatch,
cx,
)
.into_any_element(),
DockerRow::Standalone => div()
.px_1()
.pt_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(t!("docker.panel.standalone"))
.into_any_element(),
DockerRow::Container(index) => render_container(
&table.containers()[*index],
false,
&dispatch,
cx,
)
.into_any_element(),
DockerRow::Volume(index) => {
let volume = &table.volumes()[*index];
render_object(
format!("docker-volume:{}", volume.name).into(),
volume.summary(),
&dispatch,
cx,
)
.into_any_element()
}
DockerRow::Image(index) => {
let image = &table.images()[*index];
render_object(
format!("docker-image:{}", image.key).into(),
image.summary(),
&dispatch,
cx,
)
.into_any_element()
}
DockerRow::Network(index) => {
let network = &table.networks()[*index];
render_object(
format!("docker-network:{}", network.name).into(),
network.summary(),
&dispatch,
cx,
)
.into_any_element()
}
};
div()
.px_3()
.pt_2()
.when(last, |row| row.pb_3())
.child(row)
.into_any_element()
})
.size_full(),
)
}
})
.vertical_scrollbar(&self.list);
v_flex().size_full().child(header).child(list)
}
}
impl Render for DockerPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let host = self
.target
.as_ref()
.and_then(|target| self.hosts.get(&target.terminal));
let table = host.and_then(|host| host.table.clone());
let problem = host.and_then(|host| host.problem.clone());
let body = match (table, problem) {
// A list from before a failed reading still says something;
// the failure goes above it.
(Some(table), problem @ (None | Some(Problem::Failed(_)))) => {
self.render_table(table, problem, cx).into_any_element()
}
(_, Some(problem)) => div()
.p_3()
.child(render_problem(&problem, cx))
.into_any_element(),
(None, None) => h_flex()
.id("docker-loading")
.test_support()
.gap_2()
.justify_center()
.py_8()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child(t!("tools.reading"))
.into_any_element(),
};
v_flex().id("docker").test_support().size_full().child(body)
}
}
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
let (text, color) = match problem {
Problem::NotConnected => (
t!("docker.panel.not_connected"),
cx.theme().muted_foreground,
),
Problem::Missing => (t!("docker.panel.missing"), cx.theme().muted_foreground),
Problem::Unsupported => (t!("docker.panel.unsupported"), cx.theme().muted_foreground),
Problem::Unreachable(why) => (
t!("docker.panel.unreachable", error = why),
cx.theme().danger,
),
Problem::Failed(error) => (t!("tools.read_failed", error = error), cx.theme().danger),
};
div()
.id("docker-message")
.test_support()
.aria_label(text.clone())
.text_sm()
.text_color(color)
.child(text)
}
/// What a container's state looks like: green up, yellow on its way or
/// paused, red dead, the rest quiet.
pub(super) fn state_color(state: ContainerState, cx: &App) -> Hsla {
match state {
ContainerState::Running => cx.theme().success,
ContainerState::Paused | ContainerState::Restarting | ContainerState::Removing => {
cx.theme().warning
}
ContainerState::Dead => cx.theme().danger,
ContainerState::Created | ContainerState::Exited => cx.theme().muted_foreground,
}
}
fn dot(color: Hsla) -> Div {
div().flex_shrink_0().size_2().rounded_full().bg(color)
}
/// Stop or start, then restart: what a container or a project offers,
/// stop while something in it is up.
fn command_buttons(
id: &str,
subject: ContainerSubject,
ids: Vec<String>,
up: bool,
dispatch: &FocusHandle,
cx: &App,
) -> impl IntoElement {
let button = |command: ContainerCommand| {
let (dispatch, subject, ids) = (dispatch.clone(), subject.clone(), ids.clone());
let button = Button::new(SharedString::from(format!(
"docker-{}:{id}",
command.verb()
)))
.small()
.icon(Icon::new(match command {
ContainerCommand::Start => CatalogIcon::Play,
ContainerCommand::Stop => CatalogIcon::Square,
ContainerCommand::Restart => CatalogIcon::RotateCw,
}))
.tooltip(command.label())
.accessibility_label(command.label())
.on_click(move |_, window, cx| {
// Not the line's click too.
cx.stop_propagation();
dispatch.dispatch_action(
&ControlContainers {
subject: subject.clone(),
ids: ids.clone(),
command,
},
window,
cx,
)
});
match command {
ContainerCommand::Stop => button.custom(tinted(cx.theme().danger, cx)),
ContainerCommand::Start => button.custom(tinted(cx.theme().success, cx)),
ContainerCommand::Restart => button.ghost(),
}
};
h_flex()
.flex_shrink_0()
.gap_1()
.child(button(if up {
ContainerCommand::Stop
} else {
ContainerCommand::Start
}))
.child(button(ContainerCommand::Restart))
}
/// A compose project's card: a line for the project, a click on which
/// folds and unfolds it, and its containers under it when unfolded.
fn render_project(
table: &DockerTable,
project: &Project,
unfolded: bool,
dispatch: &FocusHandle,
cx: &App,
) -> impl IntoElement {
let total = project.containers.len();
let color = match project.running {
0 => cx.theme().muted_foreground,
running if running == total => cx.theme().success,
_ => cx.theme().warning,
};
let ids: Vec<String> = project
.containers
.iter()
.map(|index| table.containers()[*index].id.clone())
.collect();
let toggle = dispatch.clone();
let name = project.name.clone();
v_flex()
.id(SharedString::from(format!(
"docker-project:{}",
project.name
)))
.test_support()
.aria_label(format!(
"{} · {}",
project.name,
t!(
"docker.project.running",
running = project.running,
total = total
)
))
.rounded(cx.theme().radius)
.bg(cx.theme().tokens.group_box)
.border_1()
.border_color(cx.theme().border)
.child(
h_flex()
.id(SharedString::from(format!(
"docker-project-line:{}",
project.name
)))
.test_support()
.gap_2()
.p_3()
.cursor_pointer()
.on_click(move |_, window, cx| {
toggle.dispatch_action(&ToggleDockerProject(name.clone()), window, cx)
})
.child(
Icon::new(if unfolded {
IconName::ChevronDown
} else {
IconName::ChevronRight
})
.small()
.text_color(cx.theme().muted_foreground),
)
.child(dot(color))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.child(
h_flex()
.gap_2()
.child(
div()
.min_w_0()
.truncate()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.child(project.name.clone()),
)
.child(
Tag::secondary()
.small()
.rounded_full()
.child(format!("{}/{total}", project.running)),
),
)
.when_some(project.working_dir.clone(), |column, directory| {
column.child(
div()
.truncate()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(directory),
)
}),
)
.child(command_buttons(
&format!("project:{}", project.name),
ContainerSubject::Project(project.name.clone()),
ids,
project.running > 0,
dispatch,
cx,
)),
)
.when(unfolded, |card| {
card.children(project.containers.iter().map(|index| {
div()
.border_t_1()
.border_color(cx.theme().border)
.pl_6()
.child(render_container(
&table.containers()[*index],
true,
dispatch,
cx,
))
}))
})
}
/// A container's line: in its project's card, or on its own. A click
/// opens its details.
fn render_container(
container: &Container,
in_project: bool,
dispatch: &FocusHandle,
cx: &App,
) -> impl IntoElement {
let detail = if container.ports.is_empty() {
container.image.clone()
} else {
container.ports.clone()
};
let (open, more) = (dispatch.clone(), dispatch.clone());
let (id, more_id) = (container.id.clone(), container.id.clone());
h_flex()
.id(SharedString::from(format!(
"docker-container:{}",
container.name
)))
.test_support()
.aria_label(format!(
"{} · {} · {detail}",
container.name,
container.state.label()
))
.gap_2()
.p_3()
.rounded(cx.theme().radius)
.cursor_pointer()
.when(!in_project, |line| {
line.hover({
let hover = cx.theme().list_hover;
move |style| style.bg(hover)
})
})
.on_click(move |_, window, cx| {
open.dispatch_action(
&ShowDockerObject {
object: DockerObject::Container,
id: id.clone(),
},
window,
cx,
)
})
.child(dot(state_color(container.state, cx)))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.child(
div()
.truncate()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.child(container.name.clone()),
)
.child(
div()
.truncate()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(detail),
),
)
.child(command_buttons(
&container.name,
ContainerSubject::Container(container.name.clone()),
vec![container.id.clone()],
container.state.is_up(),
dispatch,
cx,
))
.child(
Button::new(SharedString::from(format!(
"docker-more:{}",
container.name
)))
.ghost()
.small()
.icon(IconName::Ellipsis)
.tooltip(t!("tools.details_and_logs"))
.accessibility_label(t!("tools.details_and_logs"))
.on_click(move |_, window, cx| {
cx.stop_propagation();
more.dispatch_action(
&ShowDockerObject {
object: DockerObject::Container,
id: more_id.clone(),
},
window,
cx,
)
}),
)
}
/// A card for a volume, an image or a network: its name, a line about
/// it, a tag, and the button that removes it when nothing uses it. A click
/// opens its details.
fn render_object(
card: SharedString,
summary: ObjectSummary,
dispatch: &FocusHandle,
cx: &App,
) -> impl IntoElement {
let label = [
Some(summary.name.clone()),
Some(summary.detail.clone()),
summary.tag.clone().map(|(tag, _)| tag),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" · ");
let (open, remove) = (dispatch.clone(), dispatch.clone());
let show = ShowDockerObject {
object: summary.object,
id: summary.id.clone(),
};
let removal = RemoveDockerObject {
object: summary.object,
id: summary.id.clone(),
name: summary.name.clone(),
};
h_flex()
.id(card.clone())
.test_support()
.aria_label(label)
.gap_2()
.p_3()
.rounded(cx.theme().radius)
.bg(cx.theme().tokens.group_box)
.border_1()
.border_color(cx.theme().tokens.group_box)
.hover({
let border = cx.theme().border;
move |style| style.border_color(border)
})
.cursor_pointer()
.on_click(move |_, window, cx| open.dispatch_action(&show, window, cx))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.child(
h_flex()
.gap_2()
.child(
// 「registry.ap-…/hello-world:latest」: a long
// registry's name goes, the image's stays.
div()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis_middle()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.child(summary.name.clone()),
)
.when_some(summary.tag.clone(), |line, (tag, tone)| {
line.child(soft_tag(tag, tone_color(tone, cx)).flex_shrink_0())
}),
)
.child(
div()
.truncate()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(summary.detail.clone()),
),
)
.child(
Button::new(SharedString::from(format!("{card}-remove")))
.ghost()
.small()
.icon(Icon::new(CatalogIcon::Trash))
.tooltip(
summary
.removable
.clone()
.err()
.unwrap_or_else(|| t!("common.delete")),
)
.accessibility_label(t!("common.delete"))
.disabled(summary.removable.is_err())
.on_click(move |_, window, cx| {
// Not the card's click too, which opens the details.
cx.stop_propagation();
remove.dispatch_action(&removal, window, cx)
}),
)
}
+1093
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
//! Docker: the right sidebar's tool that lists the containers of the host of
//! the SSH terminal in front by compose project, with its volumes, images
//! and networks, starts, stops and restarts containers, shows their details
//! and output, and removes what nothing uses, over that terminal's own
//! connection.
mod container_details;
mod details;
mod docker_panel;
mod linux;
mod model;
mod object_details;
pub use container_details::open_container_dialog;
pub use docker_panel::DockerPanel;
pub use linux::{control_command, done, remove_command};
pub use model::{Container, ContainerCommand, ContainerSubject, DockerObject, ObjectSummary};
pub use object_details::open_object_dialog;
+926
View File
@@ -0,0 +1,926 @@
//! What the Docker tool reads off a host: containers grouped by their
//! compose project, volumes, images and networks.
use std::collections::BTreeMap;
use gpui_kit::SharedString;
use crate::i18n::{t, tn};
/// What a container is doing, as `docker ps` says.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContainerState {
Running,
Paused,
Restarting,
Created,
Exited,
Dead,
Removing,
}
impl ContainerState {
/// `docker ps`'s `State`, or, from a Docker before 20.10 without one,
/// its `Status`: 「Up 3 days」「Exited (0) 2 hours ago」.
pub fn parse(state: &str, status: &str) -> Self {
match state {
"running" => ContainerState::Running,
"paused" => ContainerState::Paused,
"restarting" => ContainerState::Restarting,
"created" => ContainerState::Created,
"exited" => ContainerState::Exited,
"dead" => ContainerState::Dead,
"removing" => ContainerState::Removing,
_ if status.starts_with("Up") && status.contains("(Paused)") => ContainerState::Paused,
_ if status.starts_with("Up") => ContainerState::Running,
_ if status.starts_with("Restarting") => ContainerState::Restarting,
_ if status.starts_with("Created") => ContainerState::Created,
_ if status.starts_with("Dead") => ContainerState::Dead,
_ if status.starts_with("Removal") => ContainerState::Removing,
_ => ContainerState::Exited,
}
}
pub fn label(self) -> SharedString {
match self {
ContainerState::Running => t!("docker.state.running"),
ContainerState::Paused => t!("docker.state.paused"),
ContainerState::Restarting => t!("docker.state.restarting"),
ContainerState::Created => t!("docker.state.created"),
ContainerState::Exited => t!("docker.state.exited"),
ContainerState::Dead => t!("docker.state.dead"),
ContainerState::Removing => t!("docker.state.removing"),
}
}
/// Whether it is up: what stops and restarts, rather than starts.
pub fn is_up(self) -> bool {
matches!(
self,
ContainerState::Running | ContainerState::Paused | ContainerState::Restarting
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Container {
/// The full ID.
pub id: String,
pub name: String,
/// As it was run: 「nginx:1.27」, 「hello-world」.
pub image: String,
pub state: ContainerState,
/// 「56000→9000/tcp, :::56000→9000/tcp」, empty with none published.
pub ports: String,
/// The compose project it belongs to, and that project's directory.
pub project: Option<String>,
pub working_dir: Option<String>,
/// The volumes (by name) and host paths mounted into it.
pub mounts: Vec<String>,
pub networks: Vec<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Volume {
pub name: String,
pub driver: String,
pub mountpoint: String,
/// The containers it is mounted into, running or not, by name: Docker
/// will not remove it while there are any.
pub used_by: Vec<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
/// 「4a3b5c6d7e8f」. Not unique in the list: an image tagged twice is
/// two of its lines, one per tag.
pub id: String,
/// What a command names this line by, unique in the list: 「nginx:1.27」,
/// or the ID for an image without a tag. Removing a tag removes the
/// image only with its last one, as `docker rmi nginx:1.27` does.
pub key: String,
/// 「nginx:1.27」, `None` for a dangling image (「<none>」).
pub reference: Option<String>,
/// 「187.69 MB」.
pub size: String,
/// 「2026-09-07」.
pub created: String,
/// The containers made from it, by name.
pub used_by: Vec<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Network {
pub id: String,
pub name: String,
pub driver: String,
pub scope: String,
/// The containers attached to it, by name.
pub used_by: Vec<String>,
}
impl Network {
/// One of the three Docker makes itself, which cannot be removed.
pub fn builtin(&self) -> bool {
matches!(self.name.as_str(), "bridge" | "host" | "none")
}
}
/// A compose project: the containers sharing its label.
#[derive(Clone, Debug, PartialEq)]
pub struct Project {
pub name: String,
pub working_dir: Option<String>,
/// By their place in `DockerTable::containers`, by name.
pub containers: Vec<usize>,
pub running: usize,
}
impl Project {
/// Whether the list shows its containers until folded or unfolded by
/// hand: only a project of one container, running. A project of
/// several would push the rest of the list down; the card already says
/// how many of them run.
pub fn unfolded_at_first(&self) -> bool {
self.running > 0 && self.containers.len() == 1
}
}
/// What can be done to containers.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ContainerCommand {
Start,
Stop,
Restart,
}
impl ContainerCommand {
pub fn verb(self) -> &'static str {
match self {
ContainerCommand::Start => "start",
ContainerCommand::Stop => "stop",
ContainerCommand::Restart => "restart",
}
}
pub fn label(self) -> SharedString {
match self {
ContainerCommand::Start => t!("docker.command.start"),
ContainerCommand::Stop => t!("docker.command.stop"),
ContainerCommand::Restart => t!("docker.command.restart"),
}
}
}
/// What a container command is run on, for the question and the
/// notification: 「容器“web”」, 「项目“php-56”」.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ContainerSubject {
/// One container, by name.
Container(String),
/// A compose project's containers, by the project's name.
Project(String),
}
/// What kind of thing a removal removes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum DockerObject {
Container,
Image,
Volume,
Network,
}
impl DockerObject {
pub fn label(self) -> SharedString {
match self {
DockerObject::Container => t!("docker.object.container"),
DockerObject::Image => t!("docker.object.image"),
DockerObject::Volume => t!("docker.object.volume"),
DockerObject::Network => t!("docker.object.network"),
}
}
}
/// The tabs over the list.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DockerTab {
#[default]
Containers,
Volumes,
Images,
Networks,
}
impl DockerTab {
pub const ALL: [DockerTab; 4] = [
DockerTab::Containers,
DockerTab::Volumes,
DockerTab::Images,
DockerTab::Networks,
];
pub fn label(self) -> SharedString {
match self {
DockerTab::Containers => t!("docker.tab.containers"),
DockerTab::Volumes => t!("docker.tab.volumes"),
DockerTab::Images => t!("docker.tab.images"),
DockerTab::Networks => t!("docker.tab.networks"),
}
}
}
/// A line of the list.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DockerRow {
/// A compose project by its place in `projects()`, with its containers
/// under it when unfolded.
Project {
index: usize,
unfolded: bool,
},
/// The heading over the containers of no project.
Standalone,
Container(usize),
Volume(usize),
Image(usize),
Network(usize),
}
/// One reading of a host's Docker.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DockerTable {
/// 「26.0.0」.
pub version: Option<String>,
/// 「v2.25.0」.
pub compose: Option<String>,
containers: Vec<Container>,
projects: Vec<Project>,
volumes: Vec<Volume>,
images: Vec<Image>,
networks: Vec<Network>,
}
impl DockerTable {
/// Sorts each kind by name, groups the containers by project, and
/// marks what the containers use.
pub fn new(
version: Option<String>,
compose: Option<String>,
mut containers: Vec<Container>,
mut volumes: Vec<Volume>,
mut images: Vec<Image>,
mut networks: Vec<Network>,
) -> Self {
containers.sort_by(|a, b| a.name.cmp(&b.name));
let mut grouped: BTreeMap<String, Project> = BTreeMap::new();
for (index, container) in containers.iter().enumerate() {
let Some(name) = &container.project else {
continue;
};
let project = grouped.entry(name.clone()).or_insert_with(|| Project {
name: name.clone(),
working_dir: None,
containers: Vec::new(),
running: 0,
});
if project.working_dir.is_none() {
project.working_dir = container.working_dir.clone();
}
project.containers.push(index);
if container.state.is_up() {
project.running += 1;
}
}
let using = |uses: &dyn Fn(&Container) -> bool| -> Vec<String> {
containers
.iter()
.filter(|container| uses(container))
.map(|container| container.name.clone())
.collect()
};
for volume in &mut volumes {
volume.used_by = using(&|container| container.mounts.contains(&volume.name));
}
volumes.sort_by(|a, b| a.name.cmp(&b.name));
for image in &mut images {
image.used_by = using(&|container| {
let used = container.image.as_str();
// A container whose image lost its name since shows its ID.
let id = used.trim_start_matches("sha256:");
let by_id = id.len() >= 12 && id.chars().all(|c| c.is_ascii_hexdigit());
image.reference.as_deref().is_some_and(|reference| {
used == reference || reference.strip_suffix(":latest") == Some(used)
}) || (by_id && (id.starts_with(&image.id) || image.id.starts_with(id)))
});
}
// Tagged ones first, by name; then those without a tag, the ones
// without a repository either last.
images.sort_by(|a, b| {
let order = |image: &Image| {
(
image.key == image.id,
image.reference.is_none(),
image.reference.clone(),
image.id.clone(),
)
};
order(a).cmp(&order(b))
});
// One card per key: an image without a tag in two repositories is
// one image.
let mut seen = std::collections::HashSet::new();
images.retain(|image| seen.insert(image.key.clone()));
for network in &mut networks {
network.used_by = using(&|container| container.networks.contains(&network.name));
}
networks.sort_by(|a, b| a.name.cmp(&b.name));
Self {
version,
compose,
containers,
projects: grouped.into_values().collect(),
volumes,
images,
networks,
}
}
pub fn containers(&self) -> &[Container] {
&self.containers
}
pub fn container(&self, id: &str) -> Option<&Container> {
self.containers.iter().find(|container| container.id == id)
}
/// A volume, an image or a network by what Docker knows it by, as its
/// card and its details show it.
pub fn summary_of(&self, object: DockerObject, id: &str) -> Option<ObjectSummary> {
match object {
DockerObject::Container => None,
DockerObject::Volume => self
.volumes
.iter()
.find(|volume| volume.name == id)
.map(Volume::summary),
DockerObject::Image => self
.images
.iter()
.find(|image| image.key == id)
.map(Image::summary),
DockerObject::Network => self
.networks
.iter()
.find(|network| network.id == id)
.map(Network::summary),
}
}
pub fn projects(&self) -> &[Project] {
&self.projects
}
pub fn volumes(&self) -> &[Volume] {
&self.volumes
}
pub fn images(&self) -> &[Image] {
&self.images
}
pub fn networks(&self) -> &[Network] {
&self.networks
}
/// 「Docker 26.0.0 · Compose v2.25.0」.
pub fn summary(&self) -> String {
let mut parts = vec![
format!("Docker {}", self.version.as_deref().unwrap_or(""))
.trim()
.to_owned(),
];
if let Some(compose) = &self.compose {
parts.push(format!("Compose {compose}"));
}
parts.join(" · ")
}
/// How many of each the tabs count, in their order.
pub fn counts(&self) -> [usize; 4] {
[
self.containers.len(),
self.volumes.len(),
self.images.len(),
self.networks.len(),
]
}
/// The list under `tab`. Containers: each compose project, its
/// containers under it when `unfolded` says so, then the containers
/// of no project under their heading.
pub fn rows(&self, tab: DockerTab, unfolded: impl Fn(&Project) -> bool) -> Vec<DockerRow> {
match tab {
DockerTab::Containers => {
let mut rows: Vec<DockerRow> = self
.projects
.iter()
.enumerate()
.map(|(index, project)| DockerRow::Project {
index,
unfolded: unfolded(project),
})
.collect();
let standalone: Vec<DockerRow> = self
.containers
.iter()
.enumerate()
.filter(|(_, container)| container.project.is_none())
.map(|(index, _)| DockerRow::Container(index))
.collect();
if !standalone.is_empty() {
rows.push(DockerRow::Standalone);
rows.extend(standalone);
}
rows
}
DockerTab::Volumes => (0..self.volumes.len()).map(DockerRow::Volume).collect(),
DockerTab::Images => (0..self.images.len()).map(DockerRow::Image).collect(),
DockerTab::Networks => (0..self.networks.len()).map(DockerRow::Network).collect(),
}
}
}
/// How a tag reads: what is in use stands out, the rest is quiet.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tone {
Good,
Quiet,
}
/// A volume, an image or a network as its card and its details show it.
#[derive(Clone, Debug, PartialEq)]
pub struct ObjectSummary {
pub object: DockerObject,
/// What Docker knows it by, for its commands: the ID, a volume's name.
pub id: String,
pub name: String,
/// A line about it: 「local · /var/lib/docker/volumes/data/_data」.
pub detail: String,
pub tag: Option<(String, Tone)>,
/// Whether it can be removed, and why not.
pub removable: Result<(), SharedString>,
/// The containers using it, by name.
pub used_by: Vec<String>,
}
fn joined(parts: &[&str]) -> String {
parts
.iter()
.filter(|part| !part.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" · ")
}
impl Volume {
pub fn summary(&self) -> ObjectSummary {
let in_use = !self.used_by.is_empty();
ObjectSummary {
object: DockerObject::Volume,
id: self.name.clone(),
name: self.name.clone(),
detail: joined(&[&self.driver, &self.mountpoint]),
tag: Some(if in_use {
(t!("docker.object.in_use").into(), Tone::Good)
} else {
(t!("docker.object.unused").into(), Tone::Quiet)
}),
removable: if in_use {
Err(t!("docker.object.in_use_by_containers"))
} else {
Ok(())
},
used_by: self.used_by.clone(),
}
}
}
impl Image {
pub fn summary(&self) -> ObjectSummary {
let in_use = !self.used_by.is_empty();
ObjectSummary {
object: DockerObject::Image,
id: self.key.clone(),
name: self
.reference
.clone()
.unwrap_or_else(|| t!("docker.image.unnamed").into()),
detail: joined(&[&self.id, &self.size, &self.created]),
tag: in_use.then(|| (t!("docker.object.in_use").into(), Tone::Good)),
removable: if in_use {
Err(t!("docker.object.in_use_by_containers"))
} else {
Ok(())
},
used_by: self.used_by.clone(),
}
}
}
impl Network {
pub fn summary(&self) -> ObjectSummary {
let (tag, removable) = if self.builtin() {
(
(t!("docker.network.builtin").into(), Tone::Quiet),
Err(t!("docker.network.builtin_kept")),
)
} else if !self.used_by.is_empty() {
(
(
tn!("docker.network.containers", self.used_by.len()).into(),
Tone::Good,
),
Err(t!("docker.network.in_use")),
)
} else {
((t!("docker.object.unused").into(), Tone::Quiet), Ok(()))
};
ObjectSummary {
object: DockerObject::Network,
id: self.id.clone(),
name: self.name.clone(),
detail: joined(&[&self.driver, &self.scope]),
tag: Some(tag),
removable,
used_by: self.used_by.clone(),
}
}
}
/// A part of something's details, under its heading.
#[derive(Clone, Debug, PartialEq)]
pub struct DetailSection {
/// For the test ids of its lines: 「basics」.
pub id: &'static str,
/// The heading's text, by its key: translated where it is drawn.
pub title: &'static str,
pub body: SectionBody,
}
/// What a line of details is labelled with.
#[derive(Clone, Debug, PartialEq)]
pub enum RowLabel {
/// One of ShellRS's labels, by its text's key: 「docker.field.mountpoint」.
Field(&'static str),
/// One of the host's, as it is: a port, a path, a label's key.
Raw(String),
}
impl RowLabel {
/// What the line is found by: a field by the last part of its key
/// (「mountpoint」), the host's own as it is.
pub fn id(&self) -> &str {
match self {
RowLabel::Field(key) => key.rsplit('.').next().unwrap_or(key),
RowLabel::Raw(label) => label,
}
}
pub fn text(&self) -> SharedString {
match self {
RowLabel::Field(key) => t!(*key),
RowLabel::Raw(label) => label.clone().into(),
}
}
}
impl From<String> for RowLabel {
fn from(label: String) -> Self {
RowLabel::Raw(label)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum SectionBody {
/// Labels and their values: 「挂载点」 「/var/lib/docker/volumes/…」.
Rows(Vec<(RowLabel, String)>),
/// Names, one a line: the containers using it.
List(Vec<String>),
/// Lines of text as they are, in a fixed-width face: the environment.
Text(Vec<String>),
}
impl SectionBody {
pub fn is_empty(&self) -> bool {
match self {
SectionBody::Rows(rows) => rows.is_empty(),
SectionBody::List(lines) | SectionBody::Text(lines) => lines.is_empty(),
}
}
}
/// Labels and values, with 「—」 for the empty values.
pub fn rows(
rows: impl IntoIterator<Item = (impl Into<RowLabel>, impl Into<String>)>,
) -> SectionBody {
SectionBody::Rows(
rows.into_iter()
.map(|(label, value)| {
let value = value.into();
(
label.into(),
if value.is_empty() {
"—".into()
} else {
value
},
)
})
.collect(),
)
}
impl ContainerDetails {
/// Its sections, in the order its dialog shows them.
pub fn sections(&self) -> Vec<DetailSection> {
vec![
DetailSection {
id: "basics",
title: "docker.section.basics",
body: rows([
(RowLabel::Field("docker.field.name"), self.name.clone()),
(RowLabel::Raw("ID".into()), self.id.clone()),
(RowLabel::Field("docker.field.image"), self.image.clone()),
(
RowLabel::Field("docker.field.created"),
self.created.clone(),
),
(
RowLabel::Field("docker.field.entrypoint"),
self.entrypoint.clone(),
),
(
RowLabel::Field("docker.field.command"),
self.command.clone(),
),
]),
},
DetailSection {
id: "ports",
title: "docker.section.ports",
body: rows(self.ports.clone()),
},
DetailSection {
id: "mounts",
title: "docker.section.mounts",
body: rows(self.mounts.clone()),
},
DetailSection {
id: "environment",
title: "docker.section.environment",
body: SectionBody::Text(self.environment.clone()),
},
DetailSection {
id: "labels",
title: "docker.section.labels",
body: rows(self.labels.clone()),
},
]
}
}
/// A container's details, as `docker inspect` says.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ContainerDetails {
pub name: String,
pub id: String,
pub image: String,
pub state: String,
/// 「2026-09-07T10:59:16.371325789Z」.
pub created: String,
pub entrypoint: String,
pub command: String,
/// The container's port and where on the host it is published:
/// 「4321/tcp」, 「127.0.0.1:4321」 (empty when it is not).
pub ports: Vec<(String, String)>,
/// Where from and where to: 「/srv/data」, 「/data」, with 「rw」 or 「ro」.
pub mounts: Vec<(String, String)>,
pub environment: Vec<String>,
pub labels: Vec<(String, String)>,
}
#[cfg(test)]
mod tests {
use super::*;
fn container(name: &str, state: ContainerState, project: Option<&str>) -> Container {
Container {
id: format!("{name}-id"),
name: name.into(),
image: format!("{name}:latest"),
state,
ports: String::new(),
project: project.map(str::to_owned),
working_dir: project.map(|project| format!("/srv/{project}")),
mounts: Vec::new(),
networks: vec!["bridge".into()],
}
}
#[test]
fn containers_group_under_their_project_and_the_rest_stand_alone() {
let table = DockerTable::new(
Some("26.0.0".into()),
Some("v2.25.0".into()),
vec![
container("forex-web", ContainerState::Running, None),
container("vaultwarden", ContainerState::Exited, Some("vaultwarden")),
container("php56", ContainerState::Running, Some("php-56")),
container("exciting_tesla", ContainerState::Exited, None),
],
Vec::new(),
Vec::new(),
Vec::new(),
);
assert_eq!(table.summary(), "Docker 26.0.0 · Compose v2.25.0");
let projects: Vec<(&str, usize, usize)> = table
.projects()
.iter()
.map(|project| {
(
project.name.as_str(),
project.running,
project.containers.len(),
)
})
.collect();
assert_eq!(projects, [("php-56", 1, 1), ("vaultwarden", 0, 1)]);
let rows = table.rows(DockerTab::Containers, |project| project.running > 0);
let names: Vec<String> = rows
.iter()
.map(|row| match row {
DockerRow::Project { index, unfolded } => {
format!("{} {unfolded}", table.projects()[*index].name)
}
DockerRow::Standalone => "独立容器".into(),
DockerRow::Container(index) => table.containers()[*index].name.clone(),
_ => unreachable!(),
})
.collect();
assert_eq!(
names,
[
"php-56 true",
"vaultwarden false",
"独立容器",
"exciting_tesla",
"forex-web",
]
);
assert_eq!(table.counts(), [4, 0, 0, 0]);
}
#[test]
fn only_a_running_project_of_one_container_starts_unfolded() {
let table = DockerTable::new(
None,
None,
vec![
container("php56", ContainerState::Running, Some("php-56")),
container("vaultwarden", ContainerState::Exited, Some("vaultwarden")),
container("db", ContainerState::Running, Some("shop")),
container("web", ContainerState::Running, Some("shop")),
container("worker", ContainerState::Exited, Some("shop")),
],
Vec::new(),
Vec::new(),
Vec::new(),
);
let unfolded: Vec<(&str, bool)> = table
.projects()
.iter()
.map(|project| (project.name.as_str(), project.unfolded_at_first()))
.collect();
assert_eq!(
unfolded,
[("php-56", true), ("shop", false), ("vaultwarden", false)]
);
}
#[test]
fn what_the_containers_use_is_marked() {
let mut web = container("web", ContainerState::Running, None);
web.mounts = vec!["web-data".into(), "/srv/web".into()];
web.image = "nginx".into();
web.networks = vec!["web_default".into()];
let volume = |name: &str| Volume {
name: name.into(),
driver: "local".into(),
mountpoint: String::new(),
used_by: Vec::new(),
};
let image = |id: &str, reference: Option<&str>| Image {
id: id.into(),
key: reference.unwrap_or(id).into(),
reference: reference.map(str::to_owned),
size: String::new(),
created: String::new(),
used_by: Vec::new(),
};
let network = |name: &str| Network {
id: name.into(),
name: name.into(),
driver: "bridge".into(),
scope: "local".into(),
used_by: Vec::new(),
};
let table = DockerTable::new(
None,
None,
vec![web],
vec![volume("web-data"), volume("old-data")],
vec![
image("aaa", None),
image("bbb", Some("nginx:latest")),
image("ccc", Some("redis:7")),
],
vec![network("web_default"), network("bridge")],
);
assert_eq!(table.summary(), "Docker");
let volumes: Vec<(&str, Vec<String>)> = table
.volumes()
.iter()
.map(|volume| (volume.name.as_str(), volume.used_by.clone()))
.collect();
assert_eq!(
volumes,
[("old-data", vec![]), ("web-data", vec!["web".to_owned()])]
);
let images: Vec<(&str, usize)> = table
.images()
.iter()
.map(|image| (image.id.as_str(), image.used_by.len()))
.collect();
// Named first; nginx:latest is what 「nginx」 runs.
assert_eq!(images, [("bbb", 1), ("ccc", 0), ("aaa", 0)]);
let networks: Vec<(&str, usize, bool)> = table
.networks()
.iter()
.map(|network| {
(
network.name.as_str(),
network.used_by.len(),
network.builtin(),
)
})
.collect();
assert_eq!(networks, [("bridge", 0, true), ("web_default", 1, false)]);
// The card and the details say what is in use and what can go.
let summary = |object, id: &str| table.summary_of(object, id).expect(id);
let data = summary(DockerObject::Volume, "web-data");
assert_eq!(data.tag, Some(("使用中".into(), Tone::Good)));
assert_eq!(data.removable, Err("有容器在用,不能删除".into()));
assert_eq!(data.used_by, ["web"]);
assert_eq!(summary(DockerObject::Volume, "old-data").removable, Ok(()));
let dangling = summary(DockerObject::Image, "aaa");
assert_eq!(dangling.name, "未命名镜像");
assert_eq!(dangling.tag, None);
assert_eq!(
summary(DockerObject::Network, "bridge").removable,
Err("Docker 自带的网络,不能删除".into())
);
assert_eq!(
summary(DockerObject::Network, "web_default").tag,
Some(("1 个容器".into(), Tone::Good))
);
assert_eq!(table.summary_of(DockerObject::Volume, "gone"), None);
}
#[test]
fn a_state_reads_from_an_old_dockers_status_too() {
assert_eq!(
ContainerState::parse("", "Up 3 days"),
ContainerState::Running
);
assert_eq!(
ContainerState::parse("", "Up 3 days (Paused)"),
ContainerState::Paused
);
assert_eq!(
ContainerState::parse("", "Exited (0) 2 hours ago"),
ContainerState::Exited
);
assert_eq!(
ContainerState::parse("running", "Up 1 second"),
ContainerState::Running
);
assert!(ContainerState::Restarting.is_up());
assert!(!ContainerState::Created.is_up());
}
}
+165
View File
@@ -0,0 +1,165 @@
//! A volume's, an image's or a network's details: the dialog a click on its
//! card in the Docker tool opens.
use gpui_kit::component::{
ActiveTheme as _, Disableable as _, Icon, WindowExt as _, button::Button, dialog::DialogFooter,
h_flex, v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::details::{Read, message, reading, render_sections, run};
use super::linux::{image_details, inspect_command, network_details, volume_details};
use super::model::{DetailSection, DockerObject, ObjectSummary, Tone};
use crate::app::{CatalogIcon, RemoveDockerObject};
use crate::i18n::t;
use crate::shared::soft_tag;
use crate::terminal::TerminalView;
/// As wide as a container's details: long IDs, digests and paths.
const DIALOG_WIDTH: f32 = 46.;
/// What a tag's tone looks like.
pub(super) fn tone_color(tone: Tone, cx: &App) -> Hsla {
match tone {
Tone::Good => cx.theme().success,
Tone::Quiet => cx.theme().muted_foreground,
}
}
/// What the lines of `object`'s details are found by: 「volume-basics:mountpoint」.
fn prefix(object: DockerObject) -> &'static str {
match object {
DockerObject::Container => "container",
DockerObject::Image => "image",
DockerObject::Volume => "volume",
DockerObject::Network => "network",
}
}
/// Open the details of a volume, an image or a network of the host of
/// `terminal`, whose connection reads them. `dispatch` is the workspace's
/// focus handle: the dialog's button closes it and dispatches there.
pub fn open_object_dialog(
summary: ObjectSummary,
terminal: WeakEntity<TerminalView>,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut App,
) {
let view = cx.new(|cx| ObjectDetailsView::new(summary.clone(), terminal, cx));
window.open_dialog(cx, move |dialog, window, cx| {
let title = v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.child(div().min_w_0().truncate().child(summary.name.clone()))
.when_some(summary.tag.clone(), |line, (tag, tone)| {
line.child(soft_tag(tag, tone_color(tone, cx)).flex_shrink_0())
}),
)
.child(
div()
.truncate()
.text_sm()
.font_weight(FontWeight::NORMAL)
.text_color(cx.theme().muted_foreground)
.child(format!("{} · {}", summary.object.label(), summary.detail)),
);
let remove = {
let dispatch = dispatch.clone();
let action = RemoveDockerObject {
object: summary.object,
id: summary.id.clone(),
name: summary.name.clone(),
};
Button::new("docker-dialog-remove")
.outline()
.icon(Icon::new(CatalogIcon::Trash))
.label(t!("common.delete"))
.when_some(summary.removable.clone().err(), |button, why| {
button.disabled(true).tooltip(why)
})
.on_click(move |_, window, cx| {
// One dialog at a time: the confirmation takes its place.
window.close_dialog(cx);
dispatch.dispatch_action(&action, window, cx);
})
};
dialog
.title(title)
// Dialog geometry is an API boundary that takes `Pixels`; the
// width follows the interface zoom through the rem.
.w(rems(DIALOG_WIDTH).to_pixels(window.rem_size()))
.max_h(window.viewport_size().height * 0.9)
// Closed by its button or Escape, like every dialog.
.overlay_closable(false)
.child(view.clone())
.footer(DialogFooter::new().justify_start().child(remove))
});
}
/// The dialog's body: what `docker … inspect` says about it.
pub struct ObjectDetailsView {
summary: ObjectSummary,
details: Read<Vec<DetailSection>>,
_task: Task<()>,
}
impl ObjectDetailsView {
fn new(
summary: ObjectSummary,
terminal: WeakEntity<TerminalView>,
cx: &mut Context<Self>,
) -> Self {
let task = run(
&terminal,
inspect_command(summary.object, &summary.id),
cx,
|this: &mut Self, answer| {
let summary = &this.summary;
this.details = match answer {
Ok(output) => match match summary.object {
DockerObject::Volume => volume_details(&output, &summary.used_by),
DockerObject::Image => image_details(&output, &summary.used_by),
DockerObject::Network => network_details(&output),
DockerObject::Container => None,
} {
Some(sections) => Read::Known(sections),
None => Read::Unknown(gone(summary.object).into()),
},
Err(why) => Read::Unknown(why),
};
},
);
Self {
summary,
details: Read::Reading,
_task: task,
}
}
}
impl Render for ObjectDetailsView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let body = match &self.details {
Read::Known(sections) => {
render_sections(prefix(self.summary.object), sections, cx).into_any_element()
}
Read::Unknown(why) => message("docker-details-unknown", why.clone(), cx),
Read::Reading | Read::NotYet => reading("docker-details-reading", cx),
};
div().id("docker-details").test_support().child(body)
}
}
/// Why there are no details: it went since the list was read.
fn gone(object: DockerObject) -> SharedString {
match object {
DockerObject::Container => t!("docker.details.container_gone"),
DockerObject::Image => t!("docker.details.image_gone"),
DockerObject::Volume => t!("docker.details.volume_gone"),
DockerObject::Network => t!("docker.details.network_gone"),
}
}
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff. Show more