Compare commits

...
12 Commits
Author SHA1 Message Date
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
68 changed files with 10750 additions and 673 deletions

No files matched your search

+3
View File
@@ -24,8 +24,11 @@ on:
- "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/workspace/window_state.rs"
- "src/testing.rs"
- "tests/cli_bin.rs"
- "build.rs"
+23
View File
@@ -2,6 +2,29 @@
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
## [0.1.4]
### 新增
- 终端主题:浅色、深色外观各有 10 套配色可选,界面也可以选择跟着主题配色。
- 终端关键字高亮:按规则给匹配的文字上色,匹配时还可以发通知。
- 设置新增「键盘快捷键」:查看、改绑、禁用或恢复默认快捷键,并新增切换标签的快捷键。
- 终端里 ⌘= / ⌘- / ⌘0(其他平台 Ctrl)调终端字号,终端外缩放界面。
- 设置 › 应用:记住窗口大小和位置。
- 设置 › 关于:官网链接。
- 外部 CLI 可以查看、新建、修改和删除主机与凭据(`shellrs hosts`、`shellrs credentials`),并新增目录同步 `shellrs sync`。
### 改进
- 主机列表的搜索也能按分组名找。
- 设置页切到别的标签再回来,停在原来的分类上。
- 终端字号范围改为 10–28。
- 外部 CLI 列出主机改为 `shellrs hosts list`。
### 修复
- 远程程序卡在半帧时,终端画面不再冻住。
## [0.1.3]
### 新增
+50 -7
View File
@@ -16,8 +16,8 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
用户定过的事,不要改回去。细节以 `docs/manual.md` 和代码为准。
- **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。暂不做目录同步、过滤、查找、目录树和 Dock 布局持久化。
- **主机树**:首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
- **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。SFTP 标签暂不做目录同步(外部 CLI 有 `shellrs sync`)、过滤、查找、目录树和 Dock 布局持久化。
- **主机树**:搜索匹配主机(名称、地址、用户)和分组名;分组名匹配时列出整个分组,回车只连本身匹配的第一台主机(按树的顺序)。首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
- **主机对话框**:
- 认证方式三选一:「密码」(留空就每次连接时询问,不悄悄试密钥)、「使用凭据」、「无密码」(依次试服务器免认证、SSH Agent、`~/.ssh` 默认私钥,服务器要密码就报错、不询问)。主机自己没有私钥文件,要用私钥就建密钥凭据。
- 「测试连接」是真登录,不强制填密码。
@@ -35,6 +35,11 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 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`,本地读不了的目录不清理,拒绝同步到 `/`。
- **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。
- **右侧栏**:窗口最右一列竖排的工具切换。
- 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。
@@ -50,13 +55,26 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
- 清单地址 `https://dl.shellrs.com/update/v1/{channel}.json`、平台 key 的名字和公钥槽位一经发布就不能改。
- 默认在后台检查并下载,下好、校验完才在标题栏亮按钮(`success` 色,出错时 `warning` 色)。
- 更新对话框不列更新内容:「查看更新内容」打开整页的 `https://shellrs.com/changelog`,另一个按钮是「重启并安装」,没有「稍后」。不点也在退出时装好。
- 「设置 › 关于」只有「应用更新」一组三行:当前版本、更新渠道(稳定版 / Beta,运行时切换)、自动升级。
- 「设置 › 关于」两组:「应用更新」三行(当前版本、更新渠道(稳定版 / Beta,运行时切换)、自动升级),「ShellRS」一行「官网」(打开 `https://shellrs.com`)。
- 检查只发 User-Agent,不发安装标识。
- **编辑器**:中间区的「编辑器」Dock 标签,不用系统程序打开。
- 双击、回车、F4、右键「编辑」都打开它,本地文件也行;新建文件后直接打开。
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
- **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。
- **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的黑白灰(gpui-kit 默认主题),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。页面「重置」对卡片和开关各管各的。
- 左栏只列浅色主题,右栏只列深色主题;卡片是名称加示例输出,选中的整张加底色,右上角是蓝底(主题的 `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;撞上同一处的另一个快捷键或输入框等内置键就拒绝并提示占用者,不抢、不允许重复。
- 存在 `settings.json` 的 `shortcuts`,只存和默认不同的(`null` 是禁用)。`Shortcut` 的 `id` 一经发布不能改。
- **关键字高亮**:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在 `settings.json`,所有终端共用。
- 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit `ColorPicker` 加 `#rrggbb` 输入框)、通知、删除(不确认)。规则都是正则,区分大小写;颜色是用户数据,任意 RGB,不跟主题;不做加粗。重叠时靠前的优先。
- 总开关默认关闭。首次(文件里没有这一项)预置三条示例:ERROR 红、WARN 琥珀、IPv4 蓝,各自启用、都不通知,打开总开关就能用;用户删光后保持为空。空的新行照样保存。
- 通知同响铃:看不到那个终端才提醒,同一终端 10 秒一次,标题用备注。全屏程序(备用屏)里不高亮也不通知。
- **预览**:图片和 Markdown(不做 HTML),显示在快速查看式的大对话框里,不是标签。
- 双击图片预览;Markdown 和 SVG 是文本,双击编辑,右键另有「预览」。
- 图片默认适合窗口居中,可放大、缩小、看原图、滚动;比例只写百分比。
@@ -101,7 +119,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `SshConnector`:所有登录都走它(终端、SFTP、exec、测试连接、端口转发、外部 CLI)。
- 远程终端的传输:pty、系统探测、延迟、旁路 exec。
- 代理握手;外部 CLI 用的 `run_command`。
- `terminal/`:本地 PTY、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。
- `terminal/`:本地 PTY、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、关键字高亮(`highlight.rs`)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。
- `sftp/`:可注入的传输和本地目录接口、`RemotePath`、`RemoteFs` 协议适配、worker(批次、三次重连、空闲断网检测),以及:
- `upload.rs` / `download.rs`:断点续传,续传记录由 `journal.rs` 管;
- `operations.rs`:删除、重命名、新建、改权限;
@@ -114,7 +132,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `monitor/`、`netstat/`、`processes/`、`services/`、`docker/`、`history/`、`snippets/`:右侧栏的七个工具。各自是「命令(`linux.rs` 等)+ 纯解析和模型(`model.rs`)+ 面板」,确认和通知在工作区。
- `secrets/`:`SecretStore`(钥匙串)、`SecretRef`(条目归属)、`TemporarySecretStore`(不保存的连接的密码只在内存)。
- `settings/`:`AppSettings`、写 `settings.json` 的 `SettingsStore`、把设置落到窗口上的 `apply`、设置标签。
- `cli/`:外部 CLI(`shellrs list/exec/upload/download`,给 AI Agent 用)、套接字协议、应用里的服务端、PATH 和 Agent skill 的安装。
- `cli/`:外部 CLI(`shellrs hosts/credentials/exec/upload/download/sync`,给 AI Agent 用)、套接字协议、应用里的服务端、主机和凭据改动的校验(`manage.rs`)、PATH 和 Agent skill 的安装。
- `update/`:在线升级,包括清单、验签、下载、各平台安装器和 `Updater`。
- `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` 等)。
@@ -136,7 +154,11 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- 改表要升版本、在 `STEPS` 里加一步,并加一个「用冻结的旧 schema 升级、再和新库比对」的测试。
- 加列直接 `ALTER TABLE ADD COLUMN`,在 `SCHEMA` 里也写在最后一列之后。
- 库只往前升:旧版本遇到新库直接报错、不动文件。迁移前先 `VACUUM INTO` 备份。
- **一个数据目录只跑一个 ShellRS。** 起图形界面之前先连外部 CLI 的套接字:连得上就发 `Request::Activate`(带启动链接)然后退出。为兼容已装的 CLI,不升 `PROTOCOL_VERSION`。
- **窗口的大小和位置存在 `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`。
### GPUI / gpui-kit
@@ -161,9 +183,15 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **滚动条挂在不滚动的外层上**,或直接用 `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` 改,否则右侧栏不跟着变。
@@ -231,6 +259,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- 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` 默认没有路径。
- **Windows**:
@@ -248,8 +282,17 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **查找条必须是终端 `div` 的兄弟**,不能是子元素,否则输入会被发给 shell;它还要 `.occlude()`。
- **清屏只在本地模拟器里做**,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的 `FocusSearch` 深一层,所以优先。
- **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。
- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(`theme.blue`,主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。
- **终端颜色走全局量 `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 / Dark 和「界面跟随主题」关闭时都直接用 gpui-kit 的默认主题(`TerminalThemeSettings::app_theme`)。`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,看不到系统通知。
Generated
+3 -1
View File
@@ -7702,11 +7702,12 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"alacritty_terminal",
"anyhow",
"async-channel",
"async-io",
"base64 0.22.1",
"chrono",
"clap",
@@ -7724,6 +7725,7 @@ dependencies = [
"percent-encoding",
"portable-pty",
"rand 0.9.5",
"regex",
"reqwest",
"rusqlite",
"russh",
+7 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shellrs"
version = "0.1.3"
version = "0.1.4"
edition = "2024"
publish = false
description = "SSH client and host manager: terminal, SFTP, port forwarding"
@@ -44,6 +44,9 @@ gpui-kit = { version = "0.6.1", features = [
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"
@@ -52,6 +55,9 @@ 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"
russh-sftp = "=3.0.0"
serde_json = "1"
+3 -2
View File
@@ -84,7 +84,7 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
**A CLI for AI agents**
- `shellrs list` / `exec` / `upload` / `download`. The running ShellRS logs in on the command's behalf, so the command itself never handles a password or private key.
- `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**
@@ -141,10 +141,11 @@ Once installed, ShellRS keeps itself up to date: it checks, downloads and verifi
In 设置 › 外部 CLI (Settings › External CLI), turn on 启用外部 CLI (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 # list hosts and their IDs
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
```
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.md#外部-cli) (in Chinese) for details.
+3 -2
View File
@@ -84,7 +84,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
**给 AI Agent 用的 CLI**
- `shellrs list` / `exec` / `upload` / `download`,由正在运行的 ShellRS 代为登录,命令本身不接触密码和私钥。
- `shellrs hosts` / `credentials` / `exec` / `upload` / `download` / `sync`:管理主机和凭据、执行命令、传输和同步文件。由正在运行的 ShellRS 代为登录,命令本身不读密码和私钥。
- 在设置页一键把 `shellrs` 放进 PATH,并为 Claude Code、Codex、OpenCode、WorkBuddy 安装 Agent Skill。
**安全与更新**
@@ -141,10 +141,11 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
在「设置 › 外部 CLI」里打开「启用外部 CLI」,安装 `shellrs` 命令和 Agent Skills。之后 Agent 就能直接用你保存的主机:
```sh
shellrs list # 列出主机和它们的 ID
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)。
+35 -12
View File
@@ -38,6 +38,8 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
列表上方「新建主机…」左边的「快速连接」打开一个搜索框,列出所有保存的主机:最近连接过的在前,其余按名称排,每行写着分组和 `用户@地址:端口`。输入名称、地址、用户名、备注或分组里的字就能筛选,几个词用空格隔开时都要匹配。打开时不高亮任何一台;输入后高亮第一个匹配的,回车就连它;也可以 ↑↓ 移动,或者直接单击一台连接。
列表上方的搜索框(⌘K,其他平台 Ctrl+K)按名称、地址、用户名筛选主机,也按名称找分组:分组名匹配时列出这个分组和它下面的全部子分组、主机。结果都展开,匹配的主机连同它所在的各级分组一起显示。回车连接第一台匹配的主机(按列表里的顺序;只因分组匹配而列出的主机不算)。Esc 清空搜索。
拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。
## 临时连接
@@ -47,7 +49,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- **功能齐全**:在新标签页中连接、打开 SFTP、重新连接和右侧栏的全部工具都能用。连接方式是直接连接。
- **不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」。
- **密码只在内存里**:填的密码不进钥匙串,重新连接时照样用它;没填时服务器要密码就弹框问。选「使用凭据」时用凭据保存的密码或私钥。
- 外部 CLI 的 `shellrs list` 也列出它(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时可以用它的 ID。
- 外部 CLI 的 `shellrs hosts list` 也列出它(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时可以用它的 ID。
要保存的主机在主机列表标题栏的「+」、右键菜单或 ⌘N(其他平台 Ctrl+N)新建。
@@ -96,25 +98,33 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- **删除**:右键「删除」。有主机在用时确认框写明数量和它们之后怎么登录:用密码凭据的主机改为「密码」,连接时询问;用密钥或 SSH Agent 凭据的主机改为「无密码」。用户名不变,密码和私钥不会复制过去。已经打开的连接不受影响,凭据的密码随凭据一起从钥匙串删除。
- **SSH Agent 凭据**连不上 Agent、Agent 里没有密钥或服务器不接受其中任何一把时,会直接说明原因,不会改为询问密码。「无密码」的主机也会尝试同一个 Agent,连不上时静默跳过,接着试默认私钥(Windows 上同样如此)。
**尚未实现:在主机对话框里直接新建凭据、通过外部 CLI 管理凭据、凭据排序、自定义 SSH Agent 的套接字、把公钥自动装到主机上、导出 ShellRS 保存的私钥。**
**尚未实现:在主机对话框里直接新建凭据、凭据排序、自定义 SSH Agent 的套接字、把公钥自动装到主机上、导出 ShellRS 保存的私钥。**
## 设置
侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、外部 CLI、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。
侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、关键字高亮、键盘快捷键、外部 CLI、应用、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。
- **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认简体中文。英文界面尚在翻译中,目前只有 GPUI Kit 组件自带的文字(如搜索框提示)会切换。
- **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。
- **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(8–32 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
- **外观 → 主题**:左栏是浅色主题,右栏是深色主题,各选一套:应用外观为浅色时用左栏选中的那套,深色时用右栏的;跟随系统时随系统一起切换,标题栏的主题按钮切换时也一样。默认只有终端按所选主题配色,界面保持中性的黑白灰;打开上方的「界面跟随主题」,界面也按主题配色:侧栏、标签栏、对话框等用主题的底色和文字色,成功、警告、错误等状态用主题的绿、黄、红。每张卡片用该主题的颜色显示一段示例,选中的右上角有蓝色对勾。默认是 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 回到默认。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。
- **键盘快捷键**:主要功能的快捷键,分通用、标签、终端、编辑器四组,每行是功能名和它的按键。点按键进入「按下组合键」,按下新的组合就换上;Esc 或点别处放弃。行尾两个按钮:禁用(之后显示「无」,再点一下恢复默认)、恢复默认;页面上方的「重置」也能逐项恢复。改动立即生效,工具栏提示和右键菜单里显示的按键跟着变。
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12;「切换到标签 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-<数据目录的散列>`。
`shellrs` 命令让 Codex、Claude Code、OpenCode 等 AI Agent 在 ShellRS 里已保存的主机上执行远程命令、传输和同步文件,也能查看、新建、修改和删除主机与凭据。命令本身不读密码和私钥,也不读数据库:它把请求交给正在运行的 ShellRS,由 ShellRS 用保存的配置和钥匙串去连接。请求走本机的进程间通信:macOS、Linux 上是数据目录下的套接字 `cli.sock`,Windows 上是命名管道 `\\.\pipe\shellrs-cli-<数据目录的散列>`。
在 设置 → 外部 CLI 中:
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据(没有另外的开关)。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
- **CLI 二进制**:把 `shellrs` 放进 PATH。
- macOS:链接到 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框。
- Linux:链接到 `~/.local/bin/shellrs`。
@@ -125,17 +135,29 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
命令(`shellrs --help` 查看完整说明):
```sh
shellrs list [--query <关键词>] [--json] # 列出主机;输出不是终端时自动用 JSON
shellrs hosts list [-q <关键词>] [--json] # 列出主机;输出不是终端时自动用 JSON
shellrs hosts show <ID> [--json] # 一台主机的全部配置
shellrs exec <ID> "<命令>" # 或 --stdin 从标准输入读命令
shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON
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 list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。
- `<ID>` 是 16 位的主机 ID,即 `shellrs hosts list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。
- `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。
- `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 太旧、不认识这个命令)。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 不会看到多余的回车符。
@@ -160,7 +182,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
- 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 list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
## 标签页
@@ -176,6 +198,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。
- **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键始终是 ShellRS 的菜单。
- **通知**:程序可以请求通知(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 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
## 右侧栏
@@ -373,7 +396,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
文件名不能准确表示时会明确报错。当前 `russh-sftp 3.0.0` 的远端字符串解码会替换无效 UTF-8,浏览器因此拒绝包含替换字符 `U+FFFD` 的远端名称,避免访问错误路径。符号链接沿用 OpenSSH 的 SFTP v3 参数顺序。
**尚未实现:目录同步、前台传输进度对话框、队列排序、过滤、查找文件、目录树和 Dock 布局持久化。**
**尚未实现:SFTP 标签里的目录同步(外部 CLI 的 `shellrs sync` 可以同步)、前台传输进度对话框、队列排序、过滤、查找文件、目录树和 Dock 布局持久化。**
## 下载与更新
+17
View File
@@ -103,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.
@@ -113,6 +119,12 @@ 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.
@@ -144,6 +156,8 @@ gpui_kit::actions!(
OpenDownloadPage,
/// Open the website's changelog.
OpenChangelog,
/// Open the ShellRS website.
OpenWebsite,
]
);
@@ -222,6 +236,9 @@ id_actions! {
/// 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.
+6 -1
View File
@@ -52,6 +52,9 @@ icon_assets!(
RotateCcwClock,
Activity,
Monitor,
AppWindow,
Keyboard,
RotateCcw,
Network,
RadioTower,
ArrowDownLeft,
@@ -72,7 +75,9 @@ icon_assets!(
ZoomIn,
ZoomOut,
Zap,
ExternalLink
ExternalLink,
Highlighter,
GripVertical
]
);
+11 -250
View File
@@ -5,6 +5,7 @@ mod app_icon;
mod assets;
mod paths;
mod quit;
mod shortcuts;
mod window_hiding;
pub use actions::*;
@@ -12,15 +13,18 @@ pub use app_icon::show_logo_when_unbundled;
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,
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};
/// Key context of the host panel, for bindings that only apply there.
pub const HOST_PANEL_CONTEXT: &str = "HostPanel";
pub const RECENT_HOSTS_CONTEXT: &str = "RecentHosts";
@@ -39,9 +43,12 @@ pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList";
/// 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());
// 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));
}
@@ -65,249 +72,3 @@ pub(crate) fn host_tree_selection_color(theme: &Theme) -> Hsla {
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";
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("alt-b"), ToggleToolSidebar, 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(
&primary("s"),
EditorShortcut(EditorCommand::Save),
Some(EDITOR_CONTEXT),
),
// Over a previewed image, the interface zoom keys zoom the image;
// 0 and 9 as in macOS Preview.
KeyBinding::new(&primary("="), ZoomPreviewIn, Some(IMAGE_PREVIEW_CONTEXT)),
KeyBinding::new(&primary("-"), ZoomPreviewOut, Some(IMAGE_PREVIEW_CONTEXT)),
KeyBinding::new(
&primary("0"),
ActualSizePreview,
Some(IMAGE_PREVIEW_CONTEXT),
),
KeyBinding::new(&primary("9"), FitPreview, Some(IMAGE_PREVIEW_CONTEXT)),
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
}
/// 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))
};
// 显示隐藏文件: Finder's ⌘⇧. (macOS reports it as ⌘>), WinSCP's
// Ctrl+Alt+H elsewhere.
let side = crate::explorer::PaneSide::from_remote(remote);
#[cfg(target_os = "macos")]
bindings.push(KeyBinding::new(
"cmd->",
ToggleHiddenFiles(side),
Some(context),
));
#[cfg(not(target_os = "macos"))]
bindings.push(KeyBinding::new(
"ctrl-alt-h",
ToggleHiddenFiles(side),
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),
// WinSCP's 编辑.
bind("f4", ExplorerCommand::Edit { remote, path: None }, 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,
));
}
}
}
bindings
}
+7
View File
@@ -50,6 +50,13 @@ 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")
}
/// 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 {
+1076
View File
File diff suppressed because it is too large. Load diff
+54 -7
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 复制 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:
@@ -37,8 +39,40 @@ 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. In Git Bash, use the heredoc form above.
@@ -60,15 +94,24 @@ $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
'@ | shellrs exec <host-id> --stdin
```
The `@'` must end its line and the `'@` must start one. Set the same encodings before `list`, `upload` and `download`, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake.
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
@@ -77,7 +120,11 @@ Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. E
- `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_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.
- `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it.
- `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.
+38 -3
View File
@@ -10,6 +10,7 @@ use std::{
use super::protocol::{CliError, ErrorCode, TransferCounters, TransferSummary};
use super::server::{CliBackend, CliTarget};
use crate::connection::{ConnectionPromptKind, ConnectionPromptReply};
use crate::secrets::{SecretRef, SharedSecretStore};
use crate::sftp::{
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider,
TransferAnswer, TransferChoice, TransferPhase, TransferProgress, TransferQuestionKind,
@@ -25,13 +26,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
@@ -166,6 +178,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,
format!("本地目录不存在:{}", 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.
@@ -222,6 +256,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(|| {
+339 -38
View File
@@ -12,8 +12,9 @@ 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, TransferCounters, TransferSummary, parse_json, read_frame, write_json,
};
use crate::host::HostOs;
@@ -77,6 +78,33 @@ pub(super) fn ascii_json(value: &impl serde::Serialize) -> String {
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,
}
}
}
/// What an app from before a request was added says about it: it cannot
/// read the request, and serde names what it did not know.
const OLDER_APP: &str =
"正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它";
/// 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 {
@@ -196,6 +224,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,
@@ -239,7 +268,29 @@ 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);
}
Reply::Exit { code } if console.exec_json => {
let result = ExecResult {
exit_code: code,
@@ -251,6 +302,12 @@ where
}
Reply::Exit { code } => return Ok(code),
Reply::Activated => return Ok(0),
Reply::Error {
code: ErrorCode::BadRequest,
message,
} if message.contains("unknown variant") => {
return Ok(console.error(ErrorCode::VersionMismatch, OLDER_APP));
}
Reply::Error { code, message } => return Ok(console.error(code, &message)),
}
}
@@ -289,10 +346,268 @@ impl ProgressLine {
}
}
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: [&str; 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)
.map(|row| row[column].width())
.max()
.unwrap_or(0)
})
.collect();
for row in std::iter::once(&header).chain(rows) {
let line: Vec<String> = row
.iter()
.zip(&widths)
.map(|(cell, width)| format!("{cell}{}", " ".repeat(width - cell.width())))
.collect();
writeln!(console.stdout, "{}", line.join(" ").trim_end())?;
}
Ok(())
}
/// Labelled lines, the labels lined up.
fn print_fields(fields: &[(&str, 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(())
}
fn saved_word(saved: Option<bool>) -> &'static str {
match saved {
Some(true) => "(已保存)",
Some(false) => "(未保存)",
None => "",
}
}
fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Result<()> {
if console.json {
return print_json(host, console);
}
match asked {
Asked::Create => {
return writeln!(
console.stdout,
"已创建主机「{}」,ID {}",
host.name, host.id
);
}
Asked::Update => {
return writeln!(
console.stdout,
"已保存主机「{}」(ID {})",
host.name, host.id
);
}
_ => {}
}
let auth = match host.auth {
AuthChoice::Password => format!("密码{}", saved_word(host.password_saved)),
AuthChoice::Credential => {
format!("凭据 {}", host.credential.as_deref().unwrap_or_default())
}
AuthChoice::NoPassword => "无密码".to_string(),
};
let route = match &host.route {
RouteDetails::Direct => "直接连接".to_string(),
RouteDetails::Jump { hosts } => format!(
"SSH 跳板 {}",
hosts
.iter()
.map(|hop| hop.as_deref().unwrap_or("(已删除的主机)"))
.collect::<Vec<_>>()
.join(" → ")
),
RouteDetails::Proxy {
kind,
host,
port,
user,
password_saved,
} => format!(
"{} {}{host}:{port}{}",
match kind {
ProxyChoice::Http => "HTTP 代理",
ProxyChoice::Socks5 => "SOCKS5 代理",
},
user.as_deref()
.map(|user| format!("{user}@"))
.unwrap_or_default(),
password_saved
.map(|saved| if saved {
"(密码已保存)"
} else {
"(密码未保存)"
})
.unwrap_or_default()
),
};
let mut fields = vec![
("ID", host.id.clone()),
("名称", host.name.clone()),
(
"分组",
if host.temporary {
"(未保存)".to_string()
} else {
host.group.clone().unwrap_or_default()
},
),
("地址", format!("{}@{}:{}", host.user, host.host, host.port)),
("认证", auth),
("连接方式", route),
];
if let Some(os) = host.os.as_deref().and_then(HostOs::from_stored) {
fields.push(("系统", os.label().to_string()));
}
if !host.notes.is_empty() {
fields.push(("备注", 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 mut line = format!("已删除主机「{}」", deleted.host.name);
if deleted.forwards > 0 {
line.push_str(&format!(",连同它的 {} 条端口转发规则", deleted.forwards));
}
if deleted.jump_users > 0 {
line.push_str(&format!(
";{} 台主机经它跳转,现在那一跳是「已删除的主机」",
deleted.jump_users
));
}
writeln!(console.stdout, "{line}")
}
fn kind_label(kind: CredentialKindChoice) -> &'static str {
match kind {
CredentialKindChoice::Password => "密码",
CredentialKindChoice::Key => "密钥",
CredentialKindChoice::Agent => "SSH 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, "没有匹配的凭据");
}
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(["ID", "名称", "类型", "用户名", "主机数"], &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,
"已创建凭据「{}」,ID {}",
credential.name, credential.id
);
}
Asked::Update => {
return writeln!(
console.stdout,
"已保存凭据「{}」(ID {})",
credential.name, credential.id
);
}
_ => {}
}
let mut fields = vec![
("ID", credential.id.clone()),
("名称", credential.name.clone()),
(
"类型",
format!(
"{}{}",
kind_label(credential.kind),
saved_word(credential.password_saved)
),
),
("用户名", credential.user.clone()),
];
if credential.kind == CredentialKindChoice::Key {
let key = match (&credential.key_path, credential.kept) {
(Some(path), _) => path.clone(),
(None, _) => "由 ShellRS 保存".to_string(),
};
fields.push(("私钥", key));
if let Some(saved) = credential.passphrase_saved {
fields.push(("口令", if saved { "已保存" } else { "未保存" }.to_string()));
}
}
fields.push(("主机", 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 = format!("已删除凭据「{}」", deleted.credential.name);
if !deleted.released.is_empty() {
let names: Vec<&str> = deleted
.released
.iter()
.map(|host| host.name.as_str())
.collect();
line.push_str(&format!(
";{} 台主机改为自己登录:{}",
names.len(),
names.join("、")
));
}
writeln!(console.stdout, "{line}")
}
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);
return print_json(&hosts, console);
}
if hosts.is_empty() {
return writeln!(console.stderr, "没有匹配的主机");
@@ -319,44 +634,30 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
]
})
.collect();
let header = ["ID", "名称", "分组", "地址", "系统"].map(String::from);
let widths: Vec<usize> = (0..5)
.map(|column| {
std::iter::once(&header)
.chain(&rows)
.map(|row| row[column].width())
.max()
.unwrap_or(0)
})
.collect();
for row in std::iter::once(&header).chain(&rows) {
let line: Vec<String> = row
.iter()
.zip(&widths)
.map(|(cell, width)| format!("{cell}{}", " ".repeat(width - cell.width())))
.collect();
writeln!(console.stdout, "{}", line.join(" ").trim_end())?;
}
Ok(())
print_table(["ID", "名称", "分组", "地址", "系统"], &rows, console)
}
fn print_summary(summary: &TransferSummary, console: &mut Console) -> io::Result<i32> {
/// 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 {
serde_json::to_writer_pretty(&mut *console.stdout, summary).map_err(io::Error::other)?;
writeln!(console.stdout)?;
print_json(summary, console)?;
} else {
writeln!(
console.stdout,
"已传输 {} 个文件,共 {}{}",
let mut line = format!(
"已传输 {} 个文件,共 {}",
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} 个"),
}
)?;
format_bytes(summary.bytes)
);
if summary.skipped > 0 {
let word = if sync { "未变" } else { "跳过" };
line.push_str(&format!(",{word} {} 个", summary.skipped));
}
if summary.deleted > 0 {
line.push_str(&format!(",删除 {} 个", summary.deleted));
}
if summary.failed > 0 {
line.push_str(&format!(",失败 {} 个", summary.failed));
}
writeln!(console.stdout, "{line}")?;
for failure in &summary.failures {
writeln!(console.stderr, "{failure}")?;
}
+1117
View File
File diff suppressed because it is too large. Load diff
+287 -20
View File
@@ -10,6 +10,7 @@ mod install;
mod install_windows;
mod integration;
mod link;
mod manage;
#[cfg(windows)]
mod pipe_windows;
mod protocol;
@@ -34,20 +35,60 @@ pub use install::{
};
pub use integration::{CliIntegration, IntegrationStatus};
pub use link::{OpenLink, link_arguments};
pub use protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
pub use server::{CliBackend, CliServer, CliTarget};
pub use manage::CliChange;
pub(crate) use manage::{
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
find_credential, 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, TransferCounters, TransferSummary,
};
pub use server::{ChangeReply, CliBackend, CliServer, CliTarget};
const AFTER_HELP: &str = "\
Hosts are named by the 16-character ID that `shellrs list` prints (the one ShellRS copies with 复制 ID).
Hosts are named by the 16-character ID that `shellrs hosts list` prints (the one ShellRS copies with 复制 ID).
ShellRS must be running, with 设置 → 外部 CLI → 启用外部 CLI turned on.
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`).";
/// Run commands and move files on the SSH hosts saved in ShellRS.
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 \"生产/数据库\", 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.";
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 {
@@ -57,14 +98,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.
///
@@ -72,7 +114,7 @@ enum Command {
/// and closes it. The command gets no stdin. The exit code is the
/// remote command's.
Exec {
/// Host ID, from `shellrs list`.
/// Host ID, from `shellrs hosts list`.
#[arg(required_unless_present = "json")]
id: Option<String>,
/// One complete remote shell command, quoted as one argument.
@@ -97,7 +139,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,
@@ -111,7 +153,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,
@@ -121,6 +163,115 @@ 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
@@ -158,10 +309,6 @@ 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,
@@ -210,9 +357,129 @@ 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,
&format!("请从标准输入传入 JSON,例如 {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,
&format!("标准输入不是 JSON:{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, &format!("JSON 有误:{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, &format!("JSON 有误:{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()
+335 -1
View File
@@ -11,13 +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
@@ -104,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 {
@@ -126,6 +139,49 @@ pub enum Request {
source: String,
destination: PathBuf,
},
/// 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.
@@ -151,6 +207,14 @@ pub enum Reply {
Exit {
code: i32,
},
/// 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 {
@@ -198,8 +262,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>,
}
@@ -221,6 +290,12 @@ 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,
}
impl ErrorCode {
@@ -236,6 +311,9 @@ 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",
}
}
}
@@ -256,6 +334,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::*;
+256 -44
View File
@@ -3,24 +3,34 @@
//! backend's, so tests can put a fake behind the same server.
use std::{
collections::VecDeque,
io::{self, BufReader},
path::{Path, PathBuf},
sync::{
Arc, Mutex, RwLock,
atomic::{AtomicBool, Ordering},
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_secrets, host_details, host_info, 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, TransferCounters, TransferSummary, VersionOnly, parse_json,
read_frame, write_frame, write_json,
};
use crate::host::{Host, HostLogin, HostStore, matches_query};
use crate::secrets::SecretRef;
use crate::ssh::ExecStream;
/// Does what a CLI request asks. Every method blocks: each request has a
@@ -56,6 +66,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
@@ -63,35 +89,23 @@ 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()),
temporary: false,
},
info: host_info(host, store),
details: host_details(host, store),
secrets: host_secrets(host, store),
host: host.clone(),
login,
login: store.login_of(host),
}
}
/// Marked as not saved.
fn temporary(mut self) -> Self {
self.info.temporary = true;
self
}
pub fn host(&self) -> &Host {
&self.host
}
@@ -106,22 +120,12 @@ impl CliTarget {
/// 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> {
let saved = 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("/")),
)
});
let temporary = store
.temporary_hosts()
store
.hosts()
.iter()
.map(|host| Self::new(host, store.login_of(host), None).temporary());
saved.chain(temporary).collect()
.chain(store.temporary_hosts())
.map(|host| Self::of(host, store))
.collect()
}
/// The host tree's search, plus the group path and the ID.
@@ -137,15 +141,54 @@ 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)
};
/// 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, 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>>,
next_change: AtomicU64,
}
/// Listens on the CLI socket until dropped. Always listening, even with
@@ -164,8 +207,11 @@ impl CliServer {
let shared = Arc::new(Shared {
enabled: AtomicBool::new(false),
targets: RwLock::new(Vec::new()),
credentials: RwLock::new(Vec::new()),
backend,
activation: Mutex::new(None),
changes: Mutex::new(VecDeque::new()),
next_change: AtomicU64::new(0),
});
let listener = listen(endpoint, shared.clone())?;
Ok(Self {
@@ -178,12 +224,38 @@ 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;
}
/// 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))
}
/// Whether ShellRS was opened again since this was last asked, and the
@@ -199,6 +271,18 @@ impl CliServer {
}
}
impl Drop for CliServer {
/// The app is going: whatever waits for it to take up a change hears
/// that it never will.
fn drop(&mut self) {
self.shared
.changes
.lock()
.unwrap_or_else(|error| error.into_inner())
.clear();
}
}
/// The listening thread, stopped when dropped. On Unix, the socket file
/// goes with it.
#[cfg(unix)]
@@ -436,11 +520,15 @@ 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 let Request::Activate { open } = &envelope.request {
if let Ok(Envelope {
request: Request::Activate { open },
..
}) = &envelope
{
shared
.activation
.lock()
@@ -449,13 +537,17 @@ fn respond(
.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",
));
}
let envelope = envelope.map_err(bad_request)?;
if !shared.enabled.load(Ordering::Acquire) {
return Err(CliError::new(
ErrorCode::NotEnabled,
@@ -520,11 +612,131 @@ 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(|| {
CliError::new(
ErrorCode::CredentialNotFound,
format!(
"没有 ID 为 {credential} 的凭据:请用 shellrs credentials list 查看"
),
)
})?;
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),
}
}
/// 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,
"ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成",
)
};
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,
"ShellRS 没有及时处理这项改动,改动没有做:请稍后再试",
));
}
}
// Taken up just now: being made.
outcome.recv().unwrap_or_else(|_| Err(gone()))
}
fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
shared
.targets
@@ -536,7 +748,7 @@ fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
.ok_or_else(|| {
CliError::new(
ErrorCode::HostNotFound,
format!("没有 ID 为 {id} 的主机:请用 shellrs list 查看"),
format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"),
)
})
}
+410 -15
View File
@@ -12,12 +12,19 @@ use clap::Parser as _;
use super::client::ascii_json;
use super::client::{self, Console};
use super::link::OpenLink;
use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
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};
use super::{Cli, Command};
use super::{Cli, Command, CredentialsCommand, HostsCommand};
use super::{ConsoleText, exec_request, normalize_command};
use crate::app::cli_endpoint;
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin, HostStore, SshLink};
use crate::host::{
AuthKind, CredentialDraft, CredentialKind, GroupDraft, Host, HostDraft, HostId, HostStore,
SshLink,
};
use crate::secrets::SecretRef;
use crate::ssh::ExecStream;
@@ -70,6 +77,7 @@ impl CliBackend for FakeBackend {
bytes: 20,
skipped: 0,
failed: 1,
deleted: 0,
failures: vec!["无法上传:permission denied".into()],
})
}
@@ -83,13 +91,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 {
@@ -105,12 +147,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,
@@ -395,7 +434,7 @@ fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
assert_eq!(targets[1].login().password, SecretRef::temporary(link.0));
let fixture = fixture();
fixture.server.set_targets(targets);
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();
@@ -694,3 +733,359 @@ fn exec_json_gathers_the_output_and_says_errors_in_json() {
);
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 不认识这个命令"),
);
}
+10 -8
View File
@@ -25,7 +25,7 @@ use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, Unknow
use super::secret_fields::SecretFields;
use super::{
AuthKind, Credential, CredentialId, CredentialKind, DEFAULT_USER, GroupId, Host, HostDraft,
HostId, HostLogin, HostStore, ProxyKind, ProxySettings, Route, group_options,
HostDraftError, HostId, HostLogin, HostStore, ProxyKind, ProxySettings, Route, group_options,
};
pub use crate::shared::DeleteHandler;
use crate::shared::{
@@ -504,16 +504,18 @@ impl HostForm {
fn committed_route(&self, cx: &App) -> Result<Route, &'static str> {
match self.route {
RouteChoice::Direct => Ok(Route::Direct),
RouteChoice::Jump if self.hops.is_empty() => Err("请添加跳板主机"),
RouteChoice::Jump if self.hops.contains(&None) => Err("请移除已删除的跳板主机"),
RouteChoice::Jump if self.hops.is_empty() => Err(HostDraftError::NoJumpHosts.message()),
RouteChoice::Jump if self.hops.contains(&None) => {
Err(HostDraftError::DeletedJumpHost.message())
}
RouteChoice::Jump => Ok(Route::Jump(self.hops.clone())),
RouteChoice::Proxy => {
let host = self.proxy_host.read(cx).value().trim().to_string();
if host.is_empty() {
return Err("请输入代理地址");
return Err(HostDraftError::ProxyAddress.message());
}
let port = parse_port(self.proxy_port.read(cx).value().trim())
.ok_or("代理端口必须是 1 到 65535 之间的数字")?;
.ok_or(HostDraftError::ProxyPort.message())?;
let user = self.proxy_user.read(cx).value().trim().to_string();
if user.is_empty() && !self.proxy_secret.read(cx).password(cx).is_empty() {
return Err("填写代理密码时请同时填写用户名");
@@ -544,8 +546,8 @@ impl HostForm {
let address = self.address.read(cx).value().trim().to_string();
let port = parse_port(self.port.read(cx).value().trim());
match (address.is_empty(), port) {
(true, _) => Err("请输入地址"),
(_, None) => Err("端口必须是 1 到 65535 之间的数字"),
(true, _) => Err(HostDraftError::Address.message()),
(_, None) => Err(HostDraftError::Port.message()),
(_, Some(port)) => Ok((address, port)),
}
}
@@ -680,7 +682,7 @@ impl HostForm {
pub fn commit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let name = self.name.read(cx).value().trim().to_string();
let checked = if name.is_empty() {
Err("请输入名称")
Err(HostDraftError::Name.message())
} else {
self.endpoint(cx).and_then(|endpoint| {
Ok((
+5 -9
View File
@@ -30,7 +30,8 @@ use crate::app::{
use crate::shared::{HostMark, RowTooltip, RowTooltips};
use super::{
GroupId, Host, HostId, HostNode, HostOs, HostStore, NodeDrop, host_tree_items, matches_query,
GroupId, Host, HostId, HostNode, HostOs, HostStore, NodeDrop, first_matching_host,
host_tree_items,
};
/// The host list of the left dock: a searchable, grouped tree of hosts.
@@ -82,7 +83,7 @@ impl HostPanel {
let tree_state = cx.new(|cx| TreeState::new(cx).items(items));
let search = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("搜索主机")
.placeholder("搜索主机或分组")
.clean_on_escape()
});
@@ -258,13 +259,8 @@ impl HostPanel {
}
fn connect_first_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let first = self
.store
.read(cx)
.hosts()
.iter()
.find(|host| matches_query(host, &self.query))
.map(|host| host.id);
let store = self.store.read(cx);
let first = first_matching_host(store.groups(), store.hosts(), &self.query);
if let Some(id) = first {
window.dispatch_action(Box::new(ConnectHost(id)), cx);
}
+3 -1
View File
@@ -28,7 +28,9 @@ pub use host_dialog::{
pub use host_panel::{HostPanel, host_menu};
pub use login::{HostLogin, JumpLogin, LoginMethod, LoginRoute, ProxyLogin};
pub use model::*;
pub use outline::{HostNode, NodeDrop, group_options, host_tree_items, matches_query};
pub use outline::{
HostNode, NodeDrop, first_matching_host, group_options, host_tree_items, matches_query,
};
pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key};
pub use quick_connect::{QuickChoice, QuickHost, open_quick_connect_dialog, quick_hosts};
pub use secret_fields::SecretFields;
+81
View File
@@ -583,8 +583,89 @@ impl HostDraft {
pub fn password_secret(&self) -> SecretRef {
SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port)
}
/// The draft with surrounding blanks removed and an empty user name
/// taken as [`super::DEFAULT_USER`], or the first thing wrong with it,
/// in the order the host form checks. What the form cannot leave out
/// of a draft (a credential to pick, a proxy password without a user)
/// is the caller's to check.
pub fn validated(mut self) -> Result<Self, HostDraftError> {
self.name = self.name.trim().to_string().into();
if self.name.is_empty() {
return Err(HostDraftError::Name);
}
self.address = self.address.trim().to_string().into();
if self.address.is_empty() {
return Err(HostDraftError::Address);
}
if self.port == 0 {
return Err(HostDraftError::Port);
}
let user = self.user.trim();
self.user = if user.is_empty() {
super::DEFAULT_USER.into()
} else {
user.to_string().into()
};
match &mut self.route {
Route::Direct => {}
Route::Jump(hops) if hops.is_empty() => return Err(HostDraftError::NoJumpHosts),
Route::Jump(hops) if hops.contains(&None) => {
return Err(HostDraftError::DeletedJumpHost);
}
Route::Jump(_) => {}
Route::Proxy(proxy) => {
proxy.host = proxy.host.trim().to_string().into();
if proxy.host.is_empty() {
return Err(HostDraftError::ProxyAddress);
}
if proxy.port == 0 {
return Err(HostDraftError::ProxyPort);
}
let user = proxy.user.as_deref().map(str::trim).unwrap_or_default();
proxy.user = (!user.is_empty()).then(|| user.to_string().into());
}
}
self.notes = self.notes.trim().to_string().into();
Ok(self)
}
}
/// Why a host draft cannot be saved. `Display` is the text the host form
/// shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostDraftError {
Name,
Address,
Port,
NoJumpHosts,
DeletedJumpHost,
ProxyAddress,
ProxyPort,
}
impl HostDraftError {
pub fn message(self) -> &'static str {
match self {
HostDraftError::Name => "请输入名称",
HostDraftError::Address => "请输入地址",
HostDraftError::Port => "端口必须是 1 到 65535 之间的数字",
HostDraftError::NoJumpHosts => "请添加跳板主机",
HostDraftError::DeletedJumpHost => "请移除已删除的跳板主机",
HostDraftError::ProxyAddress => "请输入代理地址",
HostDraftError::ProxyPort => "代理端口必须是 1 到 65535 之间的数字",
}
}
}
impl std::fmt::Display for HostDraftError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.message())
}
}
impl std::error::Error for HostDraftError {}
#[cfg(test)]
mod tests {
use super::*;
+93 -24
View File
@@ -69,11 +69,18 @@ pub fn matches_query(host: &Host, query: &str) -> bool {
.any(|field| field.to_lowercase().contains(&query))
}
/// Case-insensitive match on a group's name.
fn group_matches_query(group: &HostGroup, query: &str) -> bool {
let query = query.trim().to_lowercase();
!query.is_empty() && group.name.to_lowercase().contains(&query)
}
/// Build the nested tree. Each level lists its subgroups first, then the
/// hosts that belong to it; hosts with no group sit at the root beside
/// the top-level groups. A non-empty query keeps only matching hosts, drops
/// the groups left without any, and expands what remains; otherwise
/// `expanded` decides which groups are open.
/// the top-level groups. A non-empty query keeps the matching hosts and
/// the matching groups with everything in them, drops the groups left
/// without either, and expands what remains; otherwise `expanded` decides
/// which groups are open.
pub fn host_tree_items(
groups: &[HostGroup],
hosts: &[Host],
@@ -81,7 +88,44 @@ pub fn host_tree_items(
expanded: &HashSet<GroupId>,
) -> Vec<TreeItem> {
let filtering = !query.trim().is_empty();
items_under(None, 0, groups, hosts, query, filtering, expanded)
let level = Level {
groups,
hosts,
query,
filtering,
expanded,
};
items_under(&level, None, 0, false)
}
/// The first host the query itself matches, in the order the tree lists
/// them: what Enter in the search field connects. A host shown only because
/// its group matched does not count.
pub fn first_matching_host(groups: &[HostGroup], hosts: &[Host], query: &str) -> Option<HostId> {
fn first(items: &[TreeItem], hosts: &[Host], query: &str) -> Option<HostId> {
items.iter().find_map(|item| {
match HostNode::parse(&item.id)? {
HostNode::Host(id) => hosts
.iter()
.find(|host| host.id == id)
.filter(|host| matches_query(host, query))
.map(|host| host.id),
HostNode::Group(_) => None,
}
.or_else(|| first(&item.children, hosts, query))
})
}
let items = host_tree_items(groups, hosts, query, &HashSet::new());
first(&items, hosts, query)
}
/// What every level of the tree is built from.
struct Level<'a> {
groups: &'a [HostGroup],
hosts: &'a [Host],
query: &'a str,
filtering: bool,
expanded: &'a HashSet<GroupId>,
}
/// The groups directly under `parent`, in display order. `None` asks for the
@@ -95,44 +139,37 @@ fn child_groups(groups: &[HostGroup], parent: Option<GroupId>) -> Vec<&HostGroup
children
}
/// The items under `parent`. `everything`: a group above matched the query,
/// so all of it is listed.
fn items_under(
level: &Level,
parent: Option<GroupId>,
depth: usize,
groups: &[HostGroup],
hosts: &[Host],
query: &str,
filtering: bool,
expanded: &HashSet<GroupId>,
everything: bool,
) -> Vec<TreeItem> {
// Nesting cannot run deeper than the number of groups unless the data has
// a cycle, which the store refuses to create. Stop rather than recurse
// forever if a hand-edited database ever produces one.
if depth > groups.len() {
if depth > level.groups.len() {
return Vec::new();
}
let mut items: Vec<TreeItem> = Vec::new();
for group in child_groups(groups, parent) {
let children = items_under(
Some(group.id),
depth + 1,
groups,
hosts,
query,
filtering,
expanded,
);
if filtering && children.is_empty() {
for group in child_groups(level.groups, parent) {
let matched = everything || group_matches_query(group, level.query);
let children = items_under(level, Some(group.id), depth + 1, matched);
if level.filtering && !matched && children.is_empty() {
continue;
}
items.push(
TreeItem::new(HostNode::Group(group.id).id(), group.name.clone())
.expanded(filtering || expanded.contains(&group.id))
.expanded(level.filtering || level.expanded.contains(&group.id))
.children(children),
);
}
let mut child_hosts: Vec<_> = hosts
let mut child_hosts: Vec<_> = level
.hosts
.iter()
.filter(|s| s.group == parent && matches_query(s, query))
.filter(|s| s.group == parent && (everything || matches_query(s, level.query)))
.collect();
child_hosts.sort_by_key(|host| (host.sort_order, host.id));
items.extend(
@@ -312,6 +349,38 @@ mod tests {
assert_eq!(paths, ["生产", "测试"]);
}
#[test]
fn a_query_matching_a_group_lists_all_of_it() {
let store = nested_store();
let items = host_tree_items(store.groups(), store.hosts(), "生产", &HashSet::new());
assert_eq!(labels(&items), ["生产"]);
assert!(items[0].is_expanded());
assert_eq!(labels(&items[0].children), ["数据库", "web-01"]);
assert_eq!(labels(&items[0].children[0].children), ["db-01"]);
// A subgroup: its ancestors for the way there, but not their hosts.
let items = host_tree_items(store.groups(), store.hosts(), "数据库", &HashSet::new());
assert_eq!(labels(&items), ["生产"]);
assert_eq!(labels(&items[0].children), ["数据库"]);
assert_eq!(labels(&items[0].children[0].children), ["db-01"]);
}
#[test]
fn enter_connects_the_first_host_the_query_matches_as_the_tree_lists_them() {
let store = nested_store();
let id = |name: &str| store.hosts().iter().find(|h| h.name == name).unwrap().id;
// In the tree, 数据库's db-01 comes before 生产's own web-01.
assert_eq!(
first_matching_host(store.groups(), store.hosts(), "-01"),
Some(id("db-01"))
);
// Hosts listed only because their group matched are not chosen.
assert_eq!(
first_matching_host(store.groups(), store.hosts(), "生产"),
None
);
}
#[test]
fn a_query_matching_only_a_root_host_drops_every_group() {
let store = nested_store();
+102 -14
View File
@@ -1,9 +1,10 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gpui_kit::{Context, EventEmitter, SharedString};
use gpui_kit::{Context, EventEmitter, SharedString, Task};
use zeroize::Zeroizing;
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TemporarySecretStore};
@@ -88,6 +89,9 @@ pub struct HostStore {
/// form are kept. `None` without a database to remember them, which is
/// also how most tests run: the form then offers key files only.
key_dir: Option<PathBuf>,
/// Inside [`Self::capture_failures`], the failures reported so far,
/// besides the events that report them.
capturing: RefCell<Option<Vec<SharedString>>>,
}
/// How many hosts the start page lists as recently connected.
@@ -133,6 +137,7 @@ impl HostStore {
database: None,
secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))),
key_dir: None,
capturing: RefCell::new(None),
}
}
@@ -202,6 +207,7 @@ impl HostStore {
database: Some(database),
secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))),
key_dir: None,
capturing: RefCell::new(None),
})
}
@@ -290,7 +296,7 @@ impl HostStore {
}
/// A saved host only: what the database's foreign keys may point at.
fn saved_host(&self, id: HostId) -> Option<&Host> {
pub(crate) fn saved_host(&self, id: HostId) -> Option<&Host> {
self.hosts.iter().find(|host| host.id == id)
}
@@ -1436,9 +1442,7 @@ impl HostStore {
if let Err(error) = std::fs::remove_file(path)
&& error.kind() != io::ErrorKind::NotFound
{
cx.emit(HostStoreEvent::PersistFailed(
format!("ShellRS 保存的私钥未能删除:{error}").into(),
));
self.report_failure(format!("ShellRS 保存的私钥未能删除:{error}").into(), cx);
}
self.save_secret(SecretRef::passphrase(path), None, cx);
}
@@ -1456,6 +1460,17 @@ impl HostStore {
value: Option<String>,
cx: &mut Context<Self>,
) {
self.write_secret(secret, value, cx).detach();
}
/// [`Self::save_secret`], for a caller that has to know how it went:
/// the failure, already reported as `PersistFailed`.
pub fn write_secret(
&mut self,
secret: SecretRef,
value: Option<String>,
cx: &mut Context<Self>,
) -> Task<Result<(), SharedString>> {
let secrets = self.secrets();
let failure = match (&secret, value.is_some()) {
(SecretRef::Password { .. }, true) => "密码未能写入系统钥匙串",
@@ -1479,16 +1494,15 @@ impl HostStore {
}
})
.await;
if let Err(error) = result {
result.map_err(|error| {
let message = SharedString::from(format!("{failure}:{error}"));
this.update(cx, |_, cx| {
cx.emit(HostStoreEvent::PersistFailed(
format!("{failure}:{error}").into(),
));
cx.emit(HostStoreEvent::PersistFailed(message.clone()));
})
.ok();
}
message
})
})
.detach();
}
/// The distinct password entries these hosts log in with, their
@@ -1549,12 +1563,36 @@ impl HostStore {
if let Some(database) = &self.database
&& let Err(error) = write(database)
{
cx.emit(HostStoreEvent::PersistFailed(
format!("{action}未能保存到本地数据库:{error}").into(),
));
self.report_failure(format!("{action}未能保存到本地数据库:{error}").into(), cx);
}
}
/// Say that a change could not be kept, as `PersistFailed`, and to
/// [`Self::capture_failures`] when one is listening.
fn report_failure(&self, message: SharedString, cx: &mut Context<Self>) {
if let Some(failures) = self.capturing.borrow_mut().as_mut() {
failures.push(message.clone());
}
cx.emit(HostStoreEvent::PersistFailed(message));
}
/// Run `change` and say what of it could not be written to the
/// database (or a key file deleted), for a caller that answers someone
/// who cannot see the notification: the external CLI. The failures are
/// reported as `PersistFailed` all the same, and the change stands in
/// memory as it always does. Keychain writes are not among them: they
/// finish later, and [`Self::write_secret`] says how.
pub fn capture_failures<R>(
&mut self,
cx: &mut Context<Self>,
change: impl FnOnce(&mut Self, &mut Context<Self>) -> R,
) -> (R, Vec<SharedString>) {
let outer = self.capturing.replace(Some(Vec::new()));
let result = change(self, cx);
let failures = self.capturing.replace(outer).unwrap_or_default();
(result, failures)
}
/// The sort order that puts a host last in `group`, among the
/// hosts other than `except`.
fn last_host_order(&self, group: Option<GroupId>, except: Option<HostId>) -> i64 {
@@ -1990,6 +2028,56 @@ mod tests {
assert!(HostStore::empty().save_private_key(None, "one").is_err());
}
#[gpui_kit::test]
fn a_change_says_what_could_not_be_kept_and_still_reports_it(
cx: &mut gpui_kit::TestAppContext,
) {
use gpui_kit::AppContext as _;
use std::{cell::RefCell, rc::Rc};
let database = HostDatabase::in_memory().unwrap();
let store = cx.new(|_| HostStore::load(database).unwrap());
let events = Rc::new(RefCell::new(Vec::new()));
let _subscription = cx.update(|cx| {
let events = events.clone();
cx.subscribe(&store, move |_, event: &HostStoreEvent, _| {
events.borrow_mut().push(event.clone());
})
});
// A group the database has never heard of: the write fails, the
// host stands in memory.
let ((id, kept), failures) = store.update(cx, |store, cx| {
store.capture_failures(cx, |store, cx| {
let kept = store.insert(draft("web", None), cx);
(store.insert(draft("db", Some(GroupId(99))), cx), kept)
})
});
assert_eq!(failures.len(), 1, "{failures:?}");
assert!(failures[0].starts_with("新建主机未能保存到本地数据库"));
cx.run_until_parked();
assert_eq!(
*events.borrow(),
[HostStoreEvent::PersistFailed(failures[0].clone())]
);
store.read_with(cx, |store, _| {
assert!(store.host(id).is_some() && store.host(kept).is_some());
});
// Nothing left listening afterwards.
let (_, failures) = store.update(cx, |store, cx| {
store.capture_failures(cx, |store, cx| store.remove(kept, cx))
});
assert!(failures.is_empty());
// A keychain that keeps nothing: the write says so when it is done.
let secret = SecretRef::password("root", "10.0.0.1", 22);
let write = store.update(cx, |store, cx| {
store.write_secret(secret, Some("hunter2".into()), cx)
});
let failure = cx.foreground_executor.block_test(write).unwrap_err();
assert!(failure.starts_with("密码未能写入系统钥匙串"), "{failure}");
}
#[gpui_kit::test]
fn a_kept_key_goes_with_the_last_credential_using_it(cx: &mut gpui_kit::TestAppContext) {
use crate::secrets::SecretStore as _;
+6 -3
View File
@@ -14,7 +14,7 @@ fn main() {
// bastion host opening ShellRS the way it opens Xshell. On every build,
// a Windows release build above all, where bastion hosts run it.
let link = shellrs::cli::link_arguments(&args);
// `shellrs list`, `shellrs exec …`: the command, not the app. Not in a
// `shellrs hosts list`, `shellrs exec …`: the command, not the app. Not in a
// Windows release build, which has nowhere to print: there the command
// is `shellrs-cli.exe`, which 设置 → 外部 CLI puts on the PATH.
#[cfg(any(not(windows), debug_assertions))]
@@ -43,7 +43,11 @@ fn main() {
shellrs::app::show_logo_when_unbundled(cx);
cx.activate(true);
let options = shellrs::workspace::window_options(cx);
// Read before the window opens: 应用 › 窗口 decides where.
let (settings, settings_problem) = open_settings();
let saved = shellrs::workspace::WindowState::load(&shellrs::app::window_state_path());
let options =
shellrs::workspace::window_options(saved.as_ref(), settings.settings().window, cx);
cx.spawn(async move |cx| {
cx.open_window(options, |window, cx| {
window.activate_window();
@@ -51,7 +55,6 @@ fn main() {
shellrs::app::hide_when_closed(window, cx);
let (store, problem) = open_store();
let store = cx.new(|_| store);
let (settings, settings_problem) = open_settings();
let settings = cx.new(|_| settings);
let workspace =
cx.new(|cx| shellrs::workspace::Workspace::new(store, settings, window, cx));
+323
View File
@@ -0,0 +1,323 @@
//! The app's colors from the chosen theme, so the window around a terminal
//! matches it: gpui-kit's default light or dark theme, recolored. Each gray
//! keeps its place between the default's background and text, now between
//! the theme's; each color becomes the theme's ANSI color of its hue. What
//! gpui-kit derives from these (hover, active, tab bar, status bar …)
//! follows. The theme's own default is gpui-kit's theme unchanged.
use std::rc::Rc;
use gpui_kit::component::{ThemeConfig, ThemeMode, ThemeRegistry, try_parse_color};
use gpui_kit::*;
use crate::terminal::TerminalTheme;
/// The name the app's theme has while `theme` is chosen, to tell whether it
/// is already in effect without building it again.
pub(super) fn ui_theme_name(theme: &'static TerminalTheme, cx: &App) -> SharedString {
if is_default(theme) {
default_config(theme.mode(), cx).name.clone()
} else {
theme.name().into()
}
}
/// The app's theme while `theme` is chosen.
pub(super) fn ui_theme(theme: &'static TerminalTheme, cx: &App) -> Rc<ThemeConfig> {
let default = default_config(theme.mode(), cx);
if is_default(theme) {
return default.clone();
}
Rc::new(recolor(default, theme))
}
fn is_default(theme: &'static TerminalTheme) -> bool {
theme == TerminalTheme::default_for(theme.mode())
}
fn default_config(mode: ThemeMode, cx: &App) -> &Rc<ThemeConfig> {
let registry = ThemeRegistry::global(cx);
match mode {
ThemeMode::Light => registry.default_light_theme(),
ThemeMode::Dark => registry.default_dark_theme(),
}
}
/// `default` with its colors moved onto `theme`. Everything else (radius,
/// shadow, syntax highlighting) stays the default's.
fn recolor(default: &ThemeConfig, theme: &TerminalTheme) -> ThemeConfig {
let mut config = serde_json::to_value(default).expect("a theme is plain data");
config["name"] = theme.name().into();
config["is_default"] = false.into();
if let Some(colors) = config["colors"].as_object_mut() {
let color = |key: &str| {
colors
.get(key)
.and_then(|value| value.as_str())
.and_then(|value| try_parse_color(value).ok())
.map(|color| color.to_rgb())
};
if let (Some(background), Some(foreground)) = (color("background"), color("foreground")) {
let line = Line {
from: (background, foreground),
to: (theme.background().to_rgb(), theme.foreground().to_rgb()),
};
colors.retain(|key, value| {
let Some(color) = value.as_str().and_then(|text| try_parse_color(text).ok()) else {
return true;
};
match recolor_one(key, color.to_rgb(), &line, theme) {
Some(color) => {
*value = hex(color).into();
true
}
// Left to what gpui-kit derives in its place.
None => false,
}
});
}
}
serde_json::from_value(config).expect("written from a theme")
}
/// The line from background to text, in the default and in the theme.
struct Line {
from: (Rgba, Rgba),
to: (Rgba, Rgba),
}
/// How much of its text's contrast a theme keeps for the default's readable
/// grays, such as secondary text, at most what reads well anywhere.
const TEXT_CONTRAST_SHARE: f32 = 0.8;
const READABLE_CONTRAST: f32 = 4.5;
/// One of the default's colors in the theme, or `None` to leave it to
/// gpui-kit: the lighter versions of the base colors, and colored text on a
/// colored background, which in the theme could be the same color.
fn recolor_one(key: &str, color: Rgba, line: &Line, theme: &TerminalTheme) -> Option<Rgba> {
let ansi = |index: u8| {
let mut ansi = theme.indexed(index).to_rgb();
ansi.a = color.a;
Some(ansi)
};
if let Some(base) = key.strip_prefix("base.") {
return match base {
"red" => ansi(1),
"green" => ansi(2),
"yellow" => ansi(3),
"blue" => ansi(4),
"magenta" => ansi(5),
"cyan" => ansi(6),
_ => None,
};
}
if is_gray(color) {
let (background, foreground) = line.to;
let (from_background, from_foreground) = line.from;
let mut t = (gray_value(color) - gray_value(from_background))
/ (gray_value(from_foreground) - gray_value(from_background));
// A gray the default gives text to read on its background stays
// readable even where the theme's own text is soft.
if contrast(color, from_background) >= READABLE_CONTRAST {
let floor =
(contrast(foreground, background) * TEXT_CONTRAST_SHARE).min(READABLE_CONTRAST);
t = least_reaching(background, foreground, t, floor);
}
let mut mixed = mix(background, foreground, t);
mixed.a = color.a;
return Some(mixed);
}
if key.ends_with("foreground") {
return None;
}
ansi(ansi_of_hue(color))
}
/// The ANSI color (red 1 … cyan 6) nearest in hue: 60° each, centered on
/// red at 0°, yellow, green, cyan, blue, magenta.
fn ansi_of_hue(color: Rgba) -> u8 {
let hue = Hsla::from(color).h * 360.;
match ((hue + 30.) / 60.).floor() as i32 % 6 {
0 => 1,
1 => 3,
2 => 2,
3 => 6,
4 => 4,
_ => 5,
}
}
fn is_gray(color: Rgba) -> bool {
let high = color.r.max(color.g).max(color.b);
let low = color.r.min(color.g).min(color.b);
high - low < 0.02
}
fn gray_value(color: Rgba) -> f32 {
(color.r + color.g + color.b) / 3.
}
/// `t` of the way from `from` to `to`, past either end if `t` is.
fn mix(from: Rgba, to: Rgba, t: f32) -> Rgba {
let channel = |a: f32, b: f32| (a + (b - a) * t).clamp(0., 1.);
Rgba {
r: channel(from.r, to.r),
g: channel(from.g, to.g),
b: channel(from.b, to.b),
a: 1.,
}
}
/// The smallest step at or past `t` toward the text whose mix reaches
/// `target` contrast with the background; the text itself if none does.
fn least_reaching(background: Rgba, foreground: Rgba, t: f32, target: f32) -> f32 {
let reaches = |t: f32| contrast(mix(background, foreground, t), background) >= target;
if reaches(t) {
return t;
}
let (mut low, mut high) = (t, 1.);
for _ in 0..24 {
let middle = (low + high) / 2.;
if reaches(middle) {
high = middle;
} else {
low = middle;
}
}
high
}
/// WCAG relative luminance.
fn luminance(color: Rgba) -> f32 {
let linear = |value: f32| {
if value <= 0.03928 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
}
fn contrast(a: Rgba, b: Rgba) -> f32 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// `#rrggbbaa`, as gpui-kit reads it.
fn hex(color: Rgba) -> String {
let byte = |value: f32| (value.clamp(0., 1.) * 255.).round() as u8;
format!(
"#{:02x}{:02x}{:02x}{:02x}",
byte(color.r),
byte(color.g),
byte(color.b),
byte(color.a)
)
}
#[cfg(test)]
mod tests {
use gpui_kit::component::{ActiveTheme as _, Theme, ThemeMode};
use gpui_kit::{App, Rgba, TestAppContext, rgb};
use super::{READABLE_CONTRAST, ansi_of_hue, contrast, ui_theme, ui_theme_name};
use crate::terminal::TerminalTheme;
fn color(value: u32) -> Rgba {
rgb(value)
}
/// The same color within a step of 8-bit rounding: colors pass through
/// hex and HSL on their way into the theme.
fn assert_close(a: Rgba, b: Rgba, what: &str) {
let near = |x: f32, y: f32| (x - y).abs() <= 1.5 / 255.;
assert!(
near(a.r, b.r) && near(a.g, b.g) && near(a.b, b.b),
"{what}: {a:?} is not {b:?}"
);
}
#[test]
fn colors_go_to_the_ansi_color_of_their_hue() {
// What the default themes use besides their base colors: Tailwind's
// red, yellow, green, cyan and blue, the selection's blue.
let cases = [
(0xef4444, 1),
(0xf87171, 1),
(0xeab308, 3),
(0xfacc15, 3),
(0x22c55e, 2),
(0x4ade80, 2),
(0x06b6d4, 6),
(0x22d3ee, 6),
(0x55a0fc, 4),
(0x1d4ed8, 4),
(0x60a5fa, 4),
];
for (value, ansi) in cases {
assert_eq!(ansi_of_hue(color(value)), ansi, "{value:06x}");
}
}
fn apply(theme: &'static TerminalTheme, cx: &mut App) {
let config = ui_theme(theme, cx);
assert_eq!(config.name, ui_theme_name(theme, cx));
let global = Theme::global_mut(cx);
match theme.mode() {
ThemeMode::Light => global.light_theme = config,
ThemeMode::Dark => global.dark_theme = config,
}
Theme::change(theme.mode(), None, cx);
}
#[gpui_kit::test]
fn the_app_takes_each_themes_colors_and_keeps_text_readable(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_kit::component::init(cx);
for theme in TerminalTheme::all() {
apply(theme, cx);
let app = cx.theme();
assert_eq!(app.mode, theme.mode());
let name = theme.name();
assert_close(app.background.to_rgb(), theme.background().to_rgb(), name);
assert_close(app.foreground.to_rgb(), theme.foreground().to_rgb(), name);
let text = contrast(theme.foreground().to_rgb(), theme.background().to_rgb());
let muted = contrast(app.muted_foreground.to_rgb(), app.background.to_rgb());
assert!(
muted >= (text * 0.8).min(READABLE_CONTRAST) - 0.05,
"{}: secondary text {muted:.2}",
theme.name()
);
// The default keeps gpui-kit's own colors; the rest are the
// theme's.
if theme != TerminalTheme::default_for(theme.mode()) {
assert_close(app.danger.to_rgb(), theme.indexed(1).to_rgb(), name);
assert_close(app.success.to_rgb(), theme.indexed(2).to_rgb(), name);
}
}
});
}
#[gpui_kit::test]
fn the_default_themes_are_gpui_kits_own(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_kit::component::init(cx);
let white = cx.theme().background;
apply(&TerminalTheme::all()[1], cx);
assert_ne!(cx.theme().background, white);
for mode in [ThemeMode::Light, ThemeMode::Dark] {
apply(TerminalTheme::default_for(mode), cx);
assert_eq!(
cx.theme().theme_name().as_ref(),
match mode {
ThemeMode::Light => "Default Light",
ThemeMode::Dark => "Default Dark",
}
);
}
apply(TerminalTheme::default_for(ThemeMode::Light), cx);
assert_eq!(cx.theme().background, white);
});
}
}
+42 -5
View File
@@ -1,23 +1,42 @@
use gpui_kit::component::{ActiveTheme as _, Theme};
use gpui_kit::component::{ActiveTheme as _, Theme, ThemeMode};
use gpui_kit::*;
use crate::explorer::{FileSizeFormat, ShowHiddenFiles};
use crate::terminal::{TerminalFont, is_font_installed};
use crate::terminal::{TerminalColors, TerminalFont, TerminalHighlights, is_font_installed};
use super::AppSettings;
use super::app_theme::{ui_theme, ui_theme_name};
/// Bring the window in line with the settings: the theme, the locale, the
/// terminal font, the SFTP size format and whether SFTP shows hidden files.
/// Bring the window in line with the settings: the theme chosen for the
/// appearance, in the app and its terminals, the locale, the terminal font
/// and highlight rules, the keyboard shortcuts, the SFTP size format and
/// whether SFTP shows hidden files.
/// Does nothing where they already agree, so it can run on every settings
/// change and every change of the system appearance.
pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) {
// Here, not only on a settings change: following the system, the theme
// changes with the appearance.
let mode = settings.appearance.theme_mode(window.appearance());
if cx.theme().mode != mode {
let chosen = settings.terminal_theme.theme(mode);
let app = settings.terminal_theme.app_theme(mode);
if cx.theme().mode != mode || *cx.theme().theme_name() != ui_theme_name(app, cx) {
let config = ui_theme(app, cx);
let theme = Theme::global_mut(cx);
match mode {
ThemeMode::Light => theme.light_theme = config,
ThemeMode::Dark => theme.dark_theme = config,
}
Theme::change(mode, Some(window), cx);
// The theme change resets the list hover to the theme's own.
crate::app::deepen_list_hover(cx);
}
let colors = TerminalColors::new(chosen);
if cx.try_global::<TerminalColors>() != Some(&colors) {
cx.set_global(colors);
window.refresh();
}
let locale = settings
.language
.locale(sys_locale::get_locale().as_deref());
@@ -41,6 +60,24 @@ pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) {
window.refresh();
}
// Compiled once here for every terminal, which observe it; only when
// the rules change, not on every change of the system appearance.
let highlight = settings.terminal_highlight;
let unchanged = cx
.try_global::<TerminalHighlights>()
.is_some_and(|current| {
current
.set()
.is_made_of(highlight.enabled, &highlight.rules)
});
if !unchanged {
cx.set_global(TerminalHighlights::new(highlight.enabled, highlight.rules));
}
// Rebuilds the keymap only when the user's changes differ from the
// ones it was built with.
crate::app::apply_shortcuts(&settings.shortcuts, cx);
if cx.try_global::<FileSizeFormat>() != Some(&settings.file_size_format) {
cx.set_global(settings.file_size_format);
window.refresh();
+498
View File
@@ -0,0 +1,498 @@
//! 设置 › 关键字高亮 › 规则: the rules as a table edited in place. Every
//! change goes to the settings at once, so the preview above and the
//! terminals follow as the user types.
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState},
h_flex,
input::{Input, InputEvent, InputState},
tooltip::Tooltip,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::CatalogIcon;
use crate::terminal::{HighlightColor, HighlightRule};
use super::SettingsStore;
/// The colors the picker offers first, the examples' among them.
///
/// None may be a color of the picker's own palette (shadcn's stone, red,
/// orange, yellow, green, cyan, blue, purple and pink): gpui-kit keys each
/// swatch by its hex, so one color in both rows is one accessibility node
/// twice, which a debug build stops on while assistive technology (or a
/// window manager using it) is connected. Its default first row, the
/// theme's colors, is just such a duplicate.
const FEATURED_COLORS: [HighlightColor; 10] = [
HighlightColor::RED,
HighlightColor::from_rgb(0xf76b15),
HighlightColor::AMBER,
HighlightColor::from_rgb(0x2fa86b),
HighlightColor::from_rgb(0x0fa3a3),
HighlightColor::BLUE,
HighlightColor::from_rgb(0x5b5bd6),
HighlightColor::from_rgb(0xa855d6),
HighlightColor::from_rgb(0xe0489c),
HighlightColor::from_rgb(0x8a8f98),
];
/// The rule table. The rows' text fields and color pickers live here, so
/// they keep the caret and an open picker while the settings change under
/// them; the settings hold the rules themselves.
pub struct HighlightRulesEditor {
store: Entity<SettingsStore>,
rows: Vec<RuleRow>,
/// The next row's id: a row keeps it, whatever its place, so a dragged
/// row keeps its fields.
next_id: usize,
_store_changes: Subscription,
}
struct RuleRow {
id: usize,
rule: HighlightRule,
/// Why the pattern matches nothing, when it does not compile.
error: Option<&'static str>,
pattern: Entity<InputState>,
note: Entity<InputState>,
hex: Entity<InputState>,
picker: Entity<ColorPickerState>,
_subscriptions: [Subscription; 4],
}
/// A row on its way to another place.
#[derive(Clone)]
struct DraggedRule {
id: usize,
/// Its place when the drag began; nothing moves until it ends.
ix: usize,
pattern: SharedString,
}
impl Render for DraggedRule {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.px_2()
.py_1()
.rounded(cx.theme().radius)
.bg(cx.theme().popover)
.text_color(cx.theme().popover_foreground)
.border_1()
.border_color(cx.theme().border)
.font_family(cx.theme().mono_font_family.clone())
.text_sm()
.child(self.pattern.clone())
}
}
impl HighlightRulesEditor {
pub fn new(store: Entity<SettingsStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
// The rows' own edits come back as the same rules; anything else
// (the settings file read anew) lays the rows out again.
let store_changes = cx.observe_in(&store, window, |this, store, window, cx| {
let rules = store.read(cx).settings().terminal_highlight.rules;
if rules != this.rules() {
this.rows = Vec::new();
for rule in rules {
let row = this.new_row(rule, window, cx);
this.rows.push(row);
}
cx.notify();
}
});
let mut this = Self {
store: store.clone(),
rows: Vec::new(),
next_id: 0,
_store_changes: store_changes,
};
for rule in store.read(cx).settings().terminal_highlight.rules {
let row = this.new_row(rule, window, cx);
this.rows.push(row);
}
this
}
fn rules(&self) -> Vec<HighlightRule> {
self.rows.iter().map(|row| row.rule.clone()).collect()
}
fn new_row(
&mut self,
rule: HighlightRule,
window: &mut Window,
cx: &mut Context<Self>,
) -> RuleRow {
let id = self.next_id;
self.next_id += 1;
let pattern = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(r"例如:ERROR|WARN 或 failed\s+\d+")
.default_value(rule.pattern.clone())
});
let note = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("例如:错误告警")
.default_value(rule.note.clone())
});
let hex = cx.new(|cx| InputState::new(window, cx).default_value(rule.color.to_hex()));
let picker =
cx.new(|cx| ColorPickerState::new(window, cx).default_value(rule.color.hsla()));
let subscriptions = [
cx.subscribe(&pattern, move |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
let pattern = input.read(cx).value().to_string();
this.edit(id, |rule| rule.pattern = pattern, cx);
}
}),
cx.subscribe(&note, move |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
let note = input.read(cx).value().to_string();
this.edit(id, |rule| rule.note = note, cx);
}
}),
// A color typed in whole takes effect; left half typed, the
// field shows the rule's color again.
cx.subscribe_in(
&hex,
window,
move |this, input, event: &InputEvent, window, cx| match event {
InputEvent::Change => {
if let Some(color) = HighlightColor::from_hex(&input.read(cx).value()) {
this.set_color(id, color, ColorSource::Hex, window, cx);
}
}
InputEvent::Blur => {
if let Some(row) = this.row(id) {
let hex = row.rule.color.to_hex();
input.update(cx, |input, cx| input.set_value(hex, window, cx));
}
}
_ => {}
},
),
cx.subscribe_in(
&picker,
window,
move |this, _, event: &ColorPickerEvent, window, cx| {
if let ColorPickerEvent::Change(Some(color)) = event {
let color = HighlightColor::from_hsla(*color);
this.set_color(id, color, ColorSource::Picker, window, cx);
}
},
),
];
RuleRow {
id,
error: rule.compile().err(),
rule,
pattern,
note,
hex,
picker,
_subscriptions: subscriptions,
}
}
fn row(&self, id: usize) -> Option<&RuleRow> {
self.rows.iter().find(|row| row.id == id)
}
/// Change the rule of row `id` and write the rules.
fn edit(&mut self, id: usize, change: impl FnOnce(&mut HighlightRule), cx: &mut Context<Self>) {
let Some(row) = self.rows.iter_mut().find(|row| row.id == id) else {
return;
};
change(&mut row.rule);
row.error = row.rule.compile().err();
self.write(cx);
}
/// Give row `id` `color`, and show it in whichever of its color field
/// and picker did not set it.
fn set_color(
&mut self,
id: usize,
color: HighlightColor,
source: ColorSource,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(row) = self.row(id) else {
return;
};
match source {
ColorSource::Hex => row.picker.update(cx, |picker, cx| {
picker.set_value(color.hsla(), window, cx);
}),
ColorSource::Picker => row.hex.update(cx, |hex, cx| {
hex.set_value(color.to_hex(), window, cx);
}),
}
self.edit(id, |rule| rule.color = color, cx);
}
fn write(&mut self, cx: &mut Context<Self>) {
let rules = self.rules();
self.store.update(cx, |store, cx| {
store.update(|settings| settings.terminal_highlight.rules = rules, cx)
});
cx.notify();
}
/// A rule at the end, enabled and empty, its pattern taking the keyboard.
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let row = self.new_row(HighlightRule::default(), window, cx);
let pattern = row.pattern.clone();
self.rows.push(row);
self.write(cx);
pattern.update(cx, |pattern, cx| pattern.focus(window, cx));
}
fn remove(&mut self, id: usize, cx: &mut Context<Self>) {
self.rows.retain(|row| row.id != id);
self.write(cx);
}
/// Put row `id` where row `target` is: after it when it came from
/// above, before it when from below.
fn move_row(&mut self, id: usize, target: usize, cx: &mut Context<Self>) {
let from = self.rows.iter().position(|row| row.id == id);
let to = self.rows.iter().position(|row| row.id == target);
let (Some(from), Some(to)) = (from, to) else {
return;
};
if from != to {
let row = self.rows.remove(from);
self.rows.insert(to, row);
self.write(cx);
}
}
fn render_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
h_flex().w_full().justify_end().child(
Button::new("add-highlight-rule")
.outline()
.small()
.icon(Icon::new(IconName::Plus))
.label("添加规则")
.on_click(cx.listener(|this, _, window, cx| this.add(window, cx))),
)
}
fn render_header(&self, cx: &App) -> impl IntoElement {
h_flex()
.w_full()
.gap_3()
.pb_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
// Over the drag handles.
.child(div().w_4().flex_shrink_0())
.child(div().w_8().flex_shrink_0().child("启用"))
.child(div().flex_1().min_w_0().child("正则表达式"))
.child(div().w_40().flex_shrink_0().child("备注"))
.child(div().w_32().flex_shrink_0().child("颜色"))
.child(div().w_8().flex_shrink_0().child("通知"))
.child(div().w_6().flex_shrink_0())
}
fn render_row(&self, ix: usize, row: &RuleRow, cx: &mut Context<Self>) -> impl IntoElement {
let id = row.id;
let mono = cx.theme().mono_font_family.clone();
h_flex()
.id(("highlight-rule", id))
.test_support()
.w_full()
.gap_3()
.py_2()
.border_t_1()
.border_color(cx.theme().border)
// Where a dragged row lands: after this one when it came from
// above, before it when from below.
.drag_over::<DraggedRule>(move |style, dragged, _, cx| {
if dragged.id == id {
style
} else if dragged.ix < ix {
style.border_b_1().border_color(cx.theme().primary)
} else {
style.border_color(cx.theme().primary)
}
})
.on_drop(cx.listener(move |this, dragged: &DraggedRule, _, cx| {
this.move_row(dragged.id, id, cx);
}))
// Only the handle starts a drag: elsewhere a press selects text.
.child(
div()
.id(("highlight-rule-handle", id))
.test_support()
.w_4()
.flex_shrink_0()
.cursor_grab()
.text_color(cx.theme().muted_foreground)
.tooltip(|window, cx| Tooltip::new("拖动调整顺序").build(window, cx))
.child(Icon::new(CatalogIcon::GripVertical).small())
.on_drag(
DraggedRule {
id,
ix,
pattern: row.rule.pattern.clone().into(),
},
|dragged, _, _, cx| cx.new(|_| dragged.clone()),
),
)
.child(
div().w_8().flex_shrink_0().child(
Checkbox::new(("highlight-rule-enabled", id))
.checked(row.rule.enabled)
.small()
.on_click(cx.listener(move |this, checked: &bool, _, cx| {
let enabled = *checked;
this.edit(id, |rule| rule.enabled = enabled, cx);
})),
),
)
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_1()
.child(
Input::new(&row.pattern)
.id(("highlight-rule-pattern", id))
.small()
.font_family(mono.clone()),
)
.when_some(row.error, |cell, error| {
cell.child(
div()
.id(("highlight-rule-error", id))
.test_support()
.aria_label(error)
.text_xs()
.text_color(cx.theme().danger)
.child(error),
)
}),
)
.child(
div().w_40().flex_shrink_0().child(
Input::new(&row.note)
.id(("highlight-rule-note", id))
.small(),
),
)
.child(
h_flex()
.w_32()
.flex_shrink_0()
.gap_2()
.child(
div()
.id(("highlight-rule-picker", id))
.test_support()
.flex_shrink_0()
.child(
ColorPicker::new(&row.picker)
.featured_colors(
FEATURED_COLORS.iter().map(|color| color.hsla()).collect(),
)
.small()
.accessibility_label("颜色"),
),
)
.child(
div().flex_1().min_w_0().child(
Input::new(&row.hex)
.id(("highlight-rule-color", id))
.small()
.font_family(mono),
),
),
)
.child(
div().w_8().flex_shrink_0().child(
Checkbox::new(("highlight-rule-notify", id))
.checked(row.rule.notify)
.small()
.on_click(cx.listener(move |this, checked: &bool, _, cx| {
let notify = *checked;
this.edit(id, |rule| rule.notify = notify, cx);
})),
),
)
.child(
div().w_6().flex_shrink_0().child(
Button::new(("delete-highlight-rule", id))
.ghost()
.xsmall()
.icon(Icon::new(CatalogIcon::Trash))
.tooltip("删除")
.on_click(cx.listener(move |this, _, _, cx| this.remove(id, cx))),
),
)
}
}
/// Which of a row's two color controls the user changed.
#[derive(Clone, Copy)]
enum ColorSource {
Hex,
Picker,
}
impl Render for HighlightRulesEditor {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let table = if self.rows.is_empty() {
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("还没有规则")
.into_any_element()
} else {
let rows: Vec<_> = self
.rows
.iter()
.enumerate()
.map(|(ix, row)| self.render_row(ix, row, cx).into_any_element())
.collect();
v_flex()
.w_full()
.child(self.render_header(cx))
.children(rows)
.into_any_element()
};
v_flex()
.id("highlight-rules")
.test_support()
.w_full()
.gap_3()
.child(self.render_toolbar(cx))
.child(table)
}
}
#[cfg(test)]
mod tests {
use gpui_kit::component::Colorize as _;
use super::FEATURED_COLORS;
#[test]
fn featured_colors_are_each_one_swatch() {
// The picker keys its swatches by hex.
let mut hexes: Vec<_> = FEATURED_COLORS
.iter()
.map(|color| color.hsla().to_hex())
.collect();
hexes.sort();
hexes.dedup();
assert_eq!(hexes.len(), FEATURED_COLORS.len());
}
}
+6 -1
View File
@@ -1,14 +1,19 @@
//! 设置: the settings tab, the settings it edits and how they are applied.
mod app_theme;
mod apply;
mod highlight_rules;
mod model;
mod settings_panel;
mod shortcuts_editor;
mod store;
mod terminal_themes;
pub use apply::apply;
pub use model::{
AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, NotificationSettings,
TerminalFontSettings, UpdateSettings,
TerminalFontSettings, TerminalHighlightSettings, TerminalThemeSettings, UpdateSettings,
WindowSettings,
};
pub use settings_panel::{SettingsPanel, SettingsPanelEvent};
pub use store::{SettingsStore, SettingsStoreEvent};
+256 -2
View File
@@ -1,9 +1,13 @@
use gpui_kit::WindowAppearance;
use gpui_kit::component::ThemeMode;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use crate::app::ShortcutOverrides;
use crate::explorer::{FileSizeFormat, ShowHiddenFiles};
use crate::terminal::{DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE};
use crate::terminal::{
DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, HighlightRule, LINE_HEIGHT_RANGE,
TerminalTheme, default_rules,
};
use crate::update::Channel;
/// Everything the settings page changes. Each field falls back to its
@@ -14,6 +18,8 @@ use crate::update::Channel;
pub struct AppSettings {
pub language: InterfaceLanguage,
pub appearance: Appearance,
/// 外观 → 终端主题.
pub terminal_theme: TerminalThemeSettings,
pub terminal_font: TerminalFontSettings,
pub external_cli: ExternalCliSettings,
pub update: UpdateSettings,
@@ -25,6 +31,137 @@ pub struct AppSettings {
pub show_hidden: ShowHiddenFiles,
/// 终端 → 通知.
pub notifications: NotificationSettings,
/// 关键字高亮.
pub terminal_highlight: TerminalHighlightSettings,
/// 应用 → 窗口.
pub window: WindowSettings,
/// 键盘快捷键: what the user changed of them.
pub shortcuts: ShortcutOverrides,
}
/// 应用 → 窗口: what the next launch restores of the main window. Both on
/// until the user turns them off.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WindowSettings {
/// 记住窗口大小: its size, and whether it was maximized.
pub remember_size: bool,
/// 记住窗口位置: where it was, on which screen.
pub remember_position: bool,
}
impl Default for WindowSettings {
fn default() -> Self {
Self {
remember_size: true,
remember_position: true,
}
}
}
/// 外观 → 主题: by key, the theme used while the app is light and the one
/// while it is dark, and whether the app takes its colors or only the
/// terminals do.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TerminalThemeSettings {
pub light: String,
pub dark: String,
/// 界面跟随主题. Off until the user turns it on: the app keeps
/// gpui-kit's own neutral light or dark colors.
pub app_follows: bool,
}
impl Default for TerminalThemeSettings {
fn default() -> Self {
Self {
light: TerminalTheme::default_for(ThemeMode::Light).key().into(),
dark: TerminalTheme::default_for(ThemeMode::Dark).key().into(),
app_follows: false,
}
}
}
impl TerminalThemeSettings {
/// The theme the app's colors come from for `mode`: the chosen one, or
/// the default, which is gpui-kit's own, when the app does not follow.
pub fn app_theme(&self, mode: ThemeMode) -> &'static TerminalTheme {
if self.app_follows {
self.theme(mode)
} else {
TerminalTheme::default_for(mode)
}
}
/// Whether both columns have their defaults, the switch aside.
pub fn is_default_choice(&self) -> bool {
let default = Self::default();
self.light == default.light && self.dark == default.dark
}
/// Both columns back to their defaults, the switch left as it is.
pub fn reset_choice(&mut self) {
let default = Self::default();
self.light = default.light;
self.dark = default.dark;
}
/// The theme for `mode`. A key this version does not know (from a newer
/// one, or a typo), or one of a theme for the other appearance, gives
/// way to the default rather than failing the file.
pub fn theme(&self, mode: ThemeMode) -> &'static TerminalTheme {
let key = match mode {
ThemeMode::Light => &self.light,
ThemeMode::Dark => &self.dark,
};
TerminalTheme::find(key)
.filter(|theme| theme.mode() == mode)
.unwrap_or_else(|| TerminalTheme::default_for(mode))
}
/// Use `theme` for the appearance it is made for.
pub fn choose(&mut self, theme: &TerminalTheme) {
let key = theme.key().to_string();
match theme.mode() {
ThemeMode::Light => self.light = key,
ThemeMode::Dark => self.dark = key,
}
}
}
/// 关键字高亮: whether terminals color their text by the rules, and the
/// rules, in the order they win where they overlap. Off until the user turns
/// it on. A file without the rules gets the examples a new installation
/// starts with, ready once it is on; one the user emptied stays empty.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TerminalHighlightSettings {
/// 启用关键字高亮. Off, no rule colors anything or notifies.
pub enabled: bool,
#[serde(deserialize_with = "readable_rules")]
pub rules: Vec<HighlightRule>,
}
impl Default for TerminalHighlightSettings {
fn default() -> Self {
Self {
enabled: false,
rules: default_rules(),
}
}
}
/// The rules the file holds that this version can read. One it cannot (a
/// color from a newer version, a hand-made typo) is dropped on its own:
/// failing the whole file would put every setting back to its default.
fn readable_rules<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<HighlightRule>, D::Error> {
let values = Vec::<serde_json::Value>::deserialize(deserializer)?;
Ok(values
.into_iter()
.filter_map(|value| serde_json::from_value(value).ok())
.collect())
}
/// 终端 → 通知: what a terminal may tell the user about while they look
@@ -311,6 +448,123 @@ mod tests {
assert!(settings.notifications.programs && !settings.notifications.bell);
}
#[test]
fn highlight_rules_start_as_examples_and_stay_as_the_user_leaves_them() {
// Off, with the examples each on and quiet, ready for when it is.
let settings: AppSettings = serde_json::from_str("{}").unwrap();
assert!(!settings.terminal_highlight.enabled);
assert_eq!(settings.terminal_highlight.rules, default_rules());
assert!(
default_rules()
.iter()
.all(|rule| rule.enabled && !rule.notify)
);
let settings: AppSettings =
serde_json::from_str(r#"{"terminal_highlight":{"enabled":true,"rules":[]}}"#).unwrap();
assert!(settings.terminal_highlight.enabled);
assert!(settings.terminal_highlight.rules.is_empty());
let json = serde_json::to_value(AppSettings::default()).unwrap();
assert_eq!(
json["terminal_highlight"]["rules"][0],
serde_json::json!({
"enabled": true,
"pattern": "ERROR",
"note": "错误",
"color": "#e5484d",
"notify": false
})
);
}
#[test]
fn a_rule_this_version_cannot_read_costs_only_itself() {
let settings: AppSettings = serde_json::from_str(
r##"{
"appearance": "dark",
"terminal_highlight": {"rules": [
{"pattern": "FATAL", "color": "orange"},
{"pattern": "OOM", "color": "#D946EF", "notify": true},
{"pattern": ""},
7
]}
}"##,
)
.unwrap();
assert_eq!(settings.appearance, Appearance::Dark);
let rules = &settings.terminal_highlight.rules;
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].pattern, "OOM");
assert_eq!(rules[0].color.to_hex(), "#d946ef");
assert!(rules[0].enabled && rules[0].notify);
// A row added and not filled in yet stays, to be filled in.
assert_eq!(rules[1], HighlightRule::default());
}
#[test]
fn terminal_themes_fall_back_to_the_default_for_their_appearance() {
let settings: AppSettings = serde_json::from_str("{}").unwrap();
let themes = &settings.terminal_theme;
assert_eq!(themes.light, "shellrs-light");
assert_eq!(themes.dark, "shellrs-dark");
for mode in [ThemeMode::Light, ThemeMode::Dark] {
assert_eq!(themes.theme(mode), TerminalTheme::default_for(mode));
}
// A key from a newer version, and a dark theme where a light one
// belongs, each give way on their own side only.
let settings: AppSettings = serde_json::from_str(
r#"{"appearance":"dark","terminal_theme":{"light":"shellrs-dark","dark":"sepia"}}"#,
)
.unwrap();
assert_eq!(settings.appearance, Appearance::Dark);
let themes = &settings.terminal_theme;
assert_eq!(
themes.theme(ThemeMode::Light),
TerminalTheme::default_for(ThemeMode::Light)
);
assert_eq!(
themes.theme(ThemeMode::Dark),
TerminalTheme::default_for(ThemeMode::Dark)
);
// Choosing a theme sets the side it is made for, by key.
let mut themes = TerminalThemeSettings::default();
let dark = TerminalTheme::for_mode(ThemeMode::Dark).last().unwrap();
themes.choose(dark);
assert_eq!(themes.theme(ThemeMode::Dark), dark);
assert_eq!(
themes.theme(ThemeMode::Light),
TerminalTheme::default_for(ThemeMode::Light)
);
assert_eq!(
serde_json::to_value(&themes).unwrap(),
serde_json::json!({"light": "shellrs-light", "dark": dark.key(), "app_follows": false})
);
// The app stays neutral, the default's colors, gpui-kit's own,
// unless told to follow; the terminals have the theme either way.
assert_eq!(
themes.app_theme(ThemeMode::Dark),
TerminalTheme::default_for(ThemeMode::Dark)
);
let settings: AppSettings =
serde_json::from_str(r#"{"terminal_theme":{"dark":"nord","app_follows":true}}"#)
.unwrap();
let themes = &settings.terminal_theme;
assert_eq!(themes.theme(ThemeMode::Dark).key(), "nord");
assert_eq!(themes.app_theme(ThemeMode::Dark).key(), "nord");
assert!(!themes.is_default_choice());
}
#[test]
fn the_window_is_remembered_unless_the_file_says_otherwise() {
let settings: AppSettings = serde_json::from_str("{}").unwrap();
assert!(settings.window.remember_size && settings.window.remember_position);
let settings: AppSettings =
serde_json::from_str(r#"{"window":{"remember_position":false}}"#).unwrap();
assert!(settings.window.remember_size && !settings.window.remember_position);
}
#[test]
fn automatic_updates_are_on_unless_the_file_says_otherwise() {
let settings: AppSettings = serde_json::from_str("{}").unwrap();
+231 -12
View File
@@ -1,3 +1,6 @@
use std::cell::Cell;
use std::rc::Rc;
use gpui_kit::component::{
ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
@@ -7,7 +10,10 @@ use gpui_kit::component::{
label::Label,
menu::PopupMenu,
separator::Separator,
setting::{NumberFieldOptions, SettingField, SettingGroup, SettingItem, SettingPage, Settings},
setting::{
NumberFieldOptions, SelectIndex, SettingField, SettingGroup, SettingItem, SettingPage,
Settings,
},
tooltip::Tooltip,
v_flex,
};
@@ -16,18 +22,21 @@ use gpui_kit::*;
use crate::app::{
CatalogIcon, CenterTab, CheckForUpdates, CloseSettings, CopyAgentSkill, DownloadUpdate,
InstallAgentSkill, InstallCliCommand, OpenDownloadPage, RefreshCliIntegration,
InstallAgentSkill, InstallCliCommand, OpenDownloadPage, OpenWebsite, RefreshCliIntegration,
RemoveAgentSkill, RemoveCliCommand, ShowUpdate,
};
use crate::cli::{AgentKind, BinaryStatus, CliIntegration, IntegrationStatus, SkillStatus};
use crate::shared::{ClosableTabTitle, close_tab_items};
use crate::terminal::{
FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFontPreview, is_font_installed,
monospace_font_families,
FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalColors, TerminalFont, TerminalFontPreview,
TerminalHighlights, is_font_installed, monospace_font_families,
};
use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform};
use super::{AppSettings, Choice, NotificationSettings, SettingsStore};
use super::highlight_rules::HighlightRulesEditor;
use super::shortcuts_editor::{ShortcutsEditor, shortcut_groups};
use super::terminal_themes::{app_follows_item, terminal_theme_item};
use super::{AppSettings, Choice, NotificationSettings, SettingsStore, WindowSettings};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SettingsPanelEvent {
@@ -48,12 +57,21 @@ pub struct SettingsPanel {
integration: Entity<CliIntegration>,
/// Where updating stands, for 关于.
updater: Entity<Updater>,
/// The rule table of 关键字高亮, whose fields keep their state across
/// the page's renders.
highlight_rules: Entity<HighlightRulesEditor>,
/// 键盘快捷键, which knows which shortcut is taking new keys.
shortcuts: Entity<ShortcutsEditor>,
/// The monospace families to choose the terminal font from; empty until
/// the scan in the background comes back.
font_families: &'static [SharedString],
/// The category last shown, for coming back to it. `Settings` keeps its
/// choice in element state, which goes while the tab is hidden; its
/// page header records the category it draws.
shown_category: Rc<Cell<usize>>,
focus_handle: FocusHandle,
tab_group: Option<WeakEntity<TabGroup>>,
_subscriptions: [Subscription; 3],
_subscriptions: [Subscription; 4],
_font_scan: Task<()>,
}
@@ -62,10 +80,14 @@ impl SettingsPanel {
store: Entity<SettingsStore>,
integration: Entity<CliIntegration>,
updater: Entity<Updater>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let highlight_rules = cx.new(|cx| HighlightRulesEditor::new(store.clone(), window, cx));
let shortcuts = cx.new(|cx| ShortcutsEditor::new(store.clone(), window, cx));
let subscriptions = [
cx.observe(&store, |_, _, cx| cx.notify()),
cx.observe(&shortcuts, |_, _, cx| cx.notify()),
cx.observe(&integration, |_, _, cx| cx.notify()),
cx.observe(&updater, |_, _, cx| cx.notify()),
];
@@ -85,7 +107,10 @@ impl SettingsPanel {
store,
integration,
updater,
highlight_rules,
shortcuts,
font_families: &[],
shown_category: Rc::default(),
focus_handle: cx.focus_handle(),
tab_group: None,
_subscriptions: subscriptions,
@@ -182,7 +207,18 @@ impl Render for SettingsPanel {
// Split geometry is an API boundary that takes `Pixels`.
.sidebar_width(px(200.))
.with_group_variant(GroupBoxVariant::Outline)
.pages(CATEGORIES.iter().map(|category| category.page(self, cx))),
// Used only when the tab comes back and `Settings`
// starts over.
.default_selected_index(SelectIndex {
page_ix: self.shown_category.get(),
group_ix: None,
})
.pages(
CATEGORIES
.iter()
.enumerate()
.map(|(ix, category)| category.page(ix, self, cx)),
),
)
}
}
@@ -200,7 +236,7 @@ struct Category {
type CategoryGroups = fn(&SettingsPanel, &App) -> Vec<SettingGroup>;
/// The categories, in the order the left column lists them.
const CATEGORIES: [Category; 4] = [
const CATEGORIES: [Category; 7] = [
Category {
title: "外观",
icon: CatalogIcon::Palette,
@@ -211,11 +247,26 @@ const CATEGORIES: [Category; 4] = [
icon: CatalogIcon::Terminal,
groups: terminal_groups,
},
Category {
title: "关键字高亮",
icon: CatalogIcon::Highlighter,
groups: highlight_groups,
},
Category {
title: "键盘快捷键",
icon: CatalogIcon::Keyboard,
groups: shortcuts_groups,
},
Category {
title: "外部 CLI",
icon: CatalogIcon::SquareTerminal,
groups: external_cli_groups,
},
Category {
title: "应用",
icon: CatalogIcon::AppWindow,
groups: application_groups,
},
Category {
title: "关于",
icon: CatalogIcon::Info,
@@ -224,9 +275,17 @@ const CATEGORIES: [Category; 4] = [
];
impl Category {
fn page(&self, panel: &SettingsPanel, cx: &App) -> SettingPage {
/// The page of the category at `ix` in `CATEGORIES`.
fn page(&self, ix: usize, panel: &SettingsPanel, cx: &App) -> SettingPage {
let shown = panel.shown_category.clone();
SettingPage::new(self.title)
.icon(Icon::new(self.icon))
// Drawn only for the page on display: nothing to see, it notes
// which one that is.
.title_suffix(move |_, _| {
shown.set(ix);
Empty
})
.groups((self.groups)(panel, cx))
}
}
@@ -254,6 +313,10 @@ fn appearance_groups(panel: &SettingsPanel, _: &App) -> Vec<SettingGroup> {
),
),
]),
SettingGroup::new()
.title("主题")
.description("浅色外观时用左栏选中的主题,深色外观时用右栏的。")
.items([app_follows_item(store), terminal_theme_item(store)]),
]
}
@@ -305,7 +368,7 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.bg(TerminalColors::current(cx).background())
.overflow_hidden()
.child(TerminalFontPreview::new(
"terminal-font-preview",
@@ -357,6 +420,147 @@ fn notification_group(store: &Entity<SettingsStore>) -> SettingGroup {
])
}
/// What the 关键字高亮 preview shows: a service's log followed on a server,
/// with what the example rules color (levels and addresses) and lines they
/// leave alone.
const HIGHLIGHT_PREVIEW_LINES: &[&str] = &[
"root@web-01:~# tail -f /var/log/orders/app.log",
"09:12:03 INFO orders listening on 0.0.0.0:8080",
"09:12:41 WARN slow query: 2.31s on table order_items",
"09:13:07 ERROR upstream 10.20.3.15:5432 refused connection",
"09:13:08 INFO retrying in 5s (attempt 2/5)",
];
/// 关键字高亮: the switch, a preview of the rules in the terminal's font and
/// colors, and the rules.
fn highlight_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
let (reader, writer) = (panel.store.clone(), panel.store.clone());
let count = panel
.store
.read(cx)
.settings()
.terminal_highlight
.rules
.len();
let editor = panel.highlight_rules.clone();
vec![
SettingGroup::new().title("常规").items([SettingItem::new(
"启用关键字高亮",
SettingField::switch(
move |cx| reader.read(cx).settings().terminal_highlight.enabled,
move |enabled, cx| {
writer.update(cx, |store, cx| {
store.update(|settings| settings.terminal_highlight.enabled = enabled, cx)
});
},
)
.default_value(true),
)
.description("全屏程序(vim、less、tmux 等)里不高亮。")]),
SettingGroup::new()
.title("预览")
.description("使用当前终端主题")
.items([highlight_preview_item()]),
SettingGroup::new()
.title(format!("规则({count})"))
.description("规则按从上到下应用,勾选「通知」的规则在看不到终端时弹出提醒。")
.items([
SettingItem::render(move |_, _, _| editor.clone()).keywords([
"规则",
"正则",
"关键字",
"高亮",
"颜色",
"备注",
"通知",
"排序",
]),
]),
]
}
/// The preview: sample output in the terminal's font and theme, colored by
/// the rules in effect, as a terminal would show it. The group's outline is
/// its frame; the theme's background is set in from it.
fn highlight_preview_item() -> SettingItem {
SettingItem::render(|_, window, cx| {
let rules = TerminalHighlights::current(cx);
let font = TerminalFont::current(cx);
let colors = TerminalColors::current(cx);
div()
.id("highlight-preview")
.test_support()
.w_full()
.p_3()
.rounded(cx.theme().radius)
.bg(colors.background())
.text_color(colors.foreground())
.font_family(font.family(cx))
.text_size(font.size)
.line_height(font.row_height(window))
.overflow_hidden()
.children(HIGHLIGHT_PREVIEW_LINES.iter().map(|line| {
let highlights = rules.spans(line).into_iter().map(|(range, color)| {
(
range,
HighlightStyle {
color: Some(color.hsla()),
..HighlightStyle::default()
},
)
});
div()
.whitespace_nowrap()
.child(StyledText::new(*line).with_highlights(highlights))
}))
})
.keywords(["预览", "高亮"])
}
/// 键盘快捷键.
fn shortcuts_groups(panel: &SettingsPanel, _: &App) -> Vec<SettingGroup> {
shortcut_groups(&panel.shortcuts)
}
/// 应用 → 窗口: what the next launch restores of the main window.
fn application_groups(panel: &SettingsPanel, _: &App) -> Vec<SettingGroup> {
let store = &panel.store;
let switch = |read: fn(&WindowSettings) -> bool, write: fn(&mut WindowSettings, bool)| {
let (reader, writer) = (store.clone(), store.clone());
SettingField::switch(
move |cx| read(&reader.read(cx).settings().window),
move |on, cx| {
writer.update(cx, |store, cx| {
store.update(|settings| write(&mut settings.window, on), cx)
});
},
)
.default_value(true)
};
vec![
SettingGroup::new().title("窗口").items([
SettingItem::new(
"记住窗口大小",
switch(
|window| window.remember_size,
|window, on| window.remember_size = on,
),
)
.description("下次启动时恢复上次关闭时的窗口大小和最大化状态。"),
SettingItem::new(
"记住窗口位置",
switch(
|window| window.remember_position,
|window, on| window.remember_position = on,
),
)
.description(
"下次启动时把窗口放回上次所在的屏幕和位置;那块屏幕不在时放在主屏幕中间。",
),
]),
]
}
/// 外部 CLI: whether the `shellrs` command may use the saved hosts, the
/// command itself, and the skill that teaches agents to use it.
fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
@@ -389,7 +593,8 @@ fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
)
.description(
"开启后,本机当前用户下的程序(如 AI Agent)可以通过 shellrs 命令,\
在已保存的主机上执行命令、传输文件,无需知道密码。",
在已保存的主机上执行命令、传输和同步文件,并查看、新建、修改和删除主机与凭据,\
无需知道密码。",
)]),
SettingGroup::new()
.title("CLI 二进制")
@@ -444,6 +649,20 @@ fn about_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
)
.description("检测到新版本后自动下载,下载完成后点击「重启并安装」完成更新。"),
]),
SettingGroup::new()
.title("ShellRS")
.items([SettingItem::new(
"官网",
SettingField::render(|options, _, _| {
Button::new("open-website")
.outline()
.with_size(options.size())
.icon(Icon::new(CatalogIcon::ExternalLink))
.label("shellrs.com")
.on_click(|_, window, cx| window.dispatch_action(Box::new(OpenWebsite), cx))
}),
)
.description("功能介绍、下载和更新内容。")]),
]
}
@@ -843,7 +1062,7 @@ fn font_family_field(panel: &SettingsPanel, cx: &App) -> SettingField<SharedStri
/// The terminal font size, a dropdown of whole pixels. Not a number input:
/// that clamps to its minimum as each digit is typed, so 「14」 would turn
/// into 8 at its 「1」.
/// into 10 at its 「1」.
fn font_size_field(store: &Entity<SettingsStore>) -> SettingField<SharedString> {
let default = AppSettings::default().terminal_font.size;
let (start, end) = (
+316
View File
@@ -0,0 +1,316 @@
//! 键盘快捷键: each shortcut with its keys, to change by pressing new ones,
//! turn off, or put back. Changes go straight to the settings; the keymap
//! is rebuilt from them (`app::apply_shortcuts`).
use gpui_kit::component::{
ActiveTheme as _, Disableable as _, Icon, Selectable as _, Sizable as _, Size, WindowExt as _,
button::{Button, ButtonVariants as _},
h_flex,
notification::Notification,
setting::{RenderOptions, SettingField, SettingGroup, SettingItem},
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{
CatalogIcon, Shortcut, ShortcutGroup, ShortcutOverrides, check, fixed_bindings, key_caps,
key_text,
};
use super::SettingsStore;
/// The page's state: the shortcut whose new keys are being pressed, if any.
pub struct ShortcutsEditor {
store: Entity<SettingsStore>,
recording: Option<&'static Shortcut>,
/// The box that says 按下组合键; while it has the keyboard, every key
/// pressed is taken as the new shortcut instead of doing anything.
focus_handle: FocusHandle,
/// What had the keyboard before, given it back once the keys are in.
previous_focus: Option<FocusHandle>,
intercept: Option<Subscription>,
_blur: Subscription,
}
impl ShortcutsEditor {
pub fn new(store: Entity<SettingsStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
// Clicking anywhere else gives up, as Escape does.
let blur = cx.on_blur(&focus_handle, window, |this, _, cx| this.stop(cx));
Self {
store,
recording: None,
focus_handle,
previous_focus: None,
intercept: None,
_blur: blur,
}
}
fn start(&mut self, shortcut: &'static Shortcut, window: &mut Window, cx: &mut Context<Self>) {
self.previous_focus = window.focused(cx);
self.recording = Some(shortcut);
window.focus(&self.focus_handle, cx);
// Before the keymap: a key bound to something, ⌘W say, would do it
// before an element's own key listener heard of it.
let editor = cx.weak_entity();
let focus = self.focus_handle.clone();
self.intercept = Some(cx.intercept_keystrokes(move |event, window, cx| {
if !focus.is_focused(window) {
return;
}
cx.stop_propagation();
let keystroke = event.keystroke.clone();
editor
.update(cx, |editor, cx| editor.record(&keystroke, window, cx))
.ok();
}));
cx.notify();
}
fn stop(&mut self, cx: &mut Context<Self>) {
if self.recording.take().is_some() {
self.intercept = None;
cx.notify();
}
}
/// Done pressing keys: back to what had the keyboard before.
fn finish(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.stop(cx);
if let Some(previous) = self.previous_focus.take() {
window.focus(&previous, cx);
}
}
fn record(&mut self, keystroke: &Keystroke, window: &mut Window, cx: &mut Context<Self>) {
let Some(shortcut) = self.recording else {
return;
};
// A modifier on its own is the start of a combination.
if matches!(
keystroke.key.as_str(),
"shift" | "control" | "alt" | "platform" | "function"
) {
return;
}
if keystroke.key == "escape" && !keystroke.modifiers.modified() {
self.finish(window, cx);
return;
}
let overrides = self.store.read(cx).settings().shortcuts;
match check(shortcut, keystroke, &overrides, &fixed_bindings(cx)) {
Ok(keys) => {
self.change(|overrides| overrides.set(shortcut, keys), cx);
self.finish(window, cx);
}
// Still listening, for another try.
Err(refusal) => {
let pressed = Keystroke {
modifiers: keystroke.modifiers,
key: keystroke.key.clone(),
key_char: None,
};
let message = refusal.message(&key_text(&pressed.unparse(), false));
window.push_notification(Notification::warning(message), cx);
}
}
}
fn change(&self, change: impl FnOnce(&mut ShortcutOverrides), cx: &mut App) {
self.store.update(cx, |store, cx| {
store.update(|settings| change(&mut settings.shortcuts), cx)
});
}
}
/// The page: a group of shortcuts for each part of the app.
pub(super) fn shortcut_groups(editor: &Entity<ShortcutsEditor>) -> Vec<SettingGroup> {
ShortcutGroup::ALL
.into_iter()
.map(|group| {
SettingGroup::new()
.title(group.title())
.items(Shortcut::in_group(group).map(|shortcut| shortcut_item(editor, shortcut)))
})
.collect()
}
/// One shortcut: its name, then its keys, 禁用 and 恢复默认. The page's
/// 重置 puts it back too.
fn shortcut_item(editor: &Entity<ShortcutsEditor>, shortcut: &'static Shortcut) -> SettingItem {
let (field, dirty, reset) = (editor.clone(), editor.clone(), editor.clone());
SettingItem::new(
shortcut.label(),
SettingField::render(move |options, window, cx| {
render_field(&field, shortcut, options, window, cx)
})
.on_reset(
move |cx| {
!dirty
.read(cx)
.store
.read(cx)
.settings()
.shortcuts
.is_default(shortcut)
},
move |_, cx| {
reset.update(cx, |editor, cx| {
editor.change(|overrides| overrides.reset(shortcut), cx)
});
},
),
)
}
fn element_id(kind: &str, shortcut: &Shortcut) -> ElementId {
SharedString::from(format!("shortcut-{kind}-{}", shortcut.id())).into()
}
fn render_field(
editor: &Entity<ShortcutsEditor>,
shortcut: &'static Shortcut,
options: &RenderOptions,
_: &mut Window,
cx: &mut App,
) -> AnyElement {
let state = editor.read(cx);
let overrides = state.store.read(cx).settings().shortcuts;
let recording = state
.recording
.is_some_and(|recording| std::ptr::eq(recording, shortcut));
let focus_handle = state.focus_handle.clone();
let size = options.size();
let keys = overrides.keys(shortcut);
let disabled = overrides.is_disabled(shortcut);
let is_default = overrides.is_default(shortcut);
let theme = cx.theme();
let keys_area = if recording {
div()
.id(element_id("recorder", shortcut))
.test_support()
.track_focus(&focus_handle)
.map(|this| match size {
Size::XSmall | Size::Small => this.h_6(),
_ => this.h_8(),
})
.min_w(rems(8.))
.px_2()
.flex()
.items_center()
.rounded(theme.radius)
.border_1()
.border_color(theme.ring)
.bg(theme.background)
.text_sm()
.text_color(theme.muted_foreground)
.child("按下组合键")
.into_any_element()
} else {
let start = editor.clone();
// What the caps show, for screen readers.
let spoken = keys
.as_deref()
.map(|keys| key_text(keys, shortcut.is_numbered()))
.unwrap_or_else(|| "无".into());
Button::new(element_id("keys", shortcut))
.ghost()
.with_size(size)
.accessibility_label(spoken)
.tooltip("修改快捷键")
.child(key_caps_element(
keys.as_deref(),
shortcut.is_numbered(),
cx,
))
.on_click(move |_, window, cx| {
start.update(cx, |editor, cx| editor.start(shortcut, window, cx))
})
.into_any_element()
};
let (toggle, reset) = (editor.clone(), editor.clone());
h_flex()
.gap_1()
.child(keys_area)
.child(
Button::new(element_id("disable", shortcut))
.ghost()
.with_size(size)
.icon(Icon::new(CatalogIcon::Ban))
.selected(disabled)
.tooltip(if disabled { "启用" } else { "禁用" })
.on_click(move |_, _, cx| {
toggle.update(cx, |editor, cx| {
editor.change(
|overrides| {
if disabled {
overrides.reset(shortcut)
} else {
overrides.disable(shortcut)
}
},
cx,
)
})
}),
)
.child(
Button::new(element_id("reset", shortcut))
.ghost()
.with_size(size)
.icon(Icon::new(CatalogIcon::RotateCcw))
.tooltip("恢复默认")
.disabled(is_default)
.on_click(move |_, _, cx| {
reset.update(cx, |editor, cx| {
editor.change(|overrides| overrides.reset(shortcut), cx)
})
}),
)
.into_any_element()
}
/// The keys as caps joined by +, as in ⌘ + N; 无 when turned off.
fn key_caps_element(keys: Option<&str>, numbered: bool, cx: &App) -> AnyElement {
let theme = cx.theme();
let Some(keys) = keys else {
return div()
.text_sm()
.text_color(theme.muted_foreground)
.child("无")
.into_any_element();
};
let caps = key_caps(keys, numbered);
let last = caps.len().saturating_sub(1);
h_flex()
.gap_1()
.children(caps.into_iter().enumerate().flat_map(|(ix, cap)| {
let cap = div()
.h_5()
.min_w_5()
.px_1()
.flex()
.items_center()
.justify_center()
.rounded(theme.radius)
.border_1()
.border_color(theme.border)
.bg(theme.background)
.text_xs()
.text_color(theme.foreground)
.child(cap)
.into_any_element();
let plus = (ix < last).then(|| {
div()
.text_xs()
.text_color(theme.muted_foreground)
.child("+")
.into_any_element()
});
std::iter::once(cap).chain(plus)
}))
.into_any_element()
}
+248
View File
@@ -0,0 +1,248 @@
//! 外观 → 主题: the light themes and the dark ones side by side, a card
//! each showing a few lines of a shell in its colors, the one in use marked.
//! Choosing a card writes the settings at once; which column is in effect
//! follows the app's appearance, and the app and its terminals both take
//! its colors.
use gpui_kit::base::{Radio, RadioGroup};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, ThemeMode, h_flex,
scroll::ScrollableElement as _,
setting::{SettingField, SettingItem},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::terminal::{TerminalFont, TerminalTheme};
use super::{SettingsStore, TerminalThemeSettings};
/// How tall each column's list is, in rems: about three cards, the third
/// cut off so the list shows that it scrolls.
const LIST_HEIGHT: f32 = 24.;
/// What a card shows: a prompt and `ls -l`, as Debian colors them, in the
/// theme's green, blue and cyan. Each part names the ANSI color it is
/// drawn in, or the theme's text color.
const SAMPLE: &[&[(&str, Option<u8>)]] = &[
&[
("root@shellrs", Some(2)),
(":", None),
("~", Some(4)),
("$ ls -l", None),
],
&[("drwxr-xr-x 2 root ", None), ("boot", Some(4))],
&[
("lrwxrwxrwx 1 root ", None),
("bin", Some(6)),
(" -> usr/bin", None),
],
];
/// The group's one item: both columns, and the page's 重置 putting both
/// back to the defaults.
pub(super) fn terminal_theme_item(store: &Entity<SettingsStore>) -> SettingItem {
let (reader, dirty, reset) = (store.clone(), store.clone(), store.clone());
SettingItem::render(move |_, _, cx| {
let chosen = reader.read(cx).settings().terminal_theme;
h_flex()
.w_full()
.items_start()
.gap_4()
.child(column(ThemeMode::Light, &chosen, &reader, cx))
.child(column(ThemeMode::Dark, &chosen, &reader, cx))
})
.keywords(
["主题", "终端主题", "配色", "颜色", "浅色", "深色"]
.into_iter()
.chain(TerminalTheme::all().iter().map(TerminalTheme::name)),
)
.on_reset(
move |cx| !dirty.read(cx).settings().terminal_theme.is_default_choice(),
move |_, cx| {
reset.update(cx, |store, cx| {
store.update(|settings| settings.terminal_theme.reset_choice(), cx)
});
},
)
}
/// 界面跟随主题: off, the app is neutral, gpui-kit's own black, white and
/// grays, and only the terminals take the theme's colors.
pub(super) fn app_follows_item(store: &Entity<SettingsStore>) -> SettingItem {
let (reader, writer) = (store.clone(), store.clone());
SettingItem::new(
"界面跟随主题",
SettingField::switch(
move |cx| reader.read(cx).settings().terminal_theme.app_follows,
move |on, cx| {
writer.update(cx, |store, cx| {
store.update(|settings| settings.terminal_theme.app_follows = on, cx)
});
},
)
.default_value(false),
)
.description("关闭时界面用中性的黑白灰,只有终端按所选主题配色。")
}
/// One appearance's themes under its name, scrolling on their own.
fn column(
mode: ThemeMode,
chosen: &TerminalThemeSettings,
store: &Entity<SettingsStore>,
cx: &App,
) -> impl IntoElement {
let title = match mode {
ThemeMode::Light => "浅色",
ThemeMode::Dark => "深色",
};
let chosen = chosen.theme(mode);
let themes: Vec<_> = TerminalTheme::for_mode(mode).collect();
let total = themes.len();
v_flex()
.flex_1()
.min_w_0()
.gap_2()
.child(div().text_sm().font_medium().child(title))
.child(
div()
.id(column_id("terminal-themes", mode))
.w_full()
.h(rems(LIST_HEIGHT))
.child(
RadioGroup::new(column_id("terminal-theme-group", mode))
.axis(Axis::Vertical)
.flex()
.flex_col()
.gap_1()
// The scrollbar's lane, so it never covers a card's
// check mark.
.pr_3()
.children(themes.into_iter().enumerate().map(|(ix, theme)| {
card(theme, theme == chosen, (ix + 1, total), store, cx)
})),
)
.overflow_y_scrollbar()
.id(column_id("terminal-themes-scroll", mode)),
)
}
/// `prefix-light` or `prefix-dark`.
fn column_id(prefix: &str, mode: ThemeMode) -> ElementId {
SharedString::from(format!("{prefix}-{}", mode.name())).into()
}
/// A theme's name over its sample. The chosen one stands out like a chosen
/// segment and carries the check mark.
fn card(
theme: &'static TerminalTheme,
checked: bool,
(position, total): (usize, usize),
store: &Entity<SettingsStore>,
cx: &App,
) -> impl IntoElement {
let colors = cx.theme();
let store = store.clone();
Radio::new(SharedString::from(format!(
"terminal-theme-{}",
theme.key()
)))
.checked(checked)
.accessibility_label(theme.name())
.set_position(position, total)
.w_full()
.flex()
.flex_col()
.gap_2()
.p_3()
.rounded(colors.radius_lg)
// Every card has the border, so choosing one moves nothing.
.border_1()
.border_color(colors.transparent)
.styles(|styles| styles.checked(|style| style.bg(colors.accent)))
// Half of what choosing it would show.
.when(!checked, |radio| {
radio.hover(|style| style.bg(colors.accent.opacity(0.5)))
})
.focus_visible(|style| style.border_color(colors.ring))
// A click chooses without taking the keyboard from where it was,
// as gpui-component's radios do.
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_change(move |_, _, _, cx| {
store.update(cx, |store, cx| {
store.update(|settings| settings.terminal_theme.choose(theme), cx)
});
})
.child(
h_flex()
.w_full()
.gap_2()
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.child(theme.name()),
)
// A fixed slot, so the name does not move with the mark: a disc
// in the theme's blue, the hue of selection, the check in the
// page's color so it shows on the disc in light and dark.
.child(
div()
.flex_shrink_0()
.size_5()
.flex()
.items_center()
.justify_center()
.rounded(colors.radius_full())
.when(checked, |slot| {
slot.bg(colors.blue).child(
Icon::new(IconName::Check)
.xsmall()
.text_color(colors.background),
)
}),
),
)
.child(sample(theme, cx))
}
/// The sample lines in the terminal font and the theme's colors, framed by
/// a hairline so a background like the page's still shows its edge.
fn sample(theme: &'static TerminalTheme, cx: &App) -> impl IntoElement {
let font = TerminalFont::current(cx);
v_flex()
.w_full()
.px_3()
.py_2()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
.bg(theme.background())
.text_color(theme.foreground())
.font_family(font.family(cx))
.text_sm()
.overflow_hidden()
.children(SAMPLE.iter().map(|parts| {
let mut text = String::new();
let mut highlights = Vec::new();
for (part, color) in parts.iter() {
if let Some(color) = color {
highlights.push((
text.len()..text.len() + part.len(),
HighlightStyle {
color: Some(theme.indexed(*color)),
..HighlightStyle::default()
},
));
}
text.push_str(part);
}
div()
.whitespace_nowrap()
.child(StyledText::new(text).with_highlights(highlights))
}))
}
+1
View File
@@ -12,6 +12,7 @@ mod journal;
mod meter;
mod operations;
mod speed;
mod sync;
mod upload;
mod worker;
pub use worker::SshSftpTransportProvider;
+47 -4
View File
@@ -162,9 +162,21 @@ impl DirectoryListing {
pub struct UploadRequest {
sources: Vec<PathBuf>,
destination: RemotePath,
scp: bool,
mode: UploadMode,
target_name: Option<String>,
}
/// How an upload treats what it is given, and what is already there.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UploadMode {
/// The SFTP tab's: the sources go into the destination folder.
Into,
Scp,
Sync {
delete: bool,
},
}
impl UploadRequest {
pub fn new(sources: Vec<PathBuf>, destination: RemotePath) -> Result<Self> {
if sources.is_empty() {
@@ -173,7 +185,7 @@ impl UploadRequest {
Ok(Self {
sources,
destination,
scp: false,
mode: UploadMode::Into,
target_name: None,
})
}
@@ -184,7 +196,21 @@ impl UploadRequest {
Self {
sources: vec![source],
destination,
scp: true,
mode: UploadMode::Scp,
target_name: None,
}
}
/// What the local folder `source` holds, copied into the folder
/// `destination` (made when it is not there) for the external CLI's
/// `sync`: files of the same size and modification time, and links to
/// the same place, are left alone, and new files keep their permission
/// bits as with scp. With `delete`, what is there and not in `source`
/// goes first.
pub fn sync(source: PathBuf, destination: RemotePath, delete: bool) -> Self {
Self {
sources: vec![source],
destination,
mode: UploadMode::Sync { delete },
target_name: None,
}
}
@@ -195,7 +221,19 @@ impl UploadRequest {
&self.destination
}
pub fn is_scp(&self) -> bool {
self.scp
self.mode == UploadMode::Scp
}
/// `Some` for a sync, saying whether it deletes.
pub fn sync_deletes(&self) -> Option<bool> {
match self.mode {
UploadMode::Sync { delete } => Some(delete),
_ => None,
}
}
/// The source copied from, once a sync has resolved it.
pub(crate) fn with_source(mut self, source: PathBuf) -> Self {
self.sources = vec![source];
self
}
/// The name the source is copied under, once an scp destination is
/// resolved; `None` keeps the source's own.
@@ -327,6 +365,8 @@ pub struct TransferProgress {
pub(crate) succeeded: usize,
pub(crate) skipped: usize,
pub(crate) failed: usize,
/// What a sync removed from the host before copying.
pub(crate) deleted: usize,
pub(crate) bytes_per_second: u64,
pub(crate) details: Vec<TransferDetail>,
/// The file in flight: where it comes from, and how far it has got.
@@ -464,6 +504,9 @@ impl TransferProgress {
pub fn skipped(&self) -> usize {
self.skipped
}
pub fn deleted(&self) -> usize {
self.deleted
}
pub fn failed(&self) -> usize {
self.failed
}
+1 -1
View File
@@ -66,7 +66,7 @@ pub(crate) async fn run<F: RemoteFs>(fs: &F, operation: &RemoteOperation) -> Res
/// Delete a path and, for a directory, everything under it: children before
/// their directory. A link is removed as a link.
async fn delete_tree<F: RemoteFs>(fs: &F, root: &RemotePath) -> Result<()> {
pub(super) async fn delete_tree<F: RemoteFs>(fs: &F, root: &RemotePath) -> Result<()> {
let mut stack = vec![(root.clone(), false)];
while let Some((path, emptied)) = stack.pop() {
if emptied {
+137
View File
@@ -0,0 +1,137 @@
//! What the external CLI's `sync --delete` removes before it copies:
//! whatever is in the remote folder and not in the local one, or is there
//! as another kind (a folder where the local one has a file, a link where
//! it has a folder). Links are removed as links, never followed.
use std::{collections::HashMap, path::Path};
use anyhow::Result;
use super::{
EntryKind, RemotePath, TransferChoice, TransferDetail, TransferQuestionKind,
client::{RemoteFs, is_network_error},
control::{Cancelled, TransferControl},
model::local_metadata,
operations::delete_tree,
};
/// What the pruning did: how many entries it removed (a folder with all
/// in it counts once), and what it could not remove.
#[derive(Debug, Default)]
pub(super) struct Pruned {
pub deleted: usize,
pub failures: Vec<TransferDetail>,
}
/// Remove from the folder `remote` what the local folder `local` does not
/// have, all the way down. A folder that cannot be read here is left alone
/// there: not being able to see what it holds says nothing about what
/// belongs in its copy. What a transfer leaves behind while it runs (a
/// `.filepart`, a `.shellrs-…backup`) stays too: it may be another
/// transfer's, or the only copy of a file being replaced.
pub(super) async fn prune<F: RemoteFs>(
fs: &F,
local: &Path,
remote: &RemotePath,
control: &TransferControl,
) -> Result<Pruned> {
let mut pruned = Pruned::default();
let mut stack = vec![(local.to_path_buf(), remote.clone())];
while let Some((local, remote)) = stack.pop() {
control.check()?;
let Ok(here) = local_kinds(&local).await else {
continue;
};
let there = match control.run(fs.read_dir(&remote)).await {
Ok(there) => there,
Err(error) if gives_up(&error) => return Err(error),
Err(error) => {
fail(&mut pruned, &remote, &error, control).await?;
continue;
}
};
for entry in there {
let name = entry.name();
let path = remote.join(name)?;
let kind = entry.metadata().kind();
match here.get(name) {
Some(Some(local_kind)) if *local_kind == kind => {
if kind == EntryKind::Directory {
stack.push((local.join(name), path));
}
}
// There, but what it is cannot be told: left alone.
Some(None) => {}
None if is_transfer_leftover(name) => {}
_ => loop {
match control.run(delete_tree(fs, &path)).await {
Ok(()) => {
pruned.deleted += 1;
break;
}
Err(error) if gives_up(&error) => return Err(error),
Err(error) => {
if !fail(&mut pruned, &path, &error, control).await? {
break;
}
}
}
},
}
}
}
Ok(pruned)
}
/// A failure that ends the sync rather than one entry: cancelled, or the
/// connection gone.
fn gives_up(error: &anyhow::Error) -> bool {
error.is::<Cancelled>() || is_network_error(error)
}
/// Say what could not be done, as an upload says why a file failed;
/// whether to try again.
async fn fail(
pruned: &mut Pruned,
path: &RemotePath,
error: &anyhow::Error,
control: &TransferControl,
) -> Result<bool> {
let message = format!("无法删除:{error:#}");
let answer = control
.ask(TransferQuestionKind::Error, path.as_str(), &message)
.await?;
if answer.choice() == TransferChoice::Retry {
return Ok(true);
}
pruned.failures.push(TransferDetail::failed(
path.to_string(),
format!("{error:#}"),
));
Ok(false)
}
/// What a local folder holds, by name, links not followed; `None` for an
/// entry whose kind cannot be read.
async fn local_kinds(dir: &Path) -> std::io::Result<HashMap<String, Option<EntryKind>>> {
let mut kinds = HashMap::new();
let mut entries = tokio::fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
// A name that is not UTF-8 cannot be copied either; what is there
// under some name is left alone rather than taken for missing.
let Ok(name) = entry.file_name().into_string() else {
continue;
};
let kind = tokio::fs::symlink_metadata(entry.path())
.await
.ok()
.map(|metadata| local_metadata(&metadata).kind());
kinds.insert(name, kind);
}
Ok(kinds)
}
/// The files an upload writes beside its target while it runs.
fn is_transfer_leftover(name: &str) -> bool {
name.ends_with(".filepart") || (name.starts_with(".shellrs-") && name.ends_with(".backup"))
}
+134
View File
@@ -2137,3 +2137,137 @@ fn a_binary_file_is_read_whole_for_a_preview_within_its_limit() {
);
});
}
/// The external CLI's sync of `local` into `/srv/app`, resolved as the
/// worker resolves it.
async fn sync_batch(local: &Path, tmp: &Path, control: &TransferControl) -> UploadBatch {
let request = UploadRequest::sync(local.into(), remote_path("/srv/app"), false)
.resolved(remote_path("/srv"), Some("app".into()));
UploadBatch::scan(
&request,
"test@remote",
"host-key",
Journal::new(tmp.join("sync-journal")),
control,
)
.await
.unwrap()
}
#[cfg(unix)]
#[test]
fn a_sync_copies_only_what_changed_since_the_last() {
use std::os::unix::fs::PermissionsExt as _;
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let dist = tmp.path().join("dist");
std::fs::create_dir_all(dist.join("assets")).unwrap();
std::fs::write(dist.join("index.html"), b"v1").unwrap();
std::fs::write(dist.join("run.sh"), b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(dist.join("run.sh"), std::fs::Permissions::from_mode(0o755))
.unwrap();
std::fs::write(dist.join("assets/logo.png"), b"png").unwrap();
std::os::unix::fs::symlink("index.html", dist.join("home.html")).unwrap();
let remote = Remote::new(true);
remote.dir("/srv", 0o755);
let answers = Answers::new(vec![]);
let mut first = sync_batch(&dist, tmp.path(), &answers.control).await;
run(&mut first, &remote, &answers.control).await.unwrap();
let progress = &first.meter.progress;
assert_eq!((progress.succeeded(), progress.skipped()), (6, 0));
assert_eq!(remote.bytes("/srv/app/assets/logo.png"), b"png");
// New files keep their permission bits, as with scp.
assert_eq!(remote.mode("/srv/app/run.sh"), 0o755);
// Nothing changed: nothing copied, nothing asked, folders included.
let mut second = sync_batch(&dist, tmp.path(), &answers.control).await;
run(&mut second, &remote, &answers.control).await.unwrap();
let progress = &second.meter.progress;
assert_eq!((progress.succeeded(), progress.skipped()), (0, 6));
assert!(answers.questions.lock().unwrap().is_empty());
// One file changed: that one, over what is there.
std::fs::write(dist.join("index.html"), b"version 2").unwrap();
let answers = Answers::new(vec![TransferChoice::Overwrite]);
let mut third = sync_batch(&dist, tmp.path(), &answers.control).await;
run(&mut third, &remote, &answers.control).await.unwrap();
let progress = &third.meter.progress;
assert_eq!((progress.succeeded(), progress.skipped()), (1, 5));
assert_eq!(remote.bytes("/srv/app/index.html"), b"version 2");
assert_eq!(
*answers.questions.lock().unwrap(),
[TransferQuestionKind::Conflict]
);
});
}
#[cfg(unix)]
#[test]
fn a_sync_with_delete_removes_what_is_not_here_and_nothing_it_cannot_see() {
use std::os::unix::fs::PermissionsExt as _;
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let dist = tmp.path().join("dist");
std::fs::create_dir_all(dist.join("sub")).unwrap();
std::fs::create_dir_all(dist.join("locked")).unwrap();
std::fs::write(dist.join("keep.txt"), b"k").unwrap();
std::fs::write(dist.join("kind"), b"a file here").unwrap();
std::fs::set_permissions(dist.join("locked"), std::fs::Permissions::from_mode(0o000))
.unwrap();
let remote = Remote::new(true);
remote.dir("/srv/app", 0o755);
remote.file("/srv/app/keep.txt", b"k");
remote.file("/srv/app/extra.txt", b"x");
remote.dir("/srv/app/old", 0o755);
remote.file("/srv/app/old/file", b"o");
// A folder there where a file is here.
remote.dir("/srv/app/kind", 0o755);
remote.file("/srv/app/kind/inner", b"i");
// What a transfer leaves while it runs.
remote.file("/srv/app/keep.txt.filepart", b"partial");
remote.file("/srv/app/.shellrs-1234.backup", b"original");
// A folder this side cannot read, and one that side cannot.
remote.dir("/srv/app/locked", 0o755);
remote.file("/srv/app/locked/extra", b"?");
remote.dir("/srv/app/sub", 0o755);
remote.file("/srv/app/sub/extra", b"?");
*remote.denied.borrow_mut() = Some("/srv/app/sub".into());
let answers = Answers::new(vec![TransferChoice::Skip]);
let pruned = super::sync::prune(&remote, &dist, &remote_path("/srv/app"), &answers.control)
.await
.unwrap();
std::fs::set_permissions(dist.join("locked"), std::fs::Permissions::from_mode(0o755))
.unwrap();
assert_eq!(pruned.deleted, 3);
assert_eq!(pruned.failures.len(), 1);
let keys: Vec<_> = remote.nodes.borrow().keys().cloned().collect();
assert_eq!(
keys,
[
"/srv/app",
"/srv/app/.shellrs-1234.backup",
"/srv/app/keep.txt",
"/srv/app/keep.txt.filepart",
"/srv/app/locked",
"/srv/app/locked/extra",
"/srv/app/sub",
"/srv/app/sub/extra",
]
);
assert_eq!(
*answers.questions.lock().unwrap(),
[TransferQuestionKind::Error]
);
// Counted with the batch that follows: what went, and what failed.
let mut batch = sync_batch(&dist, tmp.path(), &answers.control).await;
let total = batch.meter.progress.total();
batch.add_pruned(pruned.deleted, pruned.failures);
let progress = &batch.meter.progress;
assert_eq!((progress.deleted(), progress.failed()), (3, 1));
assert_eq!(progress.total(), total + 1);
});
}
+48 -2
View File
@@ -35,6 +35,9 @@ pub(crate) struct UploadBatch {
/// A new remote file takes the local file's permission bits, as scp
/// gives them; otherwise the server's defaults apply.
preserve_mode: bool,
/// The external CLI's sync: what is already there as it is here is
/// left alone, folders included, and counted as skipped.
sync: bool,
}
impl UploadBatch {
pub async fn scan(
@@ -136,9 +139,20 @@ impl UploadBatch {
all_conflicts: None,
approved_resumes: HashSet::new(),
blocked_directories: Vec::new(),
preserve_mode: request.is_scp(),
preserve_mode: request.is_scp() || request.sync_deletes().is_some(),
sync: request.sync_deletes().is_some(),
})
}
/// Count what a sync deleted before the batch was scanned, and what it
/// failed to delete among the items that failed.
pub fn add_pruned(&mut self, deleted: usize, failures: Vec<TransferDetail>) {
let progress = &mut self.meter.progress;
progress.deleted += deleted;
progress.total += failures.len();
progress.failed += failures.len();
progress.details.extend(failures);
}
pub fn verify_host(&self, fingerprint: &str) -> Result<()> {
if self.host_key != fingerprint {
bail!("服务器主机指纹已改变,已停止续传");
@@ -305,7 +319,7 @@ impl UploadBatch {
self.discard_record(fs, record).await?;
}
return match control.run(fs.metadata(&item.target)).await? {
Some(metadata) if metadata.kind() == EntryKind::Directory => Ok(true),
Some(metadata) if metadata.kind() == EntryKind::Directory => Ok(!self.sync),
Some(_) => bail!("目标已存在且不是目录,不会跟随或替换链接"),
None => {
control.run(fs.mkdir(&item.target)).await?;
@@ -339,6 +353,9 @@ impl UploadBatch {
record
} else {
let original = control.run(fs.metadata(&item.target)).await?;
if self.sync && self.unchanged(fs, item, original.as_ref(), control).await? {
return Ok(false);
}
if !self
.approve(&item.target, &original, false, control)
.await?
@@ -431,6 +448,35 @@ impl UploadBatch {
self.journal.save(&record).await?;
self.recover_publish(fs, &mut record, control).await
}
/// For a sync: whether `remote`, what is at the item's target, is
/// already what the item is. A file of the same size and modification
/// time (an upload gives the copy the local file's), a link to the same
/// place. A side that does not say when it was modified has changed.
async fn unchanged<F: RemoteFs>(
&self,
fs: &F,
item: &UploadItem,
remote: Option<&FileMetadata>,
control: &TransferControl,
) -> Result<bool> {
let Some(remote) = remote else {
return Ok(false);
};
let local = &item.metadata;
Ok(match (local.kind(), remote.kind()) {
(EntryKind::File, EntryKind::File) => {
local.size() == remote.size()
&& local.modified().is_some()
&& local.modified() == remote.modified()
}
(EntryKind::Symlink, EntryKind::Symlink) => {
let here = tokio::fs::read_link(&item.source).await?;
let there = control.run(fs.readlink(&item.target)).await?;
here.to_str() == Some(there.as_str())
}
_ => false,
})
}
async fn upload_file<F: RemoteFs>(
&mut self,
fs: &F,
+76 -13
View File
@@ -9,7 +9,7 @@ use super::{
journal::{DownloadJournal, Journal},
meter::TransferMeter,
model::{scp_local_target, scp_remote_target},
operations,
operations, sync,
upload::UploadBatch,
};
use crate::{
@@ -416,7 +416,30 @@ impl SshSftpTransport {
events
.send(SftpEvent::Progress(TransferProgress::default()))
.await?;
let request = if request.is_scp() {
let mut pruned = sync::Pruned::default();
let request = if let Some(delete) = request.sync_deletes() {
// The local folder itself, through any link to it:
// what it holds is what is copied.
let source = &request.sources()[0];
let local = tokio::fs::canonicalize(source)
.await
.ok()
.filter(|path| path.is_dir())
.map(|path| dunce::simplified(&path).to_path_buf())
.ok_or_else(|| anyhow!("本地目录 {} 不存在", source.display()))?;
let target =
sync_target(connected.as_ref(), request.destination(), &control)
.await?;
if delete {
pruned =
sync::prune(connected.as_ref(), &local, &target, &control).await?;
}
// Copied as the folder it goes to, which is there.
request
.clone()
.with_source(local)
.resolved(target.parent(), Some(target.file_name().to_string()))
} else if request.is_scp() {
let destination = request.destination();
let is_directory = destination.is_root()
|| control
@@ -445,17 +468,17 @@ impl SshSftpTransport {
.await?;
super::UploadRequest::new(request.sources().to_vec(), target)?
};
batch = Some(TransferBatch::Upload(
control
.run(UploadBatch::scan(
&request,
&self.config.endpoint(),
connected.fingerprint(),
self.journal.clone(),
&control,
))
.await?,
));
let mut upload = control
.run(UploadBatch::scan(
&request,
&self.config.endpoint(),
connected.fingerprint(),
self.journal.clone(),
&control,
))
.await?;
upload.add_pruned(pruned.deleted, pruned.failures);
batch = Some(TransferBatch::Upload(upload));
}
if let SftpCommand::Download(request) = &operation {
batch = None;
@@ -635,6 +658,46 @@ impl SshSftpTransport {
}
}
/// The folder a sync copies into: `destination` when it is a folder, links
/// to it resolved; made when it is not there, in a folder that is, as scp
/// would. Never the root: a sync with `--delete` would empty the server.
async fn sync_target(
fs: &SftpClient,
destination: &RemotePath,
control: &TransferControl,
) -> Result<RemotePath> {
let target = match control.run(fs.stat(destination)).await? {
Some(metadata) if metadata.kind() == EntryKind::Directory => {
control.run(fs.canonicalize(destination)).await?
}
Some(_) => anyhow::bail!("远程路径 {destination} 不是目录"),
None => {
let parent = destination.parent();
let missing = || anyhow!("远程目录 {parent} 不存在");
let parent = control
.run(fs.canonicalize(&parent))
.await
.map_err(|_| missing())?;
if !control
.run(fs.stat(&parent))
.await?
.is_some_and(|metadata| metadata.kind() == EntryKind::Directory)
{
return Err(missing());
}
let target = parent.join(destination.file_name())?;
if !target.is_root() {
control.run(fs.mkdir(&target)).await?;
}
target
}
};
if target.is_root() {
anyhow::bail!("不能同步到根目录 /");
}
Ok(target)
}
/// Measure the connection in use every [`LATENCY_INTERVAL`], and a new one
/// at once, transfers or not. The client is held only for a ping, and let go
/// as soon as it is no longer the one in use, so 断开 still closes the
+278 -27
View File
@@ -2,7 +2,7 @@ use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};
use alacritty_terminal::Term;
use alacritty_terminal::event::{Event, EventListener, WindowSize};
@@ -14,17 +14,24 @@ use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::color::COUNT;
use alacritty_terminal::term::search::RegexSearch;
use alacritty_terminal::term::{ClipboardType, Config, Osc52, TermMode};
use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, Rgb};
use alacritty_terminal::vte::ansi::{
Color, CursorShape, CursorStyle, Processor, Rgb, Timeout as _,
};
use futures::future::{Either, select};
use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency};
use crate::host::HostOs;
use super::highlight::{
HighlightColor, KeywordHit, LineWatcher, SharedHighlights, TerminalHighlights,
advance_watching, current,
};
use super::links::{LinkMarker, url_search, visible_links};
use super::notices::{NoticeScanner, ProgramNotice, TerminalNotice};
use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch};
use super::{
ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize,
TerminalStatus, TerminalTransportCommand, TerminalTransportEvent,
ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalColors, TerminalLifecycle,
TerminalSize, TerminalStatus, TerminalTransportCommand, TerminalTransportEvent,
};
pub type AlacrittyTerm = Term<TerminalEventProxy>;
@@ -39,6 +46,8 @@ pub struct TerminalCell {
pub search: SearchMark,
/// The link the cell belongs to, an index into the snapshot's `links`.
pub link: Option<usize>,
/// The color a 关键字高亮 rule shows the cell in, when one matches it.
pub highlight: Option<HighlightColor>,
}
#[derive(Clone, Debug)]
@@ -85,6 +94,10 @@ pub struct TerminalEngine {
search: RefCell<Option<TerminalSearch>>,
/// Finds the web addresses on screen, built once.
url_search: RefCell<RegexSearch>,
/// The 关键字高亮 rules, shared with the parser thread, which checks new
/// lines against them; swapped when the settings change them.
highlights: SharedHighlights,
_highlights_observer: gpui_kit::Subscription,
generation: u64,
size: TerminalSize,
event_sender: mpsc::Sender<TerminalUiEvent>,
@@ -99,8 +112,21 @@ impl TerminalEngine {
// threads therefore never wake GPUI's local executor directly, and a
// burst of terminal bytes results in a single render notification.
let (event_sender, event_receiver) = mpsc::channel();
let runtime =
TerminalRuntime::start(generation, size, factory.create(), event_sender.clone());
let highlights = Arc::new(Mutex::new(TerminalHighlights::current(cx)));
let runtime = TerminalRuntime::start(
generation,
size,
factory.create(),
event_sender.clone(),
highlights.clone(),
);
let highlights_observer = cx.observe_global::<TerminalHighlights>(|this, cx| {
*this
.highlights
.lock()
.unwrap_or_else(|error| error.into_inner()) = TerminalHighlights::current(cx);
cx.notify();
});
let mut this = Self {
factory,
runtime,
@@ -109,6 +135,8 @@ impl TerminalEngine {
latency: None,
search: RefCell::new(None),
url_search: RefCell::new(url_search()),
highlights,
_highlights_observer: highlights_observer,
generation,
size,
event_sender,
@@ -180,8 +208,9 @@ impl TerminalEngine {
return Some(TerminalEvent::HostOsDetected(os));
}
TerminalUiEventKind::ColorRequest(index, formatter) => {
// A color the program set itself, or else the theme's.
let color = self.runtime.term.lock().colors()[index]
.unwrap_or_else(|| default_query_color(index));
.unwrap_or_else(|| TerminalColors::current(cx).query_color(index));
self.runtime.write(formatter(color).into_bytes());
}
TerminalUiEventKind::ClipboardStore(text) => {
@@ -194,6 +223,11 @@ impl TerminalEngine {
let line = cursor_line_text(&self.runtime.term.lock());
return Some(TerminalEvent::Notice(TerminalNotice::bell(&line)));
}
TerminalUiEventKind::Keyword(hit) => {
return Some(TerminalEvent::Notice(TerminalNotice::keyword(
&hit.rule, &hit.line,
)));
}
}
None
}
@@ -211,6 +245,7 @@ impl TerminalEngine {
self.size,
self.factory.create(),
self.event_sender.clone(),
self.highlights.clone(),
);
cx.notify();
}
@@ -461,6 +496,12 @@ impl TerminalEngine {
)
}
/// Whether the cursor blinks: the program may say. Cheaper than a
/// snapshot, which the blink timer would otherwise take twice a second.
pub fn cursor_blinking(&self) -> bool {
self.runtime.term.lock().cursor_style().blinking
}
pub fn snapshot(&self) -> TerminalSnapshot {
let term = self.runtime.term.lock();
let content = term.renderable_content();
@@ -478,6 +519,7 @@ impl TerminalEngine {
.unwrap_or_default();
let mut marker = MatchMarker::new(&matches, focused.as_ref());
let mut links = LinkMarker::new(visible_links(&term, &mut self.url_search.borrow_mut()));
let highlights = current(&self.highlights).screen(&term);
let mut cells: Vec<_> = content
.display_iter
.map(|indexed| {
@@ -495,6 +537,10 @@ impl TerminalEngine {
}),
search: marker.mark(indexed.point),
link: links.mark(indexed.point, indexed.cell.hyperlink()),
highlight: highlights.color(
(indexed.point.line.0 + display_offset) as usize,
indexed.point.column.0,
),
}
})
.collect();
@@ -590,6 +636,7 @@ impl TerminalRuntime {
size: TerminalSize,
transport: Box<dyn super::TerminalTransport>,
ui_events: mpsc::Sender<TerminalUiEvent>,
highlights: SharedHighlights,
) -> Self {
let (commands, command_receiver) = mpsc::channel();
let (transport_events, transport_receiver) = async_channel::unbounded();
@@ -610,16 +657,49 @@ impl TerminalRuntime {
thread::Builder::new()
.name("shellrs-terminal-parser".into())
.spawn(move || {
let mut processor = Processor::new();
let mut processor: Processor = Processor::new();
// Alacritty drops the notifications programs ask for.
let mut notices = NoticeScanner::default();
while let Ok(event) = transport_receiver.recv_blocking() {
let mut watcher = LineWatcher::new(current(&highlights));
loop {
// Output inside a synchronized update is held back until
// the program ends it. Alacritty's own event loop draws
// it anyway once the update's time is up, so a program
// that never ends it (killed mid-frame, or its connection
// lost) does not freeze the screen; this loop has to too.
let deadline = processor.sync_timeout().sync_timeout();
let event = match receive(&transport_receiver, deadline) {
Received::Event(event) => event,
Received::Closed => break,
Received::Overdue => {
processor.stop_sync(&mut *parser_term.lock());
parser_proxy.wakeup();
continue;
}
};
match event {
TerminalTransportEvent::Started => {
parser_proxy.send(TerminalUiEventKind::Started);
}
TerminalTransportEvent::Output(bytes) => {
processor.advance(&mut *parser_term.lock(), &bytes);
watcher.sync(current(&highlights));
// The lines are only read under the lock; the
// rules match them once it is let go.
let lines = {
let mut term = parser_term.lock();
// Output that never pauses gives the wait no
// chance to see the time is up.
end_overdue_sync(&mut processor, &mut term);
advance_watching(
&mut processor,
&mut term,
&bytes,
watcher.is_watching(),
)
};
if let Some(hit) = watcher.check(lines, Instant::now()) {
parser_proxy.send(TerminalUiEventKind::Keyword(hit));
}
for notice in notices.scan(&bytes) {
parser_proxy.send(TerminalUiEventKind::Notice(notice));
}
@@ -824,6 +904,8 @@ enum TerminalUiEventKind {
/// A notification a program asked for, by OSC 9 or 777.
Notice(ProgramNotice),
Bell,
/// A finished line that a 关键字高亮 rule tells of.
Keyword(KeywordHit),
}
fn append_message(term: &Arc<FairMutex<AlacrittyTerm>>, message: &str) {
@@ -837,7 +919,55 @@ fn append_message_with_processor(
message: &str,
) {
let line = format!("\r\n\x1b[2m[{message}]\x1b[0m\r\n");
processor.advance(&mut *term.lock(), line.as_bytes());
let mut term = term.lock();
// A program that ended mid-frame left its synchronized update open:
// neither that frame nor this message would show.
if processor.sync_timeout().pending_timeout() {
processor.stop_sync(&mut *term);
}
processor.advance(&mut *term, line.as_bytes());
}
/// What waiting for the transport came to.
enum Received<T> {
Event(T),
/// The open synchronized update's time is up before anything came.
Overdue,
/// The transport is gone.
Closed,
}
/// The transport's next event; with a synchronized update open, waiting
/// only until its `deadline`. `recv_blocking` cannot stop at a time, hence
/// the timer.
fn receive<T>(events: &async_channel::Receiver<T>, deadline: Option<Instant>) -> Received<T> {
let Some(deadline) = deadline else {
return events
.recv_blocking()
.map_or(Received::Closed, Received::Event);
};
async_io::block_on(async {
let next = std::pin::pin!(events.recv());
// What has come already wins over a deadline that has passed.
match select(next, async_io::Timer::at(deadline)).await {
Either::Left((Ok(event), _)) => Received::Event(event),
Either::Left((Err(_), _)) => Received::Closed,
Either::Right(_) => Received::Overdue,
}
})
}
/// Draw what an open synchronized update has held back, if its time is up.
/// Says whether it was.
fn end_overdue_sync(processor: &mut Processor, term: &mut AlacrittyTerm) -> bool {
let overdue = processor
.sync_timeout()
.sync_timeout()
.is_some_and(|deadline| deadline <= Instant::now());
if overdue {
processor.stop_sync(term);
}
overdue
}
fn sanitize_title(title: &str) -> Option<String> {
@@ -849,22 +979,6 @@ fn sanitize_title(title: &str) -> Option<String> {
(!title.trim().is_empty()).then_some(title)
}
fn default_query_color(index: usize) -> Rgb {
if index == alacritty_terminal::vte::ansi::NamedColor::Background as usize {
Rgb {
r: 30,
g: 30,
b: 30,
}
} else {
Rgb {
r: 224,
g: 224,
b: 224,
}
}
}
fn encode_paste(text: &str, mode: TermMode) -> Vec<u8> {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
if mode.contains(TermMode::BRACKETED_PASTE) {
@@ -1388,6 +1502,7 @@ mod tests {
SearchMark::None
},
link: None,
highlight: None,
};
let mut cells = vec![
cell(Flags::WIDE_CHAR, true),
@@ -1462,4 +1577,140 @@ mod tests {
// vim and less have the keys to themselves.
assert!(encode_command("ls", true, true, TermMode::ALT_SCREEN).is_err());
}
/// Sends its events, then stays quiet until shut down, as a shell does
/// when nothing is typed.
struct ScriptedTransport(Vec<TerminalTransportEvent>);
impl super::super::TerminalTransport for ScriptedTransport {
fn run(
self: Box<Self>,
_: TerminalSize,
commands: mpsc::Receiver<TerminalTransportCommand>,
events: async_channel::Sender<TerminalTransportEvent>,
) -> anyhow::Result<()> {
for event in self.0 {
events.send_blocking(event)?;
}
while let Ok(command) = commands.recv() {
if matches!(command, TerminalTransportCommand::Shutdown) {
break;
}
}
Ok(())
}
}
/// A terminal whose transport plays `script`.
fn run_script(script: Vec<TerminalTransportEvent>) -> TerminalRuntime {
let (ui_events, _) = mpsc::channel();
TerminalRuntime::start(
1,
TerminalSize::new(40, 4, 8, 16),
Box::new(ScriptedTransport(script)),
ui_events,
Arc::default(),
)
}
/// The screen's text once it holds `text`, or what it holds after two
/// seconds of waiting.
fn screen_once_it_shows(runtime: &TerminalRuntime, text: &str) -> String {
let started = Instant::now();
loop {
let term = runtime.term.lock();
let screen = (0..4)
.map(|line| {
term.grid()[Line(line)]
.into_iter()
.filter(|cell| !cell.flags.contains(Flags::WIDE_CHAR_SPACER))
.map(|cell| cell.c)
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n");
drop(term);
if screen.contains(text) || started.elapsed() > Duration::from_secs(2) {
return screen;
}
thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn an_update_left_open_is_drawn_once_its_time_is_up() {
// `printf '\e[?2026h'`, or a program killed between beginning a
// frame and ending it: nothing ends the update.
let started = Instant::now();
let runtime = run_script(vec![
TerminalTransportEvent::Started,
TerminalTransportEvent::Output(b"before \x1b[?2026hframe".to_vec()),
]);
assert!(screen_once_it_shows(&runtime, "before frame").contains("before frame"));
// Held back meanwhile, as the update asked.
assert!(started.elapsed() >= Duration::from_millis(150));
}
#[test]
fn an_update_ended_in_time_is_drawn_whole() {
let runtime = run_script(vec![
TerminalTransportEvent::Output(b"\x1b[?2026hone ".to_vec()),
TerminalTransportEvent::Output(b"two\x1b[?2026l".to_vec()),
]);
assert!(screen_once_it_shows(&runtime, "one two").contains("one two"));
}
#[test]
fn a_program_that_ends_mid_frame_still_shows_the_frame_and_its_exit() {
let runtime = run_script(vec![
TerminalTransportEvent::Output(b"\x1b[?2026hframe".to_vec()),
TerminalTransportEvent::Exited {
code: 137,
signal: None,
},
]);
let screen = screen_once_it_shows(&runtime, "退出码 137");
assert!(screen.contains("frame"), "{screen}");
assert!(screen.contains("[进程已退出,退出码 137]"), "{screen}");
}
#[test]
fn output_that_never_pauses_does_not_hold_an_update_open() {
let mut term = test_term(40, 4);
let mut processor: Processor = Processor::new();
processor.advance(&mut term, b"\x1b[?2026hframe");
assert!(!end_overdue_sync(&mut processor, &mut term));
assert_eq!(line_text(&term, 0), "");
thread::sleep(Duration::from_millis(160));
assert!(end_overdue_sync(&mut processor, &mut term));
assert_eq!(line_text(&term, 0), "frame");
// Done with, the next output is drawn as it comes.
processor.advance(&mut term, b" more");
assert_eq!(line_text(&term, 0), "frame more");
}
#[test]
fn waiting_stops_at_the_deadline_but_not_before_what_has_come() {
// Something comes in the end, so a wait past the deadline fails the
// test rather than hangs it.
let (late, receiver) = async_channel::unbounded();
thread::spawn(move || {
thread::sleep(Duration::from_secs(1));
let _ = late.send_blocking(0);
});
let soon = Instant::now() + Duration::from_millis(20);
assert!(matches!(receive(&receiver, Some(soon)), Received::Overdue));
assert!(Instant::now() >= soon);
let (sender, receiver) = async_channel::unbounded();
sender.send_blocking(1).unwrap();
let past = Instant::now();
assert!(matches!(receive(&receiver, Some(past)), Received::Event(1)));
sender.send_blocking(2).unwrap();
assert!(matches!(receive(&receiver, None), Received::Event(2)));
drop(sender);
assert!(matches!(receive(&receiver, None), Received::Closed));
}
}
+4 -2
View File
@@ -8,12 +8,14 @@ use gpui_kit::component::ActiveTheme as _;
use gpui_kit::*;
use unicode_width::UnicodeWidthChar as _;
use super::TerminalColors;
/// The size terminals had before it could be set: the theme's `mono_md`.
pub const DEFAULT_FONT_SIZE: f32 = 13.;
/// The rows of 20 px that terminals had at the default size.
pub const DEFAULT_LINE_HEIGHT: f32 = 1.54;
/// Font sizes a terminal accepts, in pixels.
pub const FONT_SIZE_RANGE: RangeInclusive<f32> = 8.0..=32.0;
pub const FONT_SIZE_RANGE: RangeInclusive<f32> = 10.0..=28.0;
/// Line heights a terminal accepts, as multiples of the font size.
pub const LINE_HEIGHT_RANGE: RangeInclusive<f32> = 1.0..=2.0;
@@ -122,7 +124,7 @@ impl RenderOnce for TerminalFontPreview {
let family = font.family(cx);
let row_height = font.row_height(window);
let lines = self.lines;
let color = cx.theme().foreground;
let color = TerminalColors::current(cx).foreground();
div()
.id(self.id)
+908
View File
@@ -0,0 +1,908 @@
//! 关键字高亮: rules that color what matches them in every terminal, and may
//! tell the user when new output matches while they look elsewhere.
//!
//! Only what is shown changes. A rule matches the text of the grid, what the
//! emulator made of the output with escape sequences and carriage returns
//! already applied, never the bytes: it cannot break a control sequence, and
//! the server's output and copied text stay as they were.
//!
//! The rows in view are matched again every frame, as find and links are, so
//! nothing has to follow scrolling, a resize's reflow or new output. The
//! rows a line wraps over are joined into one logical line first, so a word
//! wrapped onto the next row is still one match, before and after a resize.
//!
//! The `regex` crate matches, not Alacritty's grid search that find and
//! links use: its DFA cannot build `\b`, finds `^` only on the first line it
//! looks at, and sees the blanks that pad a row before `$`.
use std::collections::VecDeque;
use std::ops::Range;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use alacritty_terminal::event::EventListener;
use alacritty_terminal::grid::Dimensions as _;
use alacritty_terminal::index::{Column, Line, Point};
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{Term, TermMode};
use alacritty_terminal::vte::ansi::{Processor, Timeout as _};
use gpui_kit::{App, Global, Hsla, Rgba};
use regex::Regex;
use serde::{Deserialize, Serialize};
use super::search::MAX_WRAPPED_LINES;
/// The longest pattern a rule may have, in characters.
pub const PATTERN_LIMIT: usize = 500;
/// How long a line already told of stays quiet when it shows up again, the
/// way a program that redraws its lines (apt, docker pull) shows it.
const RETELL_AFTER: Duration = Duration::from_secs(300);
/// How many lines told of are remembered.
const TOLD_LIMIT: usize = 64;
/// One rule: a regular expression, the color its matches show in, and
/// whether to tell of them.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct HighlightRule {
pub enabled: bool,
/// A regular expression, case and all; `(?i)` ignores case. Empty in a
/// row just added, when it matches nothing.
pub pattern: String,
/// What the rule is for, in the user's words; a notification's title.
pub note: String,
pub color: HighlightColor,
/// Tell the user when a finished line of new output matches, while they
/// cannot see the terminal.
pub notify: bool,
}
impl Default for HighlightRule {
fn default() -> Self {
Self {
enabled: true,
pattern: String::new(),
note: String::new(),
color: HighlightColor::RED,
notify: false,
}
}
}
/// The color a rule shows its matches in, as the user picked it: data, not
/// a theme role, so it is the same in a light theme and a dark one. Written
/// `#rrggbb` in the settings file.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct HighlightColor(u32);
impl HighlightColor {
/// A new rule's color, and the examples'.
pub const RED: Self = Self(0xe5484d);
pub const AMBER: Self = Self(0xf5b51c);
pub const BLUE: Self = Self(0x2d8ce6);
/// `0xrrggbb`.
pub const fn from_rgb(rgb: u32) -> Self {
Self(rgb & 0xffffff)
}
/// `#rrggbb` (or `rrggbb`), in either case.
pub fn from_hex(text: &str) -> Option<Self> {
let digits = text.trim().strip_prefix('#').unwrap_or(text.trim());
(digits.len() == 6 && digits.chars().all(|digit| digit.is_ascii_hexdigit()))
.then(|| u32::from_str_radix(digits, 16).ok().map(Self))
.flatten()
}
/// `#rrggbb`, in lower case.
pub fn to_hex(self) -> String {
format!("#{:06x}", self.0)
}
/// The nearest color to `color` this can hold: its alpha is dropped.
pub fn from_hsla(color: Hsla) -> Self {
let Rgba { r, g, b, .. } = color.to_rgb();
let channel = |value: f32| (value.clamp(0., 1.) * 255.).round() as u32;
Self((channel(r) << 16) | (channel(g) << 8) | channel(b))
}
pub fn hsla(self) -> Hsla {
gpui_kit::rgb(self.0).into()
}
}
impl TryFrom<String> for HighlightColor {
type Error = String;
fn try_from(text: String) -> Result<Self, String> {
Self::from_hex(&text).ok_or_else(|| format!("not a #rrggbb color: {text}"))
}
}
impl From<HighlightColor> for String {
fn from(color: HighlightColor) -> Self {
color.to_hex()
}
}
/// The rules a new installation starts with, none of them notifying.
pub fn default_rules() -> Vec<HighlightRule> {
vec![
HighlightRule {
pattern: "ERROR".into(),
note: "错误".into(),
..HighlightRule::default()
},
HighlightRule {
pattern: "WARN".into(),
note: "警告".into(),
color: HighlightColor::AMBER,
..HighlightRule::default()
},
HighlightRule {
pattern: r"\b\d{1,3}(\.\d{1,3}){3}\b".into(),
note: "IPv4 地址".into(),
color: HighlightColor::BLUE,
..HighlightRule::default()
},
]
}
impl HighlightRule {
/// The pattern, ready to match: `None` while it is empty, and so
/// matches nothing; or why it cannot match, as the rule's row says it.
pub fn compile(&self) -> Result<Option<Regex>, &'static str> {
if self.pattern.is_empty() {
return Ok(None);
}
if self.pattern.chars().count() > PATTERN_LIMIT {
return Err("不能超过 500 个字符");
}
Regex::new(&self.pattern)
.map(Some)
.map_err(|error| match error {
regex::Error::CompiledTooBig(_) => "正则表达式过于复杂",
_ => "正则表达式写法有误",
})
}
/// What a notification calls the rule: its note, or its pattern.
fn title(&self) -> &str {
if self.note.trim().is_empty() {
&self.pattern
} else {
&self.note
}
}
}
/// Whether `regex` finds some text in `text`. A match of nothing, as `\b`
/// or `x*` make, colors nothing and tells of nothing.
fn finds_text(regex: &Regex, text: &str) -> bool {
regex.find_iter(text).any(|found| !found.is_empty())
}
/// The rules in effect, compiled once for every terminal: none while
/// 启用关键字高亮 is off. A rule turned off, empty or that does not compile
/// (only a hand-edited settings file has one) is left out.
#[derive(Debug, Default)]
pub struct HighlightSet {
enabled: bool,
rules: Vec<HighlightRule>,
compiled: Vec<Option<Regex>>,
}
// Shared by the UI thread and every terminal's parser thread.
const _: fn() = || {
fn shared<T: Send + Sync>() {}
shared::<HighlightSet>();
};
impl HighlightSet {
pub fn new(enabled: bool, rules: Vec<HighlightRule>) -> Self {
let compiled = rules
.iter()
.map(|rule| {
if enabled && rule.enabled {
rule.compile().ok().flatten()
} else {
None
}
})
.collect();
Self {
enabled,
rules,
compiled,
}
}
/// Whether this was made from `rules` with highlighting `enabled`.
pub fn is_made_of(&self, enabled: bool, rules: &[HighlightRule]) -> bool {
self.enabled == enabled && self.rules == rules
}
/// Whether any rule tells of the lines it matches.
pub fn notifies(&self) -> bool {
self.notifying().next().is_some()
}
/// The rules that match, by their index.
fn active(&self) -> impl DoubleEndedIterator<Item = (usize, &HighlightRule, &Regex)> {
self.rules
.iter()
.zip(&self.compiled)
.enumerate()
.filter_map(|(ix, (rule, compiled))| Some((ix, rule, compiled.as_ref()?)))
}
fn notifying(&self) -> impl Iterator<Item = (usize, &HighlightRule, &Regex)> {
self.active().filter(|(_, rule, _)| rule.notify)
}
/// The runs of `text` the rules color, in order, by byte range. Where
/// rules overlap, the earlier one keeps its color.
pub fn spans(&self, text: &str) -> Vec<(Range<usize>, HighlightColor)> {
if self.active().next().is_none() {
return Vec::new();
}
let mut colors = vec![None; text.len()];
// The last rule first, so an earlier one paints over it.
for (_, rule, regex) in self.active().rev() {
for found in regex.find_iter(text) {
colors[found.range()].fill(Some(rule.color));
}
}
let mut spans: Vec<(Range<usize>, HighlightColor)> = Vec::new();
for (offset, color) in colors.into_iter().enumerate() {
let Some(color) = color else { continue };
match spans.last_mut() {
Some((range, last)) if range.end == offset && *last == color => {
range.end = offset + 1;
}
_ => spans.push((offset..offset + 1, color)),
}
}
spans
}
/// The color of each cell in view. Nothing while a full-screen program
/// has the alternate screen: it redraws itself, and an editor's `error`
/// identifier is not news.
pub fn screen<T>(&self, term: &Term<T>) -> ScreenHighlights {
let columns = term.columns();
let rows = term.screen_lines();
let mut screen = ScreenHighlights {
columns,
colors: Vec::new(),
};
if self.active().next().is_none() || term.mode().contains(TermMode::ALT_SCREEN) {
return screen;
}
screen.colors = vec![None; columns * rows];
let top = Line(-(term.grid().display_offset() as i32));
let bottom = Line(top.0 + rows as i32 - 1);
// From where the line the top row is part of starts, so a match that
// starts above the view shows its part in it.
let mut line = line_start(term, top);
while line <= bottom {
let end = line_end(term, line);
let text = LineText::read(term, line, end);
for (span, color) in self.spans(&text.text) {
screen.mark(&text, span, color, top);
}
line = Line(end.0 + 1);
}
screen
}
}
/// The application's rules, which `settings::apply` sets. The terminal
/// module reads them here rather than depending on settings.
#[derive(Clone, Default)]
pub struct TerminalHighlights(Arc<HighlightSet>);
impl Global for TerminalHighlights {}
impl TerminalHighlights {
pub fn new(enabled: bool, rules: Vec<HighlightRule>) -> Self {
Self(Arc::new(HighlightSet::new(enabled, rules)))
}
/// The rules in effect; none before settings are applied.
pub fn current(cx: &App) -> Arc<HighlightSet> {
cx.try_global::<Self>()
.map(|highlights| highlights.0.clone())
.unwrap_or_default()
}
pub fn set(&self) -> &Arc<HighlightSet> {
&self.0
}
}
/// The rules a terminal's parser thread checks new lines against, swapped
/// by the engine when they change.
pub(super) type SharedHighlights = Arc<Mutex<Arc<HighlightSet>>>;
pub(super) fn current(shared: &SharedHighlights) -> Arc<HighlightSet> {
shared
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
/// The color of each cell in view, by its row on screen and column.
pub struct ScreenHighlights {
columns: usize,
/// Row by row; empty when nothing is highlighted.
colors: Vec<Option<HighlightColor>>,
}
impl ScreenHighlights {
pub fn color(&self, row: usize, column: usize) -> Option<HighlightColor> {
self.colors
.get(row * self.columns + column)
.copied()
.flatten()
}
/// Show the characters of `text` in the byte range `found` in `color`,
/// those in view: the view's top row is `top`.
fn mark(&mut self, text: &LineText, found: Range<usize>, color: HighlightColor, top: Line) {
let first = text.cells.partition_point(|cell| cell.offset < found.start);
let last = text.cells.partition_point(|cell| cell.offset < found.end);
for cell in &text.cells[first..last] {
let width = if cell.wide { 2 } else { 1 };
for column in cell.point.column.0..cell.point.column.0 + width {
let row = cell.point.line.0 - top.0;
if row < 0 || column >= self.columns {
continue;
}
if let Some(slot) = self.colors.get_mut(row as usize * self.columns + column) {
*slot = Some(color);
}
}
}
}
}
/// A character of a logical line: where it starts in the text, the cell it
/// is in, and whether it takes that cell's neighbour too.
struct LineCell {
offset: usize,
point: Point,
wide: bool,
}
/// One logical line of the grid, the rows it wraps over joined, as text.
struct LineText {
text: String,
/// Each character's cell, in order.
cells: Vec<LineCell>,
}
impl LineText {
/// The text of rows `first..=last`, without the blanks that pad the
/// last one. A wide character's spacer is no character; a concealed one
/// reads as a blank, so it neither matches nor shows in a notification.
fn read<T>(term: &Term<T>, first: Line, last: Line) -> Self {
let mut text = String::new();
let mut cells = Vec::new();
for line in first.0..=last.0 {
let row = &term.grid()[Line(line)];
for column in 0..row.len() {
let cell = &row[Column(column)];
if cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
cells.push(LineCell {
offset: text.len(),
point: Point::new(Line(line), Column(column)),
wide: cell.flags.contains(Flags::WIDE_CHAR),
});
if cell.flags.contains(Flags::HIDDEN) {
text.push(' ');
continue;
}
text.push(cell.c);
if let Some(zerowidth) = cell.zerowidth() {
text.extend(zerowidth);
}
}
}
let end = text.trim_end_matches(' ').len();
text.truncate(end);
while cells.last().is_some_and(|cell| cell.offset >= end) {
cells.pop();
}
Self { text, cells }
}
}
/// Whether the row `line` goes on in the next one.
fn wraps<T>(term: &Term<T>, line: Line) -> bool {
let row = &term.grid()[line];
row.len() > 0 && row[Column(row.len() - 1)].flags.contains(Flags::WRAPLINE)
}
/// The first row of the logical line that `line` is part of, at most
/// `MAX_WRAPPED_LINES` above it.
fn line_start<T>(term: &Term<T>, line: Line) -> Line {
let top = term.topmost_line();
let mut start = line;
while start > top && line.0 - start.0 < MAX_WRAPPED_LINES && wraps(term, Line(start.0 - 1)) {
start = Line(start.0 - 1);
}
start
}
/// The last row of the logical line that `line` is part of, at most
/// `MAX_WRAPPED_LINES` below it.
fn line_end<T>(term: &Term<T>, line: Line) -> Line {
let bottom = term.bottommost_line();
let mut end = line;
while end < bottom && end.0 - line.0 < MAX_WRAPPED_LINES && wraps(term, end) {
end = Line(end.0 + 1);
}
end
}
/// The text of the logical line the cursor is on.
fn cursor_line<T>(term: &Term<T>) -> String {
let line = term.grid().cursor.point.line;
LineText::read(term, line_start(term, line), line_end(term, line)).text
}
/// Feed `bytes` to the emulator. With `watching`, also take the text of
/// every line a line feed finishes, just before it does: the cursor is then
/// still on that line, whose text is final, carriage returns and colors
/// applied. Not on the alternate screen, whose programs redraw rather than
/// print, nor while a synchronized update holds its output back, as the
/// grid then still shows what came before.
pub(super) fn advance_watching<T: EventListener>(
processor: &mut Processor,
term: &mut Term<T>,
bytes: &[u8],
watching: bool,
) -> Vec<String> {
if !watching {
processor.advance(term, bytes);
return Vec::new();
}
let mut finished = Vec::new();
let mut rest = bytes;
while let Some(newline) = rest.iter().position(|byte| *byte == b'\n') {
processor.advance(term, &rest[..newline]);
if !term.mode().contains(TermMode::ALT_SCREEN)
&& !processor.sync_timeout().pending_timeout()
{
finished.push(cursor_line(term));
}
// The line feed starts the next part.
rest = &rest[newline..];
processor.advance(term, &rest[..1]);
rest = &rest[1..];
}
processor.advance(term, rest);
finished
}
/// A finished line of new output that a notifying rule matched: the rule,
/// by its note or pattern, and the line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeywordHit {
pub rule: String,
pub line: String,
}
/// Checks the lines a terminal finishes against the notifying rules, on its
/// parser thread.
pub(super) struct LineWatcher {
set: Arc<HighlightSet>,
/// The lines told of lately, by rule, and when.
told: VecDeque<(usize, String, Instant)>,
}
impl LineWatcher {
pub fn new(set: Arc<HighlightSet>) -> Self {
Self {
set,
told: VecDeque::new(),
}
}
/// Follow the rules as they change. What was told under the old ones is
/// forgotten.
pub fn sync(&mut self, set: Arc<HighlightSet>) {
if !Arc::ptr_eq(&self.set, &set) {
self.set = set;
self.told.clear();
}
}
pub fn is_watching(&self) -> bool {
self.set.notifies()
}
/// The first of `lines` a notifying rule matches, unless that rule told
/// of the same line in the last five minutes. One at most: a burst of
/// output is one notice.
pub fn check(&mut self, lines: Vec<String>, now: Instant) -> Option<KeywordHit> {
self.told
.retain(|(_, _, at)| now.saturating_duration_since(*at) < RETELL_AFTER);
for line in lines {
for (ix, rule, regex) in self.set.notifying() {
if !finds_text(regex, &line)
|| self
.told
.iter()
.any(|(told, text, _)| *told == ix && *text == line)
{
continue;
}
if self.told.len() == TOLD_LIMIT {
self.told.pop_front();
}
self.told.push_back((ix, line.clone(), now));
return Some(KeywordHit {
rule: rule.title().to_string(),
line,
});
}
}
None
}
}
#[cfg(test)]
mod tests {
use alacritty_terminal::event::VoidListener;
use alacritty_terminal::grid::Scroll;
use alacritty_terminal::term::Config;
use super::*;
use crate::terminal::TerminalSize;
const RED: HighlightColor = HighlightColor(0xff0000);
const AMBER: HighlightColor = HighlightColor(0xffaa00);
const BLUE: HighlightColor = HighlightColor(0x0000ff);
fn rule(pattern: &str, color: HighlightColor) -> HighlightRule {
HighlightRule {
pattern: pattern.into(),
color,
..HighlightRule::default()
}
}
fn notifying(rule: HighlightRule) -> HighlightRule {
HighlightRule {
notify: true,
..rule
}
}
fn set(rules: Vec<HighlightRule>) -> HighlightSet {
HighlightSet::new(true, rules)
}
/// A terminal fed by one parser, as a terminal's parser thread feeds it.
struct Screen {
term: Term<VoidListener>,
processor: Processor,
}
impl Screen {
fn new(columns: usize, rows: usize) -> Self {
Self {
term: Term::new(
Config::default(),
&TerminalSize::new(columns, rows, 8, 16),
VoidListener,
),
processor: Processor::new(),
}
}
fn feed(&mut self, text: &str) {
self.processor.advance(&mut self.term, text.as_bytes());
}
/// The lines finished while feeding `text`, as the parser thread
/// collects them.
fn feed_watching(&mut self, text: &str) -> Vec<String> {
advance_watching(&mut self.processor, &mut self.term, text.as_bytes(), true)
}
fn resize(&mut self, columns: usize, rows: usize) {
self.term.resize(TerminalSize::new(columns, rows, 8, 16));
}
/// The highlighted runs on screen in reading order, with their
/// color. A run goes on from the end of one row to the start of the
/// next, as a wrapped match does.
fn marks(&self, rules: Vec<HighlightRule>) -> Vec<(String, HighlightColor)> {
let screen = set(rules).screen(&self.term);
let offset = self.term.grid().display_offset() as i32;
let mut runs: Vec<(String, HighlightColor)> = Vec::new();
let mut open = false;
for row in 0..self.term.screen_lines() {
let line = &self.term.grid()[Line(row as i32 - offset)];
for column in 0..self.term.columns() {
let cell = &line[Column(column)];
match screen.color(row, column) {
Some(color) => {
if !open || runs.last().is_some_and(|(_, last)| *last != color) {
runs.push((String::new(), color));
}
open = true;
if !cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
runs.last_mut().unwrap().0.push(cell.c);
}
}
None => open = false,
}
}
}
runs
}
}
fn runs(marks: &[(&str, HighlightColor)]) -> Vec<(String, HighlightColor)> {
marks
.iter()
.map(|(text, color)| (text.to_string(), *color))
.collect()
}
#[test]
fn colors_are_written_as_hex() {
assert_eq!(
HighlightColor::from_hex("#D0021B"),
Some(HighlightColor(0xd0021b))
);
assert_eq!(
HighlightColor::from_hex("f59e0b"),
Some(HighlightColor(0xf59e0b))
);
assert_eq!(HighlightColor(0xd0021b).to_hex(), "#d0021b");
for text in ["", "#fff", "#12345g", "#1234567"] {
assert_eq!(HighlightColor::from_hex(text), None, "{text}");
}
let color = HighlightColor(0x3b82f6);
assert_eq!(HighlightColor::from_hsla(color.hsla()), color);
}
#[test]
fn a_pattern_is_a_regular_expression_as_written() {
let compile = |pattern: &str| rule(pattern, RED).compile();
let regex = compile("a.b").unwrap().unwrap();
assert!(regex.is_match("axb"));
assert!(!compile("error").unwrap().unwrap().is_match("ERROR"));
assert!(compile("(?i)error").unwrap().unwrap().is_match("ERROR"));
let address = default_rules().remove(2).compile().unwrap().unwrap();
assert!(address.is_match("from 10.0.0.12 port 22"));
assert!(!address.is_match("1234.5.6.7"));
// A row just added matches nothing, and says nothing of it.
assert!(matches!(compile(""), Ok(None)));
assert_eq!(compile("(ERROR").err(), Some("正则表达式写法有误"));
assert_eq!(
compile(r"(\w{1000}){1000}").err(),
Some("正则表达式过于复杂")
);
let long = "x".repeat(PATTERN_LIMIT + 1);
assert_eq!(compile(&long).err(), Some("不能超过 500 个字符"));
}
#[test]
fn rules_turned_off_or_unfinished_match_nothing() {
let rules = vec![
HighlightRule {
enabled: false,
..rule("ERROR", RED)
},
rule("", AMBER),
rule("(", AMBER),
rule("WARN", BLUE),
];
assert_eq!(set(rules.clone()).spans("ERROR WARN"), [(6..10, BLUE)]);
// 启用关键字高亮 off, nothing at all.
assert!(
HighlightSet::new(false, rules)
.spans("ERROR WARN")
.is_empty()
);
}
#[test]
fn spans_keep_the_earlier_rule_where_rules_overlap() {
let rules = set(vec![rule("Memory", BLUE), rule(r"\w+Error", RED)]);
assert_eq!(
rules.spans("OutOfMemoryError!"),
[(0..5, RED), (5..11, BLUE), (11..16, RED)]
);
// Byte ranges, whole characters.
assert_eq!(
set(vec![rule("错误", RED)]).spans("连接错误"),
[(6..12, RED)]
);
}
#[test]
fn matches_are_marked_where_they_show() {
let mut screen = Screen::new(30, 4);
screen.feed("ok ERROR x WARN\r\n");
assert_eq!(
screen.marks(vec![rule("ERROR", RED), rule("WARN", AMBER)]),
runs(&[("ERROR", RED), ("WARN", AMBER)])
);
// A word split between two colors is one word on screen.
screen.feed("E\x1b[31mRR\x1b[0mOR\r\n");
assert_eq!(
screen.marks(vec![rule("ERROR", RED)]),
runs(&[("ERROR", RED), ("ERROR", RED)])
);
}
#[test]
fn line_anchors_and_word_boundaries_hold_on_every_line() {
let mut screen = Screen::new(30, 5);
screen.feed("a ERROR\r\nERROR b\r\n2024-10 ERROR\r\n");
assert_eq!(
screen.marks(vec![rule("^ERROR", RED)]),
runs(&[("ERROR", RED)])
);
// The blanks padding a row are not text before `$`.
assert_eq!(
screen.marks(vec![rule("ERROR$", RED)]),
runs(&[("ERROR", RED), ("ERROR", RED)])
);
assert_eq!(
screen.marks(vec![rule(r"^\d+", BLUE)]),
runs(&[("2024", BLUE)])
);
assert!(screen.marks(vec![rule(r"\bRR", RED)]).is_empty());
}
#[test]
fn a_wrapped_match_is_marked_on_both_rows_before_and_after_a_resize() {
// `1234567ERR` and `OR tail`.
let mut screen = Screen::new(10, 6);
screen.feed("1234567ERROR tail\r\n");
let rules = || vec![rule("ERROR", RED)];
assert_eq!(screen.marks(rules()), runs(&[("ERROR", RED)]));
// Narrowed, Alacritty reflows them to `1234567E`, pushed into the
// scrollback, and `RROR tai` on the screen's top row.
screen.resize(8, 6);
assert_eq!(screen.marks(rules()), runs(&[("RROR", RED)]));
screen.term.scroll_display(Scroll::Delta(1));
assert_eq!(screen.marks(rules()), runs(&[("ERROR", RED)]));
// Widened, one row again.
screen.term.scroll_display(Scroll::Bottom);
screen.resize(40, 6);
assert_eq!(screen.marks(rules()), runs(&[("ERROR", RED)]));
}
#[test]
fn a_match_starting_above_the_view_shows_its_part_in_it() {
// `xxxxE` and `RROR` in the scrollback, `next` and `last` on screen.
let mut screen = Screen::new(5, 2);
screen.feed("xxxxERROR\r\nnext\r\nlast");
assert!(screen.marks(vec![rule("ERROR", RED)]).is_empty());
// Scrolled back a row, the view starts at `RROR`.
screen.term.scroll_display(Scroll::Delta(1));
assert_eq!(
screen.marks(vec![rule("ERROR", RED)]),
runs(&[("RROR", RED)])
);
}
#[test]
fn wide_characters_take_both_cells() {
let mut screen = Screen::new(20, 2);
screen.feed("连接错误 x");
assert_eq!(
screen.marks(vec![rule("错误", RED)]),
runs(&[("错误", RED)])
);
}
#[test]
fn nothing_is_marked_on_the_alternate_screen() {
let mut screen = Screen::new(20, 3);
screen.feed("\x1b[?1049hERROR");
assert!(screen.marks(vec![rule("ERROR", RED)]).is_empty());
screen.feed("\x1b[?1049l");
screen.feed("ERROR");
assert_eq!(
screen.marks(vec![rule("ERROR", RED)]),
runs(&[("ERROR", RED)])
);
}
#[test]
fn a_line_is_taken_when_its_line_feed_comes() {
let mut screen = Screen::new(40, 4);
assert!(screen.feed_watching("prompt$ ERR").is_empty());
assert_eq!(screen.feed_watching("OR boom\r\n"), ["prompt$ ERROR boom"]);
// Overwritten by a carriage return, the first text is gone.
assert_eq!(screen.feed_watching("ERROR\rok \r\n"), ["ok"]);
assert_eq!(screen.feed_watching("a\nb\n"), ["a", " b"]);
}
#[test]
fn a_wrapped_line_is_taken_whole() {
let mut screen = Screen::new(10, 4);
assert_eq!(
screen.feed_watching("0123456789ERROR\r\n"),
["0123456789ERROR"]
);
}
#[test]
fn full_screen_programs_and_synchronized_updates_are_not_watched() {
let mut screen = Screen::new(20, 4);
assert!(screen.feed_watching("\x1b[?1049hERROR\r\n").is_empty());
assert_eq!(
screen.feed_watching("\x1b[?1049lback\r\n").len(),
1,
"the main screen is watched again"
);
assert!(screen.feed_watching("\x1b[?2026hERROR\r\n").is_empty());
}
#[test]
fn a_notifying_rule_tells_of_a_line_once_in_a_while() {
let rules = Arc::new(set(vec![
rule("WARN", AMBER),
notifying(HighlightRule {
note: "错误".into(),
..rule("ERROR", RED)
}),
]));
let mut watcher = LineWatcher::new(rules);
assert!(watcher.is_watching());
let start = Instant::now();
let lines = |lines: &[&str]| lines.iter().map(|line| line.to_string()).collect();
assert_eq!(watcher.check(lines(&["WARN low disk"]), start), None);
// Named by its note.
assert_eq!(
watcher.check(lines(&["ok", "1 ERROR a", "2 ERROR b"]), start),
Some(KeywordHit {
rule: "错误".into(),
line: "1 ERROR a".into()
})
);
// Redrawn, the same line is not news; another one is.
assert_eq!(watcher.check(lines(&["1 ERROR a"]), start), None);
assert!(watcher.check(lines(&["2 ERROR b"]), start).is_some());
let later = start + RETELL_AFTER;
assert!(watcher.check(lines(&["1 ERROR a"]), later).is_some());
// New rules start afresh; without a note, the pattern names it.
watcher.sync(Arc::new(set(vec![notifying(rule("ERROR", RED))])));
assert_eq!(
watcher
.check(lines(&["1 ERROR a"]), later)
.map(|hit| hit.rule),
Some("ERROR".into())
);
watcher.sync(Arc::new(set(vec![rule("ERROR", RED)])));
assert!(!watcher.is_watching());
}
#[test]
fn a_match_of_nothing_tells_of_nothing() {
let mut watcher = LineWatcher::new(Arc::new(set(vec![notifying(rule(r"\b", RED))])));
assert_eq!(
watcher.check(vec!["any words".into()], Instant::now()),
None
);
}
}
+6
View File
@@ -3,6 +3,7 @@
mod engine;
mod exec;
mod font;
mod highlight;
mod links;
mod local_pty;
mod local_terminal_panel;
@@ -12,6 +13,7 @@ mod notices;
mod search;
mod terminal_panel;
mod terminal_view;
mod theme;
mod transport;
pub use engine::{TerminalCell, TerminalEngine, TerminalEvent, TerminalSnapshot};
@@ -20,6 +22,9 @@ pub use font::{
DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFont,
TerminalFontPreview, is_font_installed, monospace_font_families,
};
pub use highlight::{
HighlightColor, HighlightRule, HighlightSet, TerminalHighlights, default_rules,
};
pub use local_pty::LocalPtyTransportFactory;
pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent};
pub use model::{
@@ -32,6 +37,7 @@ pub(crate) use terminal_view::terminal_key_bindings;
pub use terminal_view::{
TERMINAL_FIND_KEY_CONTEXT, TERMINAL_KEY_CONTEXT, TerminalMenuItems, TerminalView,
};
pub use theme::{TerminalColors, TerminalTheme};
pub(crate) use transport::send_event;
pub use transport::{
ExecRequest, ExecResult, FixedRemoteTerminalTransportProvider, RemoteTerminalTransportProvider,
+21 -1
View File
@@ -1,6 +1,6 @@
//! What a terminal has to tell the user while they look elsewhere: a
//! notification a program asks for (OSC 9 as iTerm2 has it, OSC 777 as urxvt
//! and VTE have it), or the bell.
//! and VTE have it), the bell, or a line a 关键字高亮 rule tells of.
//!
//! Alacritty drops both OSC sequences, so the parser thread runs the output
//! through `NoticeScanner` too.
@@ -10,6 +10,8 @@ use alacritty_terminal::vte::{Parser, Perform};
/// The longest title and body a notification shows, in characters.
const TITLE_LIMIT: usize = 80;
const BODY_LIMIT: usize = 240;
/// The longest rule name a notification's title has, in characters.
const RULE_LIMIT: usize = 40;
/// Something a terminal asks the user to look at.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -19,6 +21,9 @@ pub enum TerminalNotice {
/// The bell rang. The line the cursor is on, which a program waiting
/// for an answer usually has its question on.
Bell { line: String },
/// A line of new output matched a 关键字高亮 rule that notifies: the
/// rule, by its note or pattern, and the line.
Keyword { rule: String, line: String },
}
impl TerminalNotice {
@@ -28,6 +33,14 @@ impl TerminalNotice {
line: clean_text(line, BODY_LIMIT),
}
}
/// The rule called `rule` matched `line`.
pub fn keyword(rule: &str, line: &str) -> Self {
TerminalNotice::Keyword {
rule: clean_text(rule, RULE_LIMIT),
line: clean_text(line, BODY_LIMIT),
}
}
}
/// A notification a program asked for, as it asked.
@@ -191,6 +204,13 @@ mod tests {
assert_eq!(clean_text(" a\tb\n\x1bc ", 10), "a b c");
assert_eq!(clean_text("一二三四五", 4), "一二三…");
assert_eq!(clean_text("一二三四", 4), "一二三四");
assert_eq!(
TerminalNotice::keyword(" ERROR ", "12:00\tERROR\x1b boom"),
TerminalNotice::Keyword {
rule: "ERROR".into(),
line: "12:00 ERROR boom".into()
}
);
assert_eq!(
notice(Some(" "), "x").cleaned(),
TerminalNotice::Program {
+1 -1
View File
@@ -14,7 +14,7 @@ use gpui_kit::SharedString;
/// How far a highlighted match may reach above the first visible row along a
/// wrapped line. Longer wrapped lines are cut there, as in Alacritty.
const MAX_WRAPPED_LINES: i32 = 100;
pub(super) const MAX_WRAPPED_LINES: i32 = 100;
/// Which way to step from the focused match: `Up` towards older output,
/// `Down` towards newer output.
+51 -60
View File
@@ -30,8 +30,8 @@ use crate::connection::{ConnectionPromptReply, Latency};
use super::mouse::{self, MouseReport, ReportButton, ReportKind};
use super::search::SearchMark;
use super::{
SearchDirection, SharedTerminalTransportFactory, TerminalEngine, TerminalEvent, TerminalFont,
TerminalLifecycle, TerminalSize, TerminalSnapshot, TerminalStatus,
SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine, TerminalEvent,
TerminalFont, TerminalLifecycle, TerminalSize, TerminalSnapshot, TerminalStatus, TerminalTheme,
};
pub const TERMINAL_KEY_CONTEXT: &str = "Terminal";
@@ -259,7 +259,7 @@ impl TerminalView {
.await;
if this
.update(cx, |this, cx| {
if this.focused && this.snapshot(cx).cursor_blinking {
if this.focused && this.engine.read(cx).cursor_blinking() {
this.cursor_visible = !this.cursor_visible;
cx.notify();
} else if this.focused && !this.cursor_visible {
@@ -541,10 +541,6 @@ impl TerminalView {
.update(cx, |engine, cx| engine.set_search_query(&query, cx));
}
fn snapshot(&self, cx: &App) -> TerminalSnapshot {
self.engine.read(cx).snapshot()
}
fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
let mode = self.engine.read(cx).mode();
if let Some(bytes) = encode_key(&event.keystroke, mode) {
@@ -964,6 +960,9 @@ impl Render for TerminalView {
.relative()
.size_full()
.p_2()
// The margin and the part of a cell the grid does not fill are
// the terminal's too.
.bg(TerminalColors::current(cx).background())
.child(terminal)
.when_some(self.find.as_ref(), |container, find| {
container.child(self.render_find_bar(find, cx))
@@ -1209,7 +1208,7 @@ impl Element for TerminalElement {
&self.snapshot.colors,
);
let cell_background = if cell.selected {
cx.theme().selection
palette.selection
} else {
match cell.search {
SearchMark::Focused => cx.theme().warning,
@@ -1235,16 +1234,23 @@ impl Element for TerminalElement {
let font = terminal_font(text_style.font(), cell.flags);
let len = character.len();
text.push_str(character);
// Links always show as links, blue whatever color the
// program gave them. Not the theme's `link`: that is the
// color of text.
let link = cell.link.map(|link| {
if hovered_link == Some(link) {
cx.theme().blue_light
// A rule's color, dimmed as the program dimmed the text.
let highlight = cell.highlight.map(|highlight| {
let color = highlight.hsla();
if cell.flags.contains(Flags::DIM) {
color.opacity(0.66)
} else {
cx.theme().blue
color
}
});
// Links always show as links, blue whatever color the
// program gave them.
let link = cell
.link
.map(|link| palette.link(hovered_link == Some(link)));
// A rule colors a link's text too; the underline still says
// it is one.
let text_color = highlight.or(link).unwrap_or(foreground);
runs.push(TextRun {
len,
font,
@@ -1253,7 +1259,7 @@ impl Element for TerminalElement {
} else if cell.search == SearchMark::Focused {
cx.theme().warning_foreground
} else {
link.unwrap_or(foreground)
text_color
},
background_color: None,
underline: match link {
@@ -1267,7 +1273,7 @@ impl Element for TerminalElement {
.intersects(Flags::ALL_UNDERLINES)
.then_some(UnderlineStyle {
thickness: px(1.),
color: Some(foreground),
color: Some(text_color),
wavy: cell.flags.contains(Flags::UNDERCURL),
})
}
@@ -1275,7 +1281,7 @@ impl Element for TerminalElement {
strikethrough: cell.flags.contains(Flags::STRIKEOUT).then_some(
StrikethroughStyle {
thickness: px(1.),
color: Some(foreground),
color: Some(text_color),
},
),
});
@@ -1325,7 +1331,7 @@ impl Element for TerminalElement {
text_style.font(),
font_size,
&palette,
cx.theme().selection,
palette.selection,
window,
);
let cursor = self
@@ -1857,21 +1863,33 @@ fn grid_point(
)
}
/// The colors of the terminal theme in effect, for one frame.
struct TerminalPalette {
theme: &'static TerminalTheme,
foreground: Hsla,
background: Hsla,
cursor: Hsla,
selection: Hsla,
}
impl TerminalPalette {
fn new(cx: &App) -> Self {
let theme = TerminalColors::current(cx);
Self {
foreground: cx.theme().foreground,
background: cx.theme().background,
cursor: cx.theme().primary,
theme,
foreground: theme.foreground(),
background: theme.background(),
cursor: theme.cursor(),
selection: theme.selection(),
}
}
/// A link's color, and a hovered link's: the theme's blue and bright
/// blue, so they read on its background.
fn link(&self, hovered: bool) -> Hsla {
self.theme.indexed(if hovered { 12 } else { 4 })
}
fn cell_colors(
&self,
foreground: Color,
@@ -1899,21 +1917,22 @@ impl TerminalPalette {
if let Some(color) = overrides.get(index).copied().flatten() {
return rgb_to_hsla(color);
}
let dim = |index: u8| self.theme.indexed(index).opacity(0.66);
match color {
Color::Named(NamedColor::Foreground | NamedColor::BrightForeground) => self.foreground,
Color::Named(NamedColor::DimForeground) => self.foreground.opacity(0.66),
Color::Named(NamedColor::Background) => self.background,
Color::Named(NamedColor::Cursor) => self.cursor,
Color::Named(NamedColor::DimBlack) => indexed_color(0).opacity(0.66),
Color::Named(NamedColor::DimRed) => indexed_color(1).opacity(0.66),
Color::Named(NamedColor::DimGreen) => indexed_color(2).opacity(0.66),
Color::Named(NamedColor::DimYellow) => indexed_color(3).opacity(0.66),
Color::Named(NamedColor::DimBlue) => indexed_color(4).opacity(0.66),
Color::Named(NamedColor::DimMagenta) => indexed_color(5).opacity(0.66),
Color::Named(NamedColor::DimCyan) => indexed_color(6).opacity(0.66),
Color::Named(NamedColor::DimWhite) => indexed_color(7).opacity(0.66),
Color::Named(named) => indexed_color(named as u8),
Color::Indexed(index) => indexed_color(index),
Color::Named(NamedColor::DimBlack) => dim(0),
Color::Named(NamedColor::DimRed) => dim(1),
Color::Named(NamedColor::DimGreen) => dim(2),
Color::Named(NamedColor::DimYellow) => dim(3),
Color::Named(NamedColor::DimBlue) => dim(4),
Color::Named(NamedColor::DimMagenta) => dim(5),
Color::Named(NamedColor::DimCyan) => dim(6),
Color::Named(NamedColor::DimWhite) => dim(7),
Color::Named(named) => self.theme.indexed(named as u8),
Color::Indexed(index) => self.theme.indexed(index),
Color::Spec(_) => unreachable!(),
}
}
@@ -1923,34 +1942,6 @@ fn rgb_to_hsla(color: Rgb) -> Hsla {
rgb(((color.r as u32) << 16) | ((color.g as u32) << 8) | color.b as u32).into()
}
fn indexed_color(index: u8) -> Hsla {
const ANSI: [u32; 16] = [
0x000000, 0xcd0000, 0x00cd00, 0xcdcd00, 0x0000ee, 0xcd00cd, 0x00cdcd, 0xe5e5e5, 0x7f7f7f,
0xff0000, 0x00ff00, 0xffff00, 0x5c5cff, 0xff00ff, 0x00ffff, 0xffffff,
];
let value = match index {
0..=15 => ANSI[index as usize],
16..=231 => {
let index = index - 16;
let component = |value: u8| {
if value == 0 {
0
} else {
55 + value as u32 * 40
}
};
(component(index / 36) << 16)
| (component((index % 36) / 6) << 8)
| component(index % 6)
}
232..=255 => {
let value = 8 + (index as u32 - 232) * 10;
(value << 16) | (value << 8) | value
}
};
rgb(value).into()
}
pub(crate) fn encode_key(keystroke: &Keystroke, mode: TermMode) -> Option<Vec<u8>> {
if keystroke.modifiers.platform {
return None;
+616
View File
@@ -0,0 +1,616 @@
//! 终端主题: the colors a terminal draws with — its text, background,
//! cursor, selection and the 16 ANSI colors. The settings choose one for the
//! light appearance and one for the dark and set the one in effect as a
//! global; every terminal view reads it, so a change reaches open tabs at
//! once. Also what a program is told when it asks for a color (OSC 4, 10,
//! 11, 12).
use alacritty_terminal::vte::ansi::{NamedColor, Rgb};
use gpui_kit::component::{ActiveTheme as _, ThemeMode};
use gpui_kit::*;
/// A built-in theme. Its colors are `0xrrggbb`: data, like a picture's, not
/// theme roles, so they are the same whatever the app's appearance.
#[derive(Debug, PartialEq, Eq)]
pub struct TerminalTheme {
/// What the settings file stores. Never changes once released.
key: &'static str,
name: &'static str,
/// The appearance it is made for: the column it is listed in.
mode: ThemeMode,
foreground: u32,
background: u32,
cursor: u32,
/// Opaque: drawn under selected text in place of its background.
selection: u32,
/// Black, red, green, yellow, blue, magenta, cyan, white, then their
/// bright versions.
ansi: [u32; 16],
}
impl TerminalTheme {
/// The built-in themes: the light ones, then the dark ones.
pub fn all() -> &'static [TerminalTheme] {
&THEMES
}
/// The themes made for `mode`, in the order they are listed.
pub fn for_mode(mode: ThemeMode) -> impl Iterator<Item = &'static TerminalTheme> {
THEMES.iter().filter(move |theme| theme.mode == mode)
}
pub fn find(key: &str) -> Option<&'static TerminalTheme> {
THEMES.iter().find(|theme| theme.key == key)
}
/// What terminals use for `mode` until another is chosen: the look they
/// had before themes could be chosen, the app's own background and text.
pub fn default_for(mode: ThemeMode) -> &'static TerminalTheme {
Self::for_mode(mode)
.next()
.expect("each appearance has its themes")
}
pub fn key(&self) -> &'static str {
self.key
}
pub fn name(&self) -> &'static str {
self.name
}
pub fn mode(&self) -> ThemeMode {
self.mode
}
pub fn foreground(&self) -> Hsla {
rgb(self.foreground).into()
}
pub fn background(&self) -> Hsla {
rgb(self.background).into()
}
pub fn cursor(&self) -> Hsla {
rgb(self.cursor).into()
}
pub fn selection(&self) -> Hsla {
rgb(self.selection).into()
}
/// One of the 256 colors a program picks by number: the 16 ANSI colors
/// are the theme's, the 6×6×6 cube and the gray ramp above them xterm's.
pub fn indexed(&self, index: u8) -> Hsla {
rgb(self.indexed_rgb(index)).into()
}
/// The answer to a program asking for color `index` it has not set
/// itself, numbered as alacritty numbers its colors: 0–255 by number,
/// then the named ones (foreground, background, cursor …).
pub fn query_color(&self, index: usize) -> Rgb {
let value = match index {
0..=255 => self.indexed_rgb(index as u8),
_ if index == NamedColor::Background as usize => self.background,
_ if index == NamedColor::Cursor as usize => self.cursor,
// The foreground, and the dim and bright versions of it and of
// the ANSI colors, which programs cannot ask for by OSC.
_ => self.foreground,
};
Rgb {
r: (value >> 16) as u8,
g: (value >> 8) as u8,
b: value as u8,
}
}
fn indexed_rgb(&self, index: u8) -> u32 {
match index {
0..=15 => self.ansi[index as usize],
16..=231 => {
let index = index - 16;
let component = |value: u8| {
if value == 0 {
0
} else {
55 + value as u32 * 40
}
};
(component(index / 36) << 16)
| (component((index % 36) / 6) << 8)
| component(index % 6)
}
232..=255 => {
let value = 8 + (index as u32 - 232) * 10;
(value << 16) | (value << 8) | value
}
}
}
}
/// The theme terminals draw with now, set by the settings for the app's
/// current appearance.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TerminalColors(&'static TerminalTheme);
impl Global for TerminalColors {}
impl TerminalColors {
pub fn new(theme: &'static TerminalTheme) -> Self {
Self(theme)
}
/// The theme in effect; before the settings set one, the default for
/// the app's appearance.
pub fn current(cx: &App) -> &'static TerminalTheme {
cx.try_global::<Self>()
.map(|colors| colors.0)
.unwrap_or_else(|| TerminalTheme::default_for(cx.theme().mode))
}
pub fn theme(&self) -> &'static TerminalTheme {
self.0
}
}
/// The app's light theme around a terminal: white, `neutral-950` text, the
/// `primary` cursor and the `selection` over white, with ANSI colors from
/// the same Tailwind palette, dark enough to read on white.
const SHELLRS_LIGHT: TerminalTheme = TerminalTheme {
key: "shellrs-light",
name: "ShellRS Light",
mode: ThemeMode::Light,
foreground: 0x0a0a0a,
background: 0xffffff,
cursor: 0x171717,
selection: 0xcce2fe,
ansi: [
0x171717, 0xdc2626, 0x15803d, 0xa16207, 0x2563eb, 0xc026d3, 0x0e7490, 0xa3a3a3, 0x737373,
0xef4444, 0x16a34a, 0xd97706, 0x3b82f6, 0xd946ef, 0x0891b2, 0xd4d4d4,
],
};
/// The app's dark theme around a terminal: `neutral-950`, `neutral-50`
/// text and cursor, the `selection` over the background, with ANSI colors
/// light enough to read on it.
const SHELLRS_DARK: TerminalTheme = TerminalTheme {
key: "shellrs-dark",
name: "ShellRS Dark",
mode: ThemeMode::Dark,
foreground: 0xfafafa,
background: 0x0a0a0a,
cursor: 0xfafafa,
selection: 0x101e48,
ansi: [
0x262626, 0xf87171, 0x4ade80, 0xfacc15, 0x60a5fa, 0xe879f9, 0x22d3ee, 0xd4d4d4, 0x737373,
0xfca5a5, 0x86efac, 0xfde047, 0x93c5fd, 0xf0abfc, 0x67e8f9, 0xfafafa,
],
};
/// Each column's themes in the order it lists them, its default first.
/// The light and dark versions of one theme share a row; a theme without the
/// other version comes after them.
static THEMES: [TerminalTheme; 20] = [
SHELLRS_LIGHT,
SOLARIZED_LIGHT,
TOKYO_DAY,
CATPPUCCIN_LATTE,
FLEXOKI_LIGHT,
ONE_HALF_LIGHT,
GRUVBOX_LIGHT,
KANAGAWA_LOTUS,
ROSE_PINE_DAWN,
EVERFOREST_LIGHT,
SHELLRS_DARK,
SOLARIZED_DARK,
TOKYO_NIGHT,
CATPPUCCIN_MOCHA,
FLEXOKI_DARK,
ONE_HALF_DARK,
GRUVBOX_DARK,
KANAGAWA_WAVE,
DRACULA,
NORD,
];
// The rest, each as its own project's terminal port has it.
/// https://raw.githubusercontent.com/altercation/solarized/master/README.md (MIT).
/// The palette table of the README, laid out as the official iTerm2 files
/// lay it out.
const SOLARIZED_LIGHT: TerminalTheme = TerminalTheme {
key: "solarized-light",
name: "Solarized Light",
mode: ThemeMode::Light,
foreground: 0x657b83,
background: 0xfdf6e3,
cursor: 0x657b83,
selection: 0xeee8d5,
ansi: [
0x073642, 0xdc322f, 0x859900, 0xb58900, 0x268bd2, 0xd33682, 0x2aa198, 0xeee8d5, 0x002b36,
0xcb4b16, 0x586e75, 0x657b83, 0x839496, 0x6c71c4, 0x93a1a1, 0xfdf6e3,
],
};
/// https://raw.githubusercontent.com/folke/tokyonight.nvim/main/extras/ghostty/tokyonight_day (Apache-2.0).
/// The project calls it Tokyo Night Day.
const TOKYO_DAY: TerminalTheme = TerminalTheme {
key: "tokyo-day",
name: "Tokyo Day",
mode: ThemeMode::Light,
foreground: 0x3760bf,
background: 0xe1e2e7,
cursor: 0x3760bf,
selection: 0xb7c1e3,
ansi: [
0xb4b5b9, 0xf52a65, 0x587539, 0x8c6c3e, 0x2e7de9, 0x9854f1, 0x007197, 0x6172b0, 0xa1a6c5,
0xff4774, 0x5c8524, 0xa27629, 0x358aff, 0xa463ff, 0x007ea8, 0x3760bf,
],
};
/// https://raw.githubusercontent.com/catppuccin/ghostty/main/themes/catppuccin-latte.conf (MIT).
const CATPPUCCIN_LATTE: TerminalTheme = TerminalTheme {
key: "catppuccin-latte",
name: "Catppuccin Latte",
mode: ThemeMode::Light,
foreground: 0x4c4f69,
background: 0xeff1f5,
cursor: 0xdc8a78,
selection: 0xd8dae1,
ansi: [
0x5c5f77, 0xd20f39, 0x40a02b, 0xdf8e1d, 0x1e66f5, 0xea76cb, 0x179299, 0xacb0be, 0x6c6f85,
0xd20f39, 0x40a02b, 0xdf8e1d, 0x1e66f5, 0xea76cb, 0x179299, 0xbcc0cc,
],
};
/// https://raw.githubusercontent.com/kepano/flexoki/main/iterm2/flexoki_light.itermcolors (MIT).
/// The selection is the kitty port's: this file selects by inverting.
const FLEXOKI_LIGHT: TerminalTheme = TerminalTheme {
key: "flexoki-light",
name: "Flexoki Light",
mode: ThemeMode::Light,
foreground: 0x100f0f,
background: 0xfffcf0,
cursor: 0x100f0f,
selection: 0xcecdc3,
ansi: [
0x100f0f, 0xaf3029, 0x66800b, 0xad8301, 0x205ea6, 0xa02f6f, 0x24837b, 0x6f6e69, 0xb7b5ac,
0xd14d41, 0x879a39, 0xd0a215, 0x4385be, 0xce5d97, 0x3aa99f, 0xcecdc3,
],
};
/// https://raw.githubusercontent.com/sonph/onehalf/master/iterm/OneHalfLight.itermcolors (MIT).
const ONE_HALF_LIGHT: TerminalTheme = TerminalTheme {
key: "one-half-light",
name: "One Half Light",
mode: ThemeMode::Light,
foreground: 0x383a42,
background: 0xfafafa,
cursor: 0xbfceff,
selection: 0xbfceff,
ansi: [
0x383a42, 0xe45649, 0x50a14f, 0xc18401, 0x0184bc, 0xa626a4, 0x0997b3, 0xfafafa, 0x4f525e,
0xe06c75, 0x98c379, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xffffff,
],
};
/// https://raw.githubusercontent.com/morhetz/gruvbox-contrib/master/termite/gruvbox-light (MIT).
/// No cursor there: it is the text. The selection is `bg3`, what gruvbox.vim
/// selects with when not inverting.
const GRUVBOX_LIGHT: TerminalTheme = TerminalTheme {
key: "gruvbox-light",
name: "Gruvbox Light",
mode: ThemeMode::Light,
foreground: 0x3c3836,
background: 0xfbf1c7,
cursor: 0x3c3836,
selection: 0xbdae93,
ansi: [
0xfbf1c7, 0xcc241d, 0x98971a, 0xd79921, 0x458588, 0xb16286, 0x689d6a, 0x7c6f64, 0x928374,
0x9d0006, 0x79740e, 0xb57614, 0x076678, 0x8f3f71, 0x427b58, 0x3c3836,
],
};
/// https://raw.githubusercontent.com/rebelot/kanagawa.nvim/master/extras/ghostty/kanagawa-lotus (MIT).
const KANAGAWA_LOTUS: TerminalTheme = TerminalTheme {
key: "kanagawa-lotus",
name: "Kanagawa Lotus",
mode: ThemeMode::Light,
foreground: 0x545464,
background: 0xf2ecbc,
cursor: 0x43436c,
selection: 0xc9cbd1,
ansi: [
0x1f1f28, 0xc84053, 0x6f894e, 0x77713f, 0x4d699b, 0xb35b79, 0x597b75, 0x545464, 0x8a8980,
0xd7474b, 0x6e915f, 0x836f4a, 0x6693bf, 0x624c83, 0x5e857a, 0x43436c,
],
};
/// https://raw.githubusercontent.com/rose-pine/ghostty/main/dist/rose-pine-dawn (MIT).
const ROSE_PINE_DAWN: TerminalTheme = TerminalTheme {
key: "rose-pine-dawn",
name: "Rosé Pine Dawn",
mode: ThemeMode::Light,
foreground: 0x575279,
background: 0xfaf4ed,
cursor: 0x575279,
selection: 0xdfdad9,
ansi: [
0xf2e9e1, 0xb4637a, 0x286983, 0xea9d34, 0x56949f, 0x907aa9, 0xd7827e, 0x575279, 0x9893a5,
0xb4637a, 0x286983, 0xea9d34, 0x56949f, 0x907aa9, 0xd7827e, 0x575279,
],
};
/// https://raw.githubusercontent.com/sainnhe/everforest/master/colors/everforest.vim
/// and autoload/everforest.vim (MIT): the medium light palette, the 16
/// colors as the color scheme gives them to Vim's terminal, `bg_visual` for
/// the selection. It sets no cursor color by default: the cursor is the
/// text.
const EVERFOREST_LIGHT: TerminalTheme = TerminalTheme {
key: "everforest-light",
name: "Everforest Light",
mode: ThemeMode::Light,
foreground: 0x5c6a72,
background: 0xfdf6e3,
cursor: 0x5c6a72,
selection: 0xeaedc8,
ansi: [
0x5c6a72, 0xf85552, 0x8da101, 0xdfa000, 0x3a94c5, 0xdf69ba, 0x35a77c, 0xe6e2cc, 0x5c6a72,
0xf85552, 0x8da101, 0xdfa000, 0x3a94c5, 0xdf69ba, 0x35a77c, 0xe6e2cc,
],
};
/// https://raw.githubusercontent.com/altercation/solarized/master/README.md (MIT).
/// The palette table of the README, laid out as the official iTerm2 files
/// lay it out.
const SOLARIZED_DARK: TerminalTheme = TerminalTheme {
key: "solarized-dark",
name: "Solarized Dark",
mode: ThemeMode::Dark,
foreground: 0x839496,
background: 0x002b36,
cursor: 0x839496,
selection: 0x073642,
ansi: [
0x073642, 0xdc322f, 0x859900, 0xb58900, 0x268bd2, 0xd33682, 0x2aa198, 0xeee8d5, 0x002b36,
0xcb4b16, 0x586e75, 0x657b83, 0x839496, 0x6c71c4, 0x93a1a1, 0xfdf6e3,
],
};
/// https://raw.githubusercontent.com/folke/tokyonight.nvim/main/extras/ghostty/tokyonight_night (Apache-2.0).
const TOKYO_NIGHT: TerminalTheme = TerminalTheme {
key: "tokyo-night",
name: "Tokyo Night",
mode: ThemeMode::Dark,
foreground: 0xc0caf5,
background: 0x1a1b26,
cursor: 0xc0caf5,
selection: 0x283457,
ansi: [
0x15161e, 0xf7768e, 0x9ece6a, 0xe0af68, 0x7aa2f7, 0xbb9af7, 0x7dcfff, 0xa9b1d6, 0x414868,
0xff899d, 0x9fe044, 0xfaba4a, 0x8db0ff, 0xc7a9ff, 0xa4daff, 0xc0caf5,
],
};
/// https://raw.githubusercontent.com/catppuccin/ghostty/main/themes/catppuccin-mocha.conf (MIT).
const CATPPUCCIN_MOCHA: TerminalTheme = TerminalTheme {
key: "catppuccin-mocha",
name: "Catppuccin Mocha",
mode: ThemeMode::Dark,
foreground: 0xcdd6f4,
background: 0x1e1e2e,
cursor: 0xf5e0dc,
selection: 0x353749,
ansi: [
0x45475a, 0xf38ba8, 0xa6e3a1, 0xf9e2af, 0x89b4fa, 0xf5c2e7, 0x94e2d5, 0xa6adc8, 0x585b70,
0xf38ba8, 0xa6e3a1, 0xf9e2af, 0x89b4fa, 0xf5c2e7, 0x94e2d5, 0xbac2de,
],
};
/// https://raw.githubusercontent.com/kepano/flexoki/main/iterm2/flexoki_dark.itermcolors (MIT).
/// The selection is the kitty port's: this file selects by inverting.
const FLEXOKI_DARK: TerminalTheme = TerminalTheme {
key: "flexoki-dark",
name: "Flexoki Dark",
mode: ThemeMode::Dark,
foreground: 0xcecdc3,
background: 0x100f0f,
cursor: 0xcecdc3,
selection: 0x403e3c,
ansi: [
0x100f0f, 0xd14d41, 0x879a39, 0xd0a215, 0x4385be, 0xce5d97, 0x3aa99f, 0x878580, 0x575653,
0xaf3029, 0x66800b, 0xad8301, 0x205ea6, 0xa02f6f, 0x24837b, 0xcecdc3,
],
};
/// https://raw.githubusercontent.com/sonph/onehalf/master/iterm/OneHalfDark.itermcolors (MIT).
/// Bright black is Windows Terminal's `#5a6374`: here it is the
/// background, which hides text in it.
const ONE_HALF_DARK: TerminalTheme = TerminalTheme {
key: "one-half-dark",
name: "One Half Dark",
mode: ThemeMode::Dark,
foreground: 0xdcdfe4,
background: 0x282c34,
cursor: 0xa3b3cc,
selection: 0x474e5d,
ansi: [
0x282c34, 0xe06c75, 0x98c379, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xdcdfe4, 0x5a6374,
0xe06c75, 0x98c379, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xdcdfe4,
],
};
/// https://raw.githubusercontent.com/morhetz/gruvbox-contrib/master/termite/gruvbox-dark (MIT).
/// No cursor there: it is the text. The selection is `bg3`, what gruvbox.vim
/// selects with when not inverting.
const GRUVBOX_DARK: TerminalTheme = TerminalTheme {
key: "gruvbox-dark",
name: "Gruvbox Dark",
mode: ThemeMode::Dark,
foreground: 0xebdbb2,
background: 0x282828,
cursor: 0xebdbb2,
selection: 0x665c54,
ansi: [
0x282828, 0xcc241d, 0x98971a, 0xd79921, 0x458588, 0xb16286, 0x689d6a, 0xa89984, 0x928374,
0xfb4934, 0xb8bb26, 0xfabd2f, 0x83a598, 0xd3869b, 0x8ec07c, 0xebdbb2,
],
};
/// https://raw.githubusercontent.com/rebelot/kanagawa.nvim/master/extras/ghostty/kanagawa-wave (MIT).
const KANAGAWA_WAVE: TerminalTheme = TerminalTheme {
key: "kanagawa-wave",
name: "Kanagawa Wave",
mode: ThemeMode::Dark,
foreground: 0xdcd7ba,
background: 0x1f1f28,
cursor: 0xc8c093,
selection: 0x2d4f67,
ansi: [
0x16161d, 0xc34043, 0x76946a, 0xc0a36e, 0x7e9cd8, 0x957fb8, 0x6a9589, 0xc8c093, 0x727169,
0xe82424, 0x98bb6c, 0xe6c384, 0x7fb4ca, 0x938aa9, 0x7aa89f, 0xdcd7ba,
],
};
/// https://raw.githubusercontent.com/dracula/windows-terminal/master/dracula.json (MIT).
const DRACULA: TerminalTheme = TerminalTheme {
key: "dracula",
name: "Dracula",
mode: ThemeMode::Dark,
foreground: 0xf8f8f2,
background: 0x282a36,
cursor: 0xf8f8f2,
selection: 0x44475a,
ansi: [
0x21222c, 0xff5555, 0x50fa7b, 0xf1fa8c, 0xbd93f9, 0xff79c6, 0x8be9fd, 0xf8f8f2, 0x6272a4,
0xff6e6e, 0x69ff94, 0xffffa5, 0xd6acff, 0xff92df, 0xa4ffff, 0xffffff,
],
};
/// https://raw.githubusercontent.com/nordtheme/alacritty/main/src/nord.yaml (MIT).
const NORD: TerminalTheme = TerminalTheme {
key: "nord",
name: "Nord",
mode: ThemeMode::Dark,
foreground: 0xd8dee9,
background: 0x2e3440,
cursor: 0xd8dee9,
selection: 0x4c566a,
ansi: [
0x3b4252, 0xbf616a, 0xa3be8c, 0xebcb8b, 0x81a1c1, 0xb48ead, 0x88c0d0, 0xe5e9f0, 0x4c566a,
0xbf616a, 0xa3be8c, 0xebcb8b, 0x81a1c1, 0xb48ead, 0x8fbcbb, 0xeceff4,
],
};
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use alacritty_terminal::vte::ansi::{NamedColor, Rgb};
use gpui_kit::component::ThemeMode;
use super::TerminalTheme;
/// WCAG relative luminance.
fn luminance(color: u32) -> f32 {
let channel = |shift: u32| {
let value = ((color >> shift) & 0xff) as f32 / 255.;
if value <= 0.03928 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
}
fn contrast(a: u32, b: u32) -> f32 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
#[test]
fn keys_are_unique_and_each_theme_suits_its_column() {
let mut keys = HashSet::new();
for theme in TerminalTheme::all() {
assert!(keys.insert(theme.key), "{} twice", theme.key);
assert_eq!(TerminalTheme::find(theme.key), Some(theme));
assert_eq!(
luminance(theme.background) > 0.5,
theme.mode == ThemeMode::Light,
"{} has a background of the other appearance",
theme.name
);
// Solarized is soft by design, about 4:1; this catches a text
// color swapped for another.
assert!(
contrast(theme.foreground, theme.background) >= 3.,
"{}'s text is hard to read",
theme.name
);
}
for mode in [ThemeMode::Light, ThemeMode::Dark] {
let default = TerminalTheme::default_for(mode);
assert_eq!(default.mode, mode);
assert_eq!(TerminalTheme::for_mode(mode).next(), Some(default));
}
}
#[test]
fn the_default_themes_colors_read_on_their_background() {
for mode in [ThemeMode::Light, ThemeMode::Dark] {
let theme = TerminalTheme::default_for(mode);
// Black, white and their bright versions are meant to fade into
// one background or the other.
for index in (1..=6).chain(9..=14) {
let ratio = contrast(theme.ansi[index], theme.background);
assert!(ratio >= 3., "{} color {index}: {ratio:.2}", theme.name);
}
// zsh's suggestions and many prompts' quiet parts are bright
// black.
assert!(contrast(theme.ansi[8], theme.background) >= 3.);
}
}
#[test]
fn colors_above_the_sixteen_are_xterms() {
let theme = TerminalTheme::default_for(ThemeMode::Light);
assert_eq!(theme.indexed_rgb(16), 0x000000);
assert_eq!(theme.indexed_rgb(21), 0x0000ff);
assert_eq!(theme.indexed_rgb(196), 0xff0000);
assert_eq!(theme.indexed_rgb(231), 0xffffff);
assert_eq!(theme.indexed_rgb(232), 0x080808);
assert_eq!(theme.indexed_rgb(255), 0xeeeeee);
assert_eq!(theme.indexed_rgb(4), theme.ansi[4]);
}
#[test]
fn programs_asking_for_colors_are_told_the_themes() {
let light = TerminalTheme::default_for(ThemeMode::Light);
let rgb = |value: u32| Rgb {
r: (value >> 16) as u8,
g: (value >> 8) as u8,
b: value as u8,
};
// vim asks for the background to choose its light or dark colors.
assert_eq!(
light.query_color(NamedColor::Background as usize),
rgb(0xffffff)
);
assert_eq!(
light.query_color(NamedColor::Foreground as usize),
rgb(light.foreground)
);
assert_eq!(
light.query_color(NamedColor::Cursor as usize),
rgb(light.cursor)
);
assert_eq!(light.query_color(1), rgb(light.ansi[1]));
assert_eq!(light.query_color(232), rgb(0x080808));
let dark = TerminalTheme::default_for(ThemeMode::Dark);
assert_eq!(
dark.query_color(NamedColor::Background as usize),
rgb(0x0a0a0a)
);
}
}
+3
View File
@@ -24,6 +24,9 @@ pub const APPLE_TEAM_ID: Option<&str> = option_env!("SHELLRS_APPLE_TEAM_ID");
/// new `schema`, not a new path.
pub const MANIFEST_URL: &str = "https://dl.shellrs.com/update/v1/{channel}.json";
/// The ShellRS website.
pub const WEBSITE: &str = "https://shellrs.com";
/// Where a person downloads ShellRS by hand.
pub const DOWNLOAD_PAGE: &str = "https://shellrs.com/download";
+370
View File
@@ -0,0 +1,370 @@
//! The workspace's share of the external CLI: coming forward when ShellRS
//! is opened again, and the changes to hosts and credentials a `shellrs`
//! command asks for. The request threads cannot reach the store; the
//! changes wait in the server until they are made here, one at a time.
use std::time::Duration;
use gpui_kit::*;
use crate::cli::{
CliChange, CliError, CredentialDeleted, CredentialFields, ErrorCode, HostDeleted, HostFields,
Reply,
};
use crate::cli::{
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
find_credential, find_saved_host, host_details, host_info, host_secrets, plan_credential,
plan_host, with_saved_credential_secrets, with_saved_passwords,
};
use crate::host::{CredentialId, GroupDraft, GroupId, HostId, HostStore};
use crate::secrets::{SecretRef, SharedSecretStore};
use super::workspace_view::Workspace;
/// How often the app looks for what the CLI server has heard.
const CLI_POLL: Duration = Duration::from_millis(100);
impl Workspace {
/// Bring the window forward whenever ShellRS is opened while it is
/// already running, and make the changes the CLI asks for. The second
/// copy of ShellRS only passes the word on and exits: two of them on
/// one data directory would each keep their own copy of the hosts and
/// write over the other's changes.
///
/// Asked on a timer, like every other worker: the thread that hears the
/// request never wakes the window itself. A change is answered once
/// made, the server's snapshot already showing it, and only then is the
/// next one taken.
pub(super) fn serve_cli(&self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
loop {
cx.background_executor().timer(CLI_POLL).await;
let taken = this.update_in(cx, |this, window, cx| {
let server = this.cli_server.as_ref()?;
let links = server.take_activation();
let change = server.take_change();
if let Some(links) = links {
crate::app::bring_forward(window, cx);
for link in links {
this.open_link(link, window, cx);
}
}
change.map(|(change, reply)| (this.apply_cli_change(change, window, cx), reply))
});
match taken {
Ok(Some((outcome, reply))) => reply.send(outcome.await),
Ok(None) => {}
Err(_) => break,
}
}
})
.detach();
}
/// Make a change a `shellrs` command asks for, checked as the host and
/// credential forms check theirs. Its outcome is the reply the command
/// prints; on success, the host or credential as it is now.
pub fn apply_cli_change(
&mut self,
change: CliChange,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<Reply, CliError>> {
let store = self.store.read(cx);
match change {
CliChange::CreateHost(fields) => self.save_host_from_cli(None, fields, cx),
CliChange::UpdateHost { host, fields } => match find_saved_host(store, &host) {
Ok(id) => self.save_host_from_cli(Some(id), fields, cx),
Err(error) => Task::ready(Err(error)),
},
CliChange::DeleteHost { host, force } => {
Task::ready(self.delete_host_from_cli(&host, force, window, cx))
}
CliChange::CreateCredential(fields) => self.save_credential_from_cli(None, fields, cx),
CliChange::UpdateCredential { credential, fields } => {
match find_credential(store, &credential) {
Ok(credential) => {
let id = credential.id;
self.save_credential_from_cli(Some(id), fields, cx)
}
Err(error) => Task::ready(Err(error)),
}
}
CliChange::DeleteCredential { credential } => {
Task::ready(self.delete_credential_from_cli(&credential, cx))
}
}
}
fn save_host_from_cli(
&mut self,
existing: Option<HostId>,
fields: HostFields,
cx: &mut Context<Self>,
) -> Task<Result<Reply, CliError>> {
let plan = match plan_host(fields, existing, self.store.read(cx)) {
Ok(plan) => plan,
Err(error) => return Task::ready(Err(error)),
};
let store = self.store.clone();
let secrets = store.read(cx).secrets();
cx.spawn(async move |_, cx| {
// Read what moves with the host before the change, after which
// the store may delete the entry it moves from.
let password = secret_value(plan.password, &secrets, cx).await;
let proxy_password = secret_value(plan.proxy_password, &secrets, cx).await;
let (id, mut failures, writes) = store.update(cx, |store, cx| {
let (id, failures) = store.capture_failures(cx, |store, cx| {
let mut draft = plan.draft;
draft.group = place_group(store, plan.group, cx);
match existing {
Some(id) => {
store.update(id, draft, cx);
id
}
None => store.insert(draft, cx),
}
});
let writes: Vec<_> = [password, proxy_password]
.into_iter()
.flatten()
.map(|(secret, value)| store.write_secret(secret, value, cx))
.collect();
(id, failures, writes)
});
for write in writes {
if let Err(error) = write.await {
failures.push(error);
}
}
if !failures.is_empty() {
return Err(save_failed(failures));
}
let (details, refs) = store
.read_with(cx, |store, _| {
store
.host(id)
.map(|host| (host_details(host, store), host_secrets(host, store)))
})
.ok_or_else(|| CliError::new(ErrorCode::HostNotFound, "主机保存后不见了"))?;
let details = cx
.background_executor()
.spawn(async move {
with_saved_passwords(details, &refs, |secret| is_saved(&secrets, secret))
})
.await;
Ok(Reply::Host(details))
})
}
/// Delete a saved host as the host list does, closing its tabs; a
/// host with tabs open only with `force`, since nobody is asked.
fn delete_host_from_cli(
&mut self,
host: &str,
force: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Result<Reply, CliError> {
let store = self.store.read(cx);
let id = find_saved_host(store, host)?;
let Some(host) = store.host(id) else {
return Err(CliError::new(ErrorCode::HostNotFound, "主机已不存在"));
};
let deleted = HostDeleted {
host: host_details(host, store),
forwards: store.forwards_of(id).count() as u64,
jump_users: store.jump_users(&[id]) as u64,
};
if !force && self.has_tabs(id, cx) {
return Err(CliError::new(
ErrorCode::HostInUse,
format!(
"主机「{}」有打开的标签:先关闭它们,或加 --force 连同标签一起关闭",
deleted.host.name
),
));
}
self.close_host_tabs(id, window, cx);
let (_, failures) = self.store.update(cx, |store, cx| {
store.capture_failures(cx, |store, cx| store.remove(id, cx))
});
if !failures.is_empty() {
return Err(save_failed(failures));
}
Ok(Reply::HostDeleted(deleted))
}
fn save_credential_from_cli(
&mut self,
existing: Option<CredentialId>,
fields: CredentialFields,
cx: &mut Context<Self>,
) -> Task<Result<Reply, CliError>> {
let plan = match plan_credential(fields, existing, self.store.read(cx)) {
Ok(plan) => plan,
Err(error) => return Task::ready(Err(error)),
};
let store = self.store.clone();
let secrets = store.read(cx).secrets();
let saved = store.update(cx, |store, cx| save_credential(store, existing, plan, cx));
let (id, mut failures, writes) = match saved {
Ok(saved) => saved,
Err(error) => return Task::ready(Err(error)),
};
cx.spawn(async move |_, cx| {
for write in writes {
if let Err(error) = write.await {
failures.push(error);
}
}
if !failures.is_empty() {
return Err(save_failed(failures));
}
let (details, refs) = store
.read_with(cx, |store, _| {
store.credential(id).map(|credential| {
(
credential_details(credential, store),
credential_secrets(credential),
)
})
})
.ok_or_else(|| CliError::new(ErrorCode::CredentialNotFound, "凭据保存后不见了"))?;
let details = cx
.background_executor()
.spawn(async move {
with_saved_credential_secrets(details, &refs, |secret| {
is_saved(&secrets, secret)
})
})
.await;
Ok(Reply::Credential(details))
})
}
/// Delete a credential as the credential list does; the hosts using it
/// log in on their own from now on, and are listed in the reply.
fn delete_credential_from_cli(
&mut self,
credential: &str,
cx: &mut Context<Self>,
) -> Result<Reply, CliError> {
let store = self.store.read(cx);
let found = find_credential(store, credential)?;
let (id, details) = (found.id, credential_details(found, store));
let (released, failures) = self.store.update(cx, |store, cx| {
store.capture_failures(cx, |store, cx| store.remove_credential(id, cx))
});
if !failures.is_empty() {
return Err(save_failed(failures));
}
let store = self.store.read(cx);
Ok(Reply::CredentialDeleted(CredentialDeleted {
credential: details,
released: released
.iter()
.filter_map(|id| store.host(*id))
.map(|host| host_info(host, store))
.collect(),
}))
}
}
/// The group a host goes into, made first when it is new.
fn place_group(
store: &mut HostStore,
plan: GroupPlan,
cx: &mut Context<HostStore>,
) -> Option<GroupId> {
match plan {
GroupPlan::Existing(group) => group,
GroupPlan::Create { parent, names } => names.into_iter().fold(parent, |parent, name| {
Some(store.insert_group(GroupDraft::new(name, parent), cx))
}),
}
}
/// Write the key file, then the credential, then start writing its
/// secrets: the order the credential form keeps, so the credential never
/// names a key file that is not there.
fn save_credential(
store: &mut HostStore,
existing: Option<CredentialId>,
plan: CredentialPlan,
cx: &mut Context<HostStore>,
) -> Result<SavedCredential, CliError> {
let mut draft = plan.draft;
if let Some(pasted) = &plan.private_key {
let path = store
.save_private_key(existing, pasted.text())
.map_err(|error| {
CliError::new(ErrorCode::SaveFailed, format!("私钥未能保存:{error}"))
})?;
draft = draft.with_key_path(path);
}
let (id, failures) = store.capture_failures(cx, |store, cx| match existing {
Some(id) => {
store.update_credential(id, draft, cx);
id
}
None => store.insert_credential(draft, cx),
});
let Some(credential) = store.credential(id).cloned() else {
return Err(CliError::new(
ErrorCode::CredentialNotFound,
"凭据保存后不见了",
));
};
let mut writes = Vec::new();
if let Some(value) = plan.password.value() {
writes.push(store.write_secret(credential.password_secret(), value, cx));
}
if let (Some(path), Some(value)) = (credential.key_path.as_deref(), plan.passphrase.value()) {
writes.push(store.write_secret(SecretRef::passphrase(path), value, cx));
}
Ok((id, failures, writes))
}
type SavedCredential = (
CredentialId,
Vec<SharedString>,
Vec<Task<Result<(), SharedString>>>,
);
/// What to write to `secret`, `None` for nothing and `Some(None)` to
/// delete it: a value moving with the host is read from where it was.
async fn secret_value(
change: Option<(SecretRef, SecretChange)>,
secrets: &SharedSecretStore,
cx: &mut AsyncApp,
) -> Option<(SecretRef, Option<String>)> {
let (secret, change) = change?;
let value = match change {
SecretChange::Carry(from) => {
let secrets = secrets.clone();
let value = cx
.background_executor()
.spawn(async move { secrets.get(&from).ok().flatten() })
.await?;
Some(value.to_string())
}
change => change.value()?,
};
Some((secret, value))
}
fn is_saved(secrets: &SharedSecretStore, secret: &SecretRef) -> bool {
secrets.get(secret).is_ok_and(|value| value.is_some())
}
fn save_failed(failures: Vec<SharedString>) -> CliError {
CliError::new(
ErrorCode::SaveFailed,
failures
.iter()
.map(SharedString::as_ref)
.collect::<Vec<_>>()
.join(";"),
)
}
+4
View File
@@ -2,6 +2,7 @@
//! the right sidebar's tools, status bar, and the start page (recent hosts)
//! the center shows while no tab is open.
mod cli_changes;
mod credentials;
mod dock_skin;
mod editors;
@@ -12,11 +13,14 @@ mod recent_hosts;
mod sidebar;
mod snippets;
mod status_bar;
mod tabs;
mod title_bar;
mod tool_sidebar;
mod tools;
mod updates;
mod window_state;
mod workspace_view;
pub use links::open_link_once_open;
pub use window_state::WindowState;
pub use workspace_view::{Workspace, notify_once_open, window_options};
+25 -6
View File
@@ -1,6 +1,7 @@
//! What terminals tell the user while they look elsewhere: notifications
//! programs ask for and the bell. The system's notification when the window
//! is not in front, one in the window when another tab is.
//! programs ask for, the bell, and lines a 关键字高亮 rule tells of. The
//! system's notification when the window is not in front, one in the window
//! when another tab is.
use std::time::{Duration, Instant};
@@ -14,9 +15,11 @@ use crate::shared::RenamableTab as _;
use crate::terminal::{LocalTerminalId, RemoteTerminalId, TerminalNotice};
/// How often one terminal may notify: a program at most every 2 seconds,
/// the bell every 10. More in between are dropped.
/// the bell and 关键字高亮 rules (all of them together) every 10. More in
/// between are dropped.
const PROGRAM_INTERVAL: Duration = Duration::from_secs(2);
const BELL_INTERVAL: Duration = Duration::from_secs(10);
const KEYWORD_INTERVAL: Duration = Duration::from_secs(10);
/// Where a notice goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -30,7 +33,9 @@ pub(super) enum Delivery {
/// Where `notice` goes. A program asked to notify, so it does so even over
/// its own terminal; the bell rings for typing too (a Tab with nothing to
/// complete), so it only tells of a terminal out of sight.
/// complete), and a keyword the user sees colored on screen, or typed
/// (`grep ERROR`), is no news, so these only tell of a terminal out of
/// sight. A rule that notifies is its own switch.
pub(super) fn delivery(
notice: &TerminalNotice,
settings: NotificationSettings,
@@ -40,6 +45,7 @@ pub(super) fn delivery(
let (allowed, quiet_in_front) = match notice {
TerminalNotice::Program { .. } => (settings.programs, false),
TerminalNotice::Bell { .. } => (settings.bell, true),
TerminalNotice::Keyword { .. } => (true, true),
};
if !allowed {
Delivery::None
@@ -62,6 +68,7 @@ pub(super) fn notice_text(tab: &str, notice: &TerminalNotice) -> (String, String
} => (format!("{tab}:{title}"), body.clone()),
TerminalNotice::Program { title: None, body } => (tab.to_string(), body.clone()),
TerminalNotice::Bell { line } => (format!("{tab}:响铃"), line.clone()),
TerminalNotice::Keyword { rule, line } => (format!("{tab}:{rule}"), line.clone()),
}
}
@@ -113,6 +120,7 @@ impl Workspace {
let (kind, interval) = match notice {
TerminalNotice::Program { .. } => ("program", PROGRAM_INTERVAL),
TerminalNotice::Bell { .. } => ("bell", BELL_INTERVAL),
TerminalNotice::Keyword { .. } => ("keyword", KEYWORD_INTERVAL),
};
let key = format!("{tag}:{kind}");
// The executor's clock, which tests can move on.
@@ -216,22 +224,29 @@ mod tests {
}
}
fn keyword() -> TerminalNotice {
TerminalNotice::keyword("错误", "12:00 ERROR boom")
}
#[test]
fn the_system_tells_of_a_window_away_and_the_window_of_another_tab() {
let on = NotificationSettings::default();
for notice in [program(None), bell()] {
for notice in [program(None), bell(), keyword()] {
assert_eq!(delivery(&notice, on, false, true), Delivery::System);
assert_eq!(delivery(&notice, on, true, false), Delivery::InApp);
}
// In sight, a program still notifies; the bell does not.
// In sight, a program still notifies; the bell and a keyword do not.
assert_eq!(delivery(&program(None), on, true, true), Delivery::InApp);
assert_eq!(delivery(&bell(), on, true, true), Delivery::None);
assert_eq!(delivery(&keyword(), on, true, true), Delivery::None);
let off = NotificationSettings {
programs: false,
bell: false,
};
assert_eq!(delivery(&program(None), off, false, false), Delivery::None);
assert_eq!(delivery(&bell(), off, false, false), Delivery::None);
// A rule's own switch says whether it notifies.
assert_eq!(delivery(&keyword(), off, false, false), Delivery::System);
}
#[test]
@@ -248,6 +263,10 @@ mod tests {
notice_text("本地终端 1", &bell()),
("本地终端 1:响铃".to_string(), "y/N?".to_string())
);
assert_eq!(
notice_text("web-01", &keyword()),
("web-01:错误".to_string(), "12:00 ERROR boom".to_string())
);
}
#[test]
+75
View File
@@ -0,0 +1,75 @@
//! Moving between the center's tabs from the keyboard: the next one, the
//! previous one, or one by its position, within the current tab's group.
use gpui_kit::component::dock::TabGroup;
use gpui_kit::*;
use crate::app::{NextTab, PreviousTab, SwitchToTab};
use super::Workspace;
impl Workspace {
pub(super) fn on_next_tab(&mut self, _: &NextTab, window: &mut Window, cx: &mut Context<Self>) {
self.step_tab(1, window, cx);
}
pub(super) fn on_previous_tab(
&mut self,
_: &PreviousTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.step_tab(-1, window, cx);
}
/// From 1; 9 is the last, as in browsers. A position past the last
/// does nothing.
pub(super) fn on_switch_to_tab(
&mut self,
action: &SwitchToTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((group, _)) = self.current_tab_group(cx) else {
return;
};
group.update(cx, |group, cx| {
let count = group.panels().len();
let ix = if action.0 >= 9 {
count.checked_sub(1)
} else {
action.0.checked_sub(1).filter(|ix| *ix < count)
};
if let Some(ix) = ix {
group.select_tab(ix, window, cx);
}
});
}
/// The tab `step` places away from the current one, around the ends.
fn step_tab(&mut self, step: isize, window: &mut Window, cx: &mut Context<Self>) {
let Some((group, ix)) = self.current_tab_group(cx) else {
return;
};
group.update(cx, |group, cx| {
let count = group.panels().len() as isize;
if count > 1 {
let next = (ix as isize + step).rem_euclid(count) as usize;
group.select_tab(next, window, cx);
}
});
}
/// The group holding the current center tab, and where in it the tab
/// is. Its `select_tab` activates the panel, which reports back to
/// `set_active_tab`.
fn current_tab_group(&self, cx: &App) -> Option<(Entity<TabGroup>, usize)> {
let (group, panel) = self.center_tab_location(self.active_tab?, cx)?;
let ix = group
.read(cx)
.panels()
.iter()
.position(|candidate| candidate.panel_id(cx) == panel)?;
Some((group, ix))
}
}
+1 -5
View File
@@ -139,11 +139,7 @@ pub fn render_title_bar(
.small()
.icon(CatalogIcon::Terminal)
.label("本地终端")
.tooltip(if cfg!(target_os = "macos") {
"新建本地终端(⌘T)"
} else {
"新建本地终端(Ctrl+T)"
})
.tooltip_with_action("新建本地终端", &NewLocalTerminal, None)
.on_click(move |_, window, cx| {
new_local.dispatch_action(&NewLocalTerminal, window, cx)
}),
+12 -2
View File
@@ -7,11 +7,12 @@ use gpui_kit::component::{WindowExt as _, notification::Notification};
use gpui_kit::*;
use crate::app::{
CheckForUpdates, DownloadUpdate, OpenChangelog, OpenDownloadPage, RestartToUpdate, ShowUpdate,
CheckForUpdates, DownloadUpdate, OpenChangelog, OpenDownloadPage, OpenWebsite, RestartToUpdate,
ShowUpdate,
};
use crate::host::ConnectionState;
use crate::terminal::TerminalLifecycle;
use crate::update::build_info::CHANGELOG_PAGE;
use crate::update::build_info::{CHANGELOG_PAGE, WEBSITE};
use crate::update::{RestartImpact, UpdaterEvent, open_update_dialog};
use super::workspace_view::Workspace;
@@ -89,6 +90,15 @@ impl Workspace {
cx.open_url(CHANGELOG_PAGE);
}
pub(super) fn on_open_website(
&mut self,
_: &OpenWebsite,
_: &mut Window,
cx: &mut Context<Self>,
) {
cx.open_url(WEBSITE);
}
/// The title bar shows the update button only in some phases; the
/// version after an update is said once the new one runs.
pub(super) fn on_updater_event(
+387
View File
@@ -0,0 +1,387 @@
//! 设置 › 应用 › 窗口: the main window's size, maximized state and place,
//! kept in `window.json` as they change, and where the next launch opens
//! the window from them.
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Duration;
use gpui_kit::*;
use serde::{Deserialize, Serialize};
use crate::settings::WindowSettings;
use super::Workspace;
/// How long a move or resize has to settle before it is written: a drag
/// reports every step.
const SAVE_DELAY: Duration = Duration::from_millis(500);
/// A new window's size, and the size of one whose size is not remembered.
fn default_size() -> Size<Pixels> {
size(px(1280.), px(800.))
}
/// How much of a window has to be on its screen for it to open where it
/// was: enough of the title bar to drag it by.
const VISIBLE_WIDTH: f32 = 120.;
const VISIBLE_HEIGHT: f32 = 40.;
/// The main window as it last was.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WindowState {
/// The screen it was on, as the system names it across restarts.
display: Option<String>,
/// Its size and place when neither maximized nor full screen, as GPUI
/// gives and takes them: from the screen's top left on macOS, the
/// desktop's elsewhere.
bounds: Bounds<Pixels>,
maximized: bool,
}
impl WindowState {
pub fn capture(window: &Window, cx: &App) -> Self {
// macOS never reports a window maximized, only its frame filling
// the screen; a full screen window comes back as it was before.
let (bounds, maximized) = match window.window_bounds() {
WindowBounds::Windowed(bounds) => (bounds, window.is_maximized()),
WindowBounds::Maximized(bounds) => (bounds, true),
WindowBounds::Fullscreen(bounds) => (bounds, false),
};
let display = window
.display(cx)
.and_then(|display| display.uuid().ok())
.map(|uuid| uuid.to_string());
Self {
display,
bounds,
maximized,
}
}
/// The state saved at `path`; none when there is none or it does not
/// read, and the window opens as a new one would.
pub fn load(path: &Path) -> Option<Self> {
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
}
/// Written whole, through a temporary file, as the settings are.
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let temporary = path.with_extension("json.tmp");
std::fs::write(&temporary, serde_json::to_vec_pretty(self)?)?;
std::fs::rename(temporary, path)
}
}
impl Workspace {
/// Keep the window's size and place at `path` as they change, for the
/// next launch. Always kept: 设置 › 应用 › 窗口 decides what of it the
/// next launch uses.
pub(super) fn remember_window(
&mut self,
path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
let latest: Rc<RefCell<Option<WindowState>>> = Rc::default();
// Written again on quitting, in case a change has not settled. The
// app's, not this view's: on Windows and Linux closing the window
// has dropped the view by then.
App::on_app_quit(cx, {
let (latest, path) = (latest.clone(), path.clone());
move |_| {
if let Some(state) = latest.borrow().as_ref() {
write(state, &path);
}
async {}
}
})
.detach();
let subscription = cx.observe_window_bounds(window, move |this, window, cx| {
let state = WindowState::capture(window, cx);
if latest.borrow().as_ref() == Some(&state) {
return;
}
*latest.borrow_mut() = Some(state.clone());
let path = path.clone();
this.window_state_save = Some(cx.spawn(async move |_, cx| {
cx.background_executor().timer(SAVE_DELAY).await;
cx.background_spawn(async move { write(&state, &path) })
.await;
}));
});
self._subscriptions.push(subscription);
}
}
/// Saving the window's place is a convenience: a failure is only logged.
fn write(state: &WindowState, path: &Path) {
if let Err(error) = state.save(path) {
eprintln!("shellrs: 无法保存窗口位置:{error}");
}
}
/// Where the main window opens: `saved`, as much of it as `settings`
/// remember, on the screens there are now.
pub fn window_placement(
saved: Option<&WindowState>,
settings: WindowSettings,
cx: &App,
) -> (WindowBounds, Option<DisplayId>) {
let screens: Vec<Screen> = cx
.displays()
.iter()
.map(|display| Screen {
id: display.id(),
uuid: display.uuid().ok().map(|uuid| uuid.to_string()),
bounds: display.bounds(),
visible: display.visible_bounds(),
})
.collect();
let primary = cx.primary_display().map(|display| display.id());
placement(
saved,
settings,
&screens,
primary,
cfg!(target_os = "macos"),
)
}
/// A screen the window could open on.
struct Screen {
id: DisplayId,
uuid: Option<String>,
bounds: Bounds<Pixels>,
/// Without the menu bar, Dock or taskbar.
visible: Bounds<Pixels>,
}
/// [`window_placement`] over given screens. `macos`: maximized is a frame
/// filling the screen's visible part, as macOS shows it, not a state.
fn placement(
saved: Option<&WindowState>,
settings: WindowSettings,
screens: &[Screen],
primary: Option<DisplayId>,
macos: bool,
) -> (WindowBounds, Option<DisplayId>) {
let primary = screens
.iter()
.find(|screen| Some(screen.id) == primary)
.or(screens.first());
let centered = |screen: Option<&Screen>, size: Size<Pixels>| match screen {
Some(screen) => Bounds::centered_at(screen.bounds.center(), size),
None => Bounds::new(point(px(0.), px(0.)), size),
};
let Some(saved) = saved.filter(|_| settings.remember_size || settings.remember_position) else {
return (
WindowBounds::Windowed(centered(primary, default_size())),
None,
);
};
// The screen it was on, if remembered and still there; else the main
// one.
let remembered = saved
.display
.as_ref()
.filter(|_| settings.remember_position)
.and_then(|uuid| {
screens
.iter()
.find(|screen| screen.uuid.as_ref() == Some(uuid))
});
let screen = remembered.or(primary);
let size = if settings.remember_size {
// Taken from a larger screen, it shrinks to fit this one.
match screen {
Some(screen) => size(
saved.bounds.size.width.min(screen.visible.size.width),
saved.bounds.size.height.min(screen.visible.size.height),
),
None => saved.bounds.size,
}
} else {
default_size()
};
let bounds = match remembered {
Some(screen) if reachable(Bounds::new(saved.bounds.origin, size), screen.bounds) => {
Bounds::new(saved.bounds.origin, size)
}
_ => centered(screen, size),
};
let display = remembered.map(|screen| screen.id);
if settings.remember_size && saved.maximized {
return match screen {
Some(screen) if macos => (WindowBounds::Windowed(screen.visible), display),
_ => (WindowBounds::Maximized(bounds), display),
};
}
(WindowBounds::Windowed(bounds), display)
}
/// Whether enough of `bounds` is on the screen to take hold of: its top
/// edge on the screen, and a stretch of title bar with it.
fn reachable(bounds: Bounds<Pixels>, screen: Bounds<Pixels>) -> bool {
let overlap = bounds.intersect(&screen);
bounds.top() >= screen.top()
&& bounds.top() <= screen.bottom() - px(VISIBLE_HEIGHT)
&& overlap.size.width >= px(VISIBLE_WIDTH)
&& overlap.size.height >= px(VISIBLE_HEIGHT)
}
#[cfg(test)]
mod tests {
use gpui_kit::{Bounds, DisplayId, Pixels, WindowBounds, point, px, size};
use super::{Screen, WindowState, default_size, placement};
use crate::settings::WindowSettings;
fn rect(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
}
/// A laptop screen and a larger one to its right, the laptop's menu
/// bar and Dock taken from its visible part.
fn screens() -> Vec<Screen> {
vec![
Screen {
id: DisplayId::new(1),
uuid: Some("laptop".into()),
bounds: rect(0., 0., 1512., 982.),
visible: rect(0., 33., 1512., 890.),
},
Screen {
id: DisplayId::new(2),
uuid: Some("monitor".into()),
bounds: rect(1512., 0., 2560., 1440.),
visible: rect(1512., 0., 2560., 1440.),
},
]
}
fn saved(display: &str, bounds: Bounds<Pixels>, maximized: bool) -> WindowState {
WindowState {
display: Some(display.into()),
bounds,
maximized,
}
}
fn remember(remember_size: bool, remember_position: bool) -> WindowSettings {
WindowSettings {
remember_size,
remember_position,
}
}
fn place(
saved: Option<&WindowState>,
settings: WindowSettings,
macos: bool,
) -> (WindowBounds, Option<DisplayId>) {
placement(saved, settings, &screens(), Some(DisplayId::new(1)), macos)
}
fn centered_on_laptop(size: gpui_kit::Size<Pixels>) -> WindowBounds {
WindowBounds::Windowed(Bounds::centered_at(
rect(0., 0., 1512., 982.).center(),
size,
))
}
#[test]
fn a_first_launch_opens_in_the_middle_of_the_main_screen() {
let opened = place(None, WindowSettings::default(), false);
assert_eq!(opened, (centered_on_laptop(default_size()), None));
}
#[test]
fn the_window_comes_back_where_it_was_on_the_screen_it_was_on() {
let was = rect(1700., 120., 1600., 1000.);
let state = saved("monitor", was, false);
assert_eq!(
place(Some(&state), WindowSettings::default(), false),
(WindowBounds::Windowed(was), Some(DisplayId::new(2)))
);
// Without its place, it keeps its size in the middle of the main
// screen, shrunk to fit there.
assert_eq!(
place(Some(&state), remember(true, false), false),
(centered_on_laptop(size(px(1512.), px(890.))), None)
);
// Without its size, a new window's size where it was.
assert_eq!(
place(Some(&state), remember(false, true), false),
(
WindowBounds::Windowed(Bounds::new(was.origin, default_size())),
Some(DisplayId::new(2))
)
);
// Neither: as a new window.
assert_eq!(
place(Some(&state), remember(false, false), false),
(centered_on_laptop(default_size()), None)
);
}
#[test]
fn a_window_whose_screen_is_gone_or_that_is_off_it_is_centered() {
let state = saved("projector", rect(100., 100., 1000., 700.), false);
assert_eq!(
place(Some(&state), WindowSettings::default(), false),
(centered_on_laptop(size(px(1000.), px(700.))), None)
);
// Dragged up past the top, or almost all off the side.
for was in [
rect(100., -200., 1000., 700.),
rect(1450., 100., 1000., 700.),
] {
let state = saved("laptop", was, false);
let (bounds, display) = place(Some(&state), WindowSettings::default(), false);
assert_eq!(bounds, centered_on_laptop(was.size));
assert_eq!(display, Some(DisplayId::new(1)));
}
}
#[test]
fn a_maximized_window_opens_maximized_if_its_size_is_remembered() {
let restored = rect(1700., 120., 1600., 1000.);
let state = saved("monitor", restored, true);
assert_eq!(
place(Some(&state), WindowSettings::default(), false),
(WindowBounds::Maximized(restored), Some(DisplayId::new(2)))
);
// On macOS, as a frame filling the screen but its menu bar and Dock.
let state = saved("laptop", rect(0., 33., 1512., 890.), true);
assert_eq!(
place(Some(&state), WindowSettings::default(), true),
(
WindowBounds::Windowed(rect(0., 33., 1512., 890.)),
Some(DisplayId::new(1))
)
);
assert!(matches!(
place(Some(&state), remember(false, true), true).0,
WindowBounds::Windowed(bounds) if bounds.size == default_size()
));
}
#[test]
fn the_state_is_kept_in_a_file_and_a_bad_one_is_ignored() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data").join("window.json");
assert_eq!(WindowState::load(&path), None);
let state = saved("monitor", rect(1700., 120., 1600., 1000.), true);
state.save(&path).unwrap();
assert_eq!(WindowState::load(&path), Some(state));
std::fs::write(&path, "{\"bounds\": 7}").unwrap();
assert_eq!(WindowState::load(&path), None);
}
}
+81 -52
View File
@@ -32,9 +32,10 @@ use crate::app::{
NewTemporaryConnection, OpenExplorer, OpenSettings, PasteTerminal, QuickConnect,
ReconnectTerminal, RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, RenameExplorer,
RenameGroup, RenameTerminal, RestartLocalTerminal, SetFileSizeFormat, ToggleHiddenFiles,
ToggleHostPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
ToggleHostPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset, ZoomTerminalIn, ZoomTerminalOut,
ZoomTerminalReset,
};
use crate::cli::{CliIntegration, CliServer, CliTarget, IntegrationPaths, SshCliBackend};
use crate::cli::{CliIntegration, CliServer, IntegrationPaths, SshCliBackend};
use crate::connection::{
ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply,
ConnectionSecret, SharedConnectionTester,
@@ -53,6 +54,7 @@ use crate::host::{
};
use crate::settings::{
Appearance, SettingsPanel, SettingsPanelEvent, SettingsStore, SettingsStoreEvent,
WindowSettings,
};
use crate::sftp::{
SharedLocalDirectoryProvider, SharedSftpTransportProvider, SshSftpTransportProvider,
@@ -60,10 +62,10 @@ use crate::sftp::{
};
use crate::shared::{commit_footer, confirm_danger, open_rename_tab_dialog};
use crate::terminal::{
LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, LocalTerminalPanelEvent,
RemoteTerminalId, SearchDirection, SharedRemoteTerminalTransportProvider,
SharedTerminalTransportFactory, TerminalLifecycle, TerminalPanel, TerminalPanelEvent,
TerminalView,
DEFAULT_FONT_SIZE, LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel,
LocalTerminalPanelEvent, RemoteTerminalId, SearchDirection,
SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalLifecycle,
TerminalPanel, TerminalPanelEvent, TerminalView,
};
use crate::update::{UpdateServices, Updater, UpdaterEvent};
@@ -75,6 +77,7 @@ use super::{
title_bar::render_title_bar,
tool_sidebar::{ToolSidebar, render_tool_switch},
tools::{TOOL_SIDEBAR_WIDTH, set_right_dock_open},
window_state::{WindowState, window_placement},
};
const DOCK_ID: &str = "shellrs-dock";
@@ -90,10 +93,6 @@ const FONT_SIZE_STEP: f32 = 2.;
/// hits the backdrop. Just below popups (`POPUP_PRIORITY`) keeps a
/// notification above any stack of dialogs without covering an open menu.
const NOTIFICATION_PRIORITY: usize = gpui_kit::base::POPUP_PRIORITY - 1;
/// How often the window asks whether ShellRS was opened again; see
/// [`CliServer::take_activation`].
const ACTIVATION_POLL: Duration = Duration::from_millis(200);
/// Show a notification from code that runs while the window is still being
/// built: loading what is on disk, starting the services.
///
@@ -106,11 +105,18 @@ pub fn notify_once_open(notification: Notification, window: &mut Window, cx: &mu
});
}
/// Window options for the main workspace window.
pub fn window_options(cx: &mut App) -> WindowOptions {
/// Window options for the main workspace window: where it was last, as much
/// of it as 设置 › 应用 › 窗口 remembers.
pub fn window_options(
saved: Option<&WindowState>,
settings: WindowSettings,
cx: &mut App,
) -> WindowOptions {
let (window_bounds, display_id) = window_placement(saved, settings, cx);
WindowOptions {
window_bounds: Some(window_bounds),
display_id,
// Window geometry is a platform boundary; `px` is the API's unit.
window_bounds: Some(WindowBounds::centered(size(px(1280.), px(800.)), cx)),
window_min_size: Some(gpui_kit::Size {
width: px(960.),
height: px(600.),
@@ -160,7 +166,7 @@ pub struct Workspace {
cli_integration: Entity<CliIntegration>,
/// Answers the `shellrs` command. Production only: UI tests never
/// listen on the real socket.
cli_server: Option<CliServer>,
pub(super) cli_server: Option<CliServer>,
/// Looks for, downloads and installs newer versions. Without services
/// in UI tests, so it never reaches the network unless a test hands it
/// fakes.
@@ -192,6 +198,9 @@ pub struct Workspace {
/// When each terminal last notified, of each kind, keyed by
/// `notices::notice_tag` and the kind.
pub(super) notice_times: HashMap<String, std::time::Instant>,
/// The pending write of the window's size and place, see
/// `window_state.rs`.
pub(super) window_state_save: Option<Task<()>>,
pub(super) _subscriptions: Vec<Subscription>,
}
@@ -235,13 +244,14 @@ impl Workspace {
integration.set_paths(IntegrationPaths::system(), cx);
integration.update_outdated(cx);
});
let secrets = this.store.read(cx).secrets();
match CliServer::start(
crate::app::cli_socket_path(),
Arc::new(SshCliBackend::new(connector, sftp)),
Arc::new(SshCliBackend::new(connector, sftp, secrets)),
) {
Ok(server) => {
this.cli_server = Some(server);
this.come_forward_when_opened_again(window, cx);
this.serve_cli(window, cx);
}
Err(error) => notify_once_open(
Notification::error(error.to_string()).title("外部 CLI 无法启动"),
@@ -250,6 +260,7 @@ impl Workspace {
),
}
this.sync_cli_server(cx);
this.remember_window(crate::app::window_state_path(), window, cx);
let bundle = cx.app_path().ok();
this.updater.update(cx, |updater, cx| {
updater.set_services(UpdateServices::system(bundle), cx);
@@ -258,44 +269,12 @@ impl Workspace {
this
}
/// Bring the window forward whenever ShellRS is opened while it is
/// already running. The second copy only passes the word on and exits:
/// two of them on one data directory would each keep their own copy of
/// the hosts and write over the other's changes.
///
/// Asked on a timer, like every other worker: the thread that hears the
/// request never wakes the window itself.
fn come_forward_when_opened_again(&self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
loop {
cx.background_executor().timer(ACTIVATION_POLL).await;
let open = this.update_in(cx, |this, window, cx| {
let Some(links) = this
.cli_server
.as_ref()
.and_then(|server| server.take_activation())
else {
return;
};
crate::app::bring_forward(window, cx);
for link in links {
this.open_link(link, window, cx);
}
});
if open.is_err() {
break;
}
}
})
.detach();
}
/// Tell the CLI server what it may use: whether 启用外部 CLI is on, and
/// the hosts as they are now.
fn sync_cli_server(&self, cx: &App) {
if let Some(server) = &self.cli_server {
server.set_enabled(self.settings.read(cx).settings().external_cli.enabled);
server.set_targets(CliTarget::all(self.store.read(cx)));
server.set_hosts(self.store.read(cx));
}
}
@@ -548,6 +527,7 @@ impl Workspace {
editors: HashMap::new(),
next_editor_id: 1,
notice_times: HashMap::new(),
window_state_save: None,
_subscriptions: subscriptions,
}
}
@@ -1140,7 +1120,7 @@ impl Workspace {
// Something may have been installed or removed since last time.
integration.update(cx, |integration, cx| integration.refresh(cx));
let updater = self.updater.clone();
let panel = cx.new(|cx| SettingsPanel::new(store, integration, updater, cx));
let panel = cx.new(|cx| SettingsPanel::new(store, integration, updater, window, cx));
let subscription = cx.subscribe_in(
&panel,
window,
@@ -1504,7 +1484,11 @@ impl Workspace {
}
/// The tab group holding a center tab, and the tab's panel id in it.
fn center_tab_location(&self, tab: CenterTab, cx: &App) -> Option<(Entity<TabGroup>, PanelId)> {
pub(super) fn center_tab_location(
&self,
tab: CenterTab,
cx: &App,
) -> Option<(Entity<TabGroup>, PanelId)> {
let (group, entity) = match tab {
CenterTab::Terminal(id) => {
let panel = self.terminals.get(&id)?;
@@ -1900,7 +1884,12 @@ impl Workspace {
/// Close whatever a host has open in the center, leaving the store
/// alone. Deleting a host and deleting the group around it both need
/// this, the latter for every host in the subtree.
fn close_host_tabs(&mut self, id: HostId, window: &mut Window, cx: &mut Context<Self>) {
pub(super) fn close_host_tabs(
&mut self,
id: HostId,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.cancel_prompts_for_host(id, window, cx);
let terminals: Vec<_> = self.terminals_of(id, cx).cloned().collect();
for terminal in terminals {
@@ -2111,6 +2100,39 @@ impl Workspace {
fn on_zoom_reset(&mut self, _: &ZoomReset, window: &mut Window, cx: &mut Context<Self>) {
self.set_font_size(FONT_SIZE_DEFAULT, window, cx);
}
/// The terminals' text a pixel larger or smaller, through the settings
/// (终端 › 字号), which keep it in range and for the next launch.
fn change_terminal_font_size(&mut self, size: impl FnOnce(f32) -> f32, cx: &mut App) {
self.settings.update(cx, |store, cx| {
store.update(
|settings| settings.terminal_font.size = size(settings.terminal_font.size),
cx,
)
});
}
fn on_zoom_terminal_in(&mut self, _: &ZoomTerminalIn, _: &mut Window, cx: &mut Context<Self>) {
self.change_terminal_font_size(|size| size + 1., cx);
}
fn on_zoom_terminal_out(
&mut self,
_: &ZoomTerminalOut,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.change_terminal_font_size(|size| size - 1., cx);
}
fn on_zoom_terminal_reset(
&mut self,
_: &ZoomTerminalReset,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.change_terminal_font_size(|_| DEFAULT_FONT_SIZE, cx);
}
}
fn new_terminal_panel(
@@ -2439,9 +2461,13 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_restart_to_update))
.on_action(cx.listener(Self::on_open_download_page))
.on_action(cx.listener(Self::on_open_changelog))
.on_action(cx.listener(Self::on_open_website))
.on_action(cx.listener(Self::on_copy_agent_skill))
.on_action(cx.listener(Self::on_close_settings))
.on_action(cx.listener(Self::on_close_active_tab))
.on_action(cx.listener(Self::on_next_tab))
.on_action(cx.listener(Self::on_previous_tab))
.on_action(cx.listener(Self::on_switch_to_tab))
.on_action(cx.listener(Self::on_close_tabs))
.on_action(cx.listener(Self::on_rename_terminal))
.on_action(cx.listener(Self::on_rename_explorer))
@@ -2501,6 +2527,9 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_zoom_in))
.on_action(cx.listener(Self::on_zoom_out))
.on_action(cx.listener(Self::on_zoom_reset))
.on_action(cx.listener(Self::on_zoom_terminal_in))
.on_action(cx.listener(Self::on_zoom_terminal_out))
.on_action(cx.listener(Self::on_zoom_terminal_reset))
.relative()
.size_full()
.flex()
+134 -11
View File
@@ -14,10 +14,11 @@ use std::{
use shellrs::app::cli_endpoint;
use shellrs::cli::{
CliBackend, CliError, CliServer, CliTarget, HostInfo, OpenLink, TransferCounters,
TransferSummary,
AuthChoice, CliBackend, CliChange, CliError, CliServer, CliTarget, HostDetails, HostInfo,
OpenLink, Reply, RouteDetails, TransferCounters, TransferSummary,
};
use shellrs::host::{AuthKind, Host, HostDraft, HostId, HostLogin};
use shellrs::host::{AuthKind, HostDraft, HostStore};
use shellrs::secrets::SecretRef;
use shellrs::ssh::ExecStream;
/// More than a pipe's buffer, so the app finishes writing before the
@@ -72,6 +73,31 @@ impl CliBackend for FakeBackend {
) -> Result<TransferSummary, CliError> {
Ok(TransferSummary::default())
}
fn sync(
&self,
_: &CliTarget,
source: &Path,
destination: &str,
delete: bool,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
self.commands
.lock()
.unwrap()
.push(format!("sync {} {destination} {delete}", source.display()));
Ok(TransferSummary {
files: 1,
bytes: 10,
skipped: 5,
deleted: 2,
..TransferSummary::default()
})
}
fn is_saved(&self, _: &SecretRef) -> bool {
false
}
}
/// Run the command against the app whose data lives in `data_dir`.
@@ -91,7 +117,7 @@ fn shellrs(data_dir: &Path, args: &[&str], stdin: &[u8]) -> Output {
#[test]
fn without_the_app_the_command_says_so_and_exits_255() {
let data_dir = tempfile::tempdir().unwrap();
let output = shellrs(data_dir.path(), &["list"], b"");
let output = shellrs(data_dir.path(), &["hosts", "list"], b"");
assert_eq!(output.status.code(), Some(255));
assert!(
String::from_utf8(output.stderr)
@@ -105,16 +131,21 @@ fn the_command_finds_the_app_through_its_data_directory() {
let data_dir = tempfile::tempdir().unwrap();
let backend = Arc::new(FakeBackend::default());
let server = CliServer::start(cli_endpoint(data_dir.path()), backend.clone()).unwrap();
let web = Host::new(
HostId(1),
HostDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Password, None),
);
server.set_targets(vec![CliTarget::new(&web, HostLogin::of(&web, None), None)]);
let mut store = HostStore::empty();
let web = store.insert_unnotified(HostDraft::new(
"web-01",
"10.0.1.12",
22,
"root",
AuthKind::Password,
None,
));
server.set_hosts(&store);
server.set_enabled(true);
let id = web.public_id.to_string();
let id = store.host(web).unwrap().public_id.to_string();
// A pipe for stdout means JSON.
let output = shellrs(data_dir.path(), &["list"], b"");
let output = shellrs(data_dir.path(), &["hosts", "list"], b"");
assert_eq!(output.status.code(), Some(0));
let hosts: Vec<HostInfo> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(hosts[0].id, id);
@@ -153,6 +184,31 @@ fn the_command_finds_the_app_through_its_data_directory() {
assert_eq!(output.status.code(), Some(0));
assert_eq!(output.stdout.len(), LARGE_OUTPUT);
// A folder's contents, from where the command runs.
let output = shellrs(
data_dir.path(),
&[
"sync",
&id,
data_dir.path().to_str().unwrap(),
"/srv/app",
"--delete",
],
b"",
);
assert_eq!(output.status.code(), Some(0));
let summary: TransferSummary = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!((summary.skipped, summary.deleted), (5, 2));
assert!(
backend
.commands
.lock()
.unwrap()
.last()
.unwrap()
.ends_with(" /srv/app true")
);
server.set_enabled(false);
let output = shellrs(data_dir.path(), &["exec", &id, "true"], b"");
assert_eq!(output.status.code(), Some(255));
@@ -233,3 +289,70 @@ fn shellrs_opened_with_a_link_hands_it_to_the_running_app() {
])
);
}
#[test]
fn a_host_described_on_stdin_reaches_the_app_and_comes_back_saved() {
let data_dir = tempfile::tempdir().unwrap();
let server = Arc::new(
CliServer::start(
cli_endpoint(data_dir.path()),
Arc::new(FakeBackend::default()),
)
.unwrap(),
);
server.set_enabled(true);
// The app's side: take the change up and answer with the host saved.
let app = std::thread::spawn({
let server = server.clone();
move || {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some((change, reply)) = server.take_change() {
let CliChange::CreateHost(fields) = change else {
panic!("not a new host: {change:?}");
};
let password = fields.password.clone().flatten();
reply.send(Ok(Reply::Host(HostDetails {
id: "Jwg5rHvXCxw89paM".into(),
name: fields.name.clone().unwrap_or_default(),
group: fields.group.clone().flatten(),
host: fields.host.clone().unwrap_or_default(),
port: fields.port.unwrap_or(22),
user: "root".into(),
auth: AuthChoice::Password,
credential: None,
password_saved: Some(password.is_some()),
route: RouteDetails::Direct,
notes: String::new(),
os: None,
temporary: false,
})));
return password.map(|password| password.expose().to_string());
}
assert!(Instant::now() < deadline, "the command sent nothing");
std::thread::sleep(Duration::from_millis(20));
}
}
});
// As Windows PowerShell pipes it: a BOM, CRLF, and its escapes.
let host = "\u{feff}{\"name\": \"\\u751f\\u4ea7 web\", \"address\": \"10.0.1.12\",\r\n \"group\": \"\u{751f}\u{4ea7}/web\", \"password\": \"s3cret\", \"id\": \"ignored\"}\r\n";
let output = shellrs(data_dir.path(), &["hosts", "create"], host.as_bytes());
assert_eq!(output.status.code(), Some(0), "{output:?}");
let saved: HostDetails = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(saved.name, "生产 web");
assert_eq!(saved.group.as_deref(), Some("生产/web"));
assert_eq!(saved.password_saved, Some(true));
assert_eq!(app.join().unwrap().as_deref(), Some("s3cret"));
// A field the command does not know is refused before anything is sent.
let output = shellrs(
data_dir.path(),
&["hosts", "create"],
br#"{"name": "web", "adress": "10.0.1.12"}"#,
);
assert_eq!(output.status.code(), Some(255));
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.starts_with("shellrs: [bad_request]"), "{stderr}");
assert!(stderr.contains("adress"), "{stderr}");
assert!(server.take_change().is_none());
}
+331
View File
@@ -0,0 +1,331 @@
//! The changes the external CLI asks for, as the workspace makes them:
//! hosts and credentials created, changed and deleted from `shellrs`.
//! The socket is not in it; `apply_cli_change` is where the server hands
//! each change over.
use shellrs::cli::{CliChange, CliError, CredentialFields, ErrorCode, HostFields, Reply, Secret};
use crate::support::*;
/// Make `change` as the server's poller would, and wait for its outcome.
fn apply(
cx: &mut TestAppContext,
handle: WindowHandle<Root>,
workspace: &Entity<Workspace>,
change: CliChange,
) -> Result<Reply, CliError> {
let outcome = in_frame(cx, handle, |window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.apply_cli_change(change, window, cx)
})
});
let outcome = cx.foreground_executor.block_test(outcome);
cx.run_until_parked();
outcome
}
fn host_id(workspace: &Entity<Workspace>, id: u64, cx: &mut TestAppContext) -> String {
cx.update(|cx| {
workspace
.read(cx)
.store()
.read(cx)
.host(HostId(id))
.unwrap()
.public_id
.to_string()
})
}
fn store(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> Entity<HostStore> {
cx.update(|cx| workspace.read(cx).store().clone())
}
#[gpui_kit::test]
fn a_host_from_the_cli_lands_in_the_tree_with_its_group_and_password(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let (handle, workspace) =
open_workspace_with_store(cx, HostStore::seed().with_secrets(secrets.clone()));
let fields = HostFields {
name: Some("api-01".into()),
host: Some("10.0.9.1".into()),
group: Some(Some("生产/接口".into())),
password: Some(Some(Secret::new("s3cret"))),
..HostFields::default()
};
let Ok(Reply::Host(created)) = apply(cx, handle, &workspace, CliChange::CreateHost(fields))
else {
panic!("not created");
};
assert_eq!(created.group.as_deref(), Some("生产/接口"));
assert_eq!(created.password_saved, Some(true));
let store = store(&workspace, cx);
let host = cx.update(|cx| {
let store = store.read(cx);
let host = store
.hosts()
.iter()
.find(|host| host.public_id.as_str() == created.id)
.unwrap();
let group = store.group(host.group.unwrap()).unwrap();
assert_eq!(group.name.as_ref(), "接口");
assert_eq!(group.parent, Some(GroupId(PRODUCTION)));
host.id
});
assert_eq!(
secrets
.get(&SecretRef::password("root", "10.0.9.1", 22))
.unwrap()
.as_deref()
.map(String::as_str),
Some("s3cret")
);
in_frame(cx, handle, |window, _| {
assert!(
window
.within("host-tree")
.try_find(("host-row", host.0))
.is_some()
);
});
// Checked as the host form checks it.
let nameless = HostFields {
host: Some("10.0.9.2".into()),
..HostFields::default()
};
let error = apply(cx, handle, &workspace, CliChange::CreateHost(nameless)).unwrap_err();
assert_eq!(
(error.code, error.message.as_str()),
(ErrorCode::BadRequest, "请输入名称")
);
}
#[gpui_kit::test]
fn a_host_moved_to_another_port_keeps_its_password_until_told_otherwise(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let before = SecretRef::password("postgres", "10.0.2.5", 22);
secrets.set(&before, "pw").unwrap();
let (handle, workspace) =
open_workspace_with_store(cx, HostStore::seed().with_secrets(secrets.clone()));
let db = host_id(&workspace, DB_01, cx);
let moved = HostFields {
port: Some(2222),
..HostFields::default()
};
let Ok(Reply::Host(changed)) = apply(
cx,
handle,
&workspace,
CliChange::UpdateHost {
host: db.clone(),
fields: moved,
},
) else {
panic!("not changed");
};
assert_eq!((changed.port, changed.password_saved), (2222, Some(true)));
let after = SecretRef::password("postgres", "10.0.2.5", 2222);
assert_eq!(
secrets.get(&after).unwrap().as_deref().map(String::as_str),
Some("pw")
);
// The entry it left goes, as no other host uses it.
assert!(secrets.get(&before).unwrap().is_none());
let forget = HostFields {
password: Some(None),
..HostFields::default()
};
let Ok(Reply::Host(changed)) = apply(
cx,
handle,
&workspace,
CliChange::UpdateHost {
host: db,
fields: forget,
},
) else {
panic!("not changed");
};
assert_eq!(changed.password_saved, Some(false));
assert!(secrets.get(&after).unwrap().is_none());
}
#[gpui_kit::test]
fn a_host_with_tabs_open_goes_only_with_force_and_takes_them_along(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let web = host_id(&workspace, WEB_01, cx);
let delete = |force| CliChange::DeleteHost {
host: web.clone(),
force,
};
let error = apply(cx, handle, &workspace, delete(false)).unwrap_err();
assert_eq!(error.code, ErrorCode::HostInUse);
let store = store(&workspace, cx);
assert!(cx.update(|cx| store.read(cx).host(HostId(WEB_01)).is_some()));
in_frame(cx, handle, |window, _| {
assert!(
window
.try_find(("terminal-tab", INITIAL_WEB_TERMINAL))
.is_some()
);
});
let Ok(Reply::HostDeleted(deleted)) = apply(cx, handle, &workspace, delete(true)) else {
panic!("not deleted");
};
assert_eq!(deleted.host.name, "web-01");
assert!(cx.update(|cx| store.read(cx).host(HostId(WEB_01)).is_none()));
in_frame(cx, handle, |window, _| {
assert!(
window
.try_find(("terminal-tab", INITIAL_WEB_TERMINAL))
.is_none()
);
});
// Nothing open, nothing to force.
let db = host_id(&workspace, DB_01, cx);
let deleted = apply(
cx,
handle,
&workspace,
CliChange::DeleteHost {
host: db,
force: false,
},
);
assert!(matches!(deleted, Ok(Reply::HostDeleted(_))));
}
#[gpui_kit::test]
fn a_host_not_saved_is_left_to_its_tabs(cx: &mut TestAppContext) {
let mut seeded = HostStore::seed();
let temporary = seeded.insert_temporary_unnotified(
HostDraft::new("临时", "10.0.0.3", 22, "root", AuthKind::Password, None),
None,
);
let (handle, workspace) = open_workspace_with_store(cx, seeded);
let id = cx.update(|cx| {
workspace
.read(cx)
.store()
.read(cx)
.host(temporary)
.unwrap()
.public_id
.to_string()
});
for change in [
CliChange::UpdateHost {
host: id.clone(),
fields: HostFields::default(),
},
CliChange::DeleteHost {
host: id.clone(),
force: true,
},
] {
let error = apply(cx, handle, &workspace, change).unwrap_err();
assert_eq!(error.code, ErrorCode::BadRequest);
assert!(
error.message.contains("没有保存的连接"),
"{}",
error.message
);
}
}
#[gpui_kit::test]
fn a_pasted_key_becomes_a_credential_shellrs_keeps(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let data = tempfile::tempdir().unwrap();
let (handle, workspace) = open_workspace_with_store(
cx,
HostStore::empty()
.with_secrets(secrets.clone())
.with_key_dir(data.path().join("keys")),
);
let key = GeneratedKey::generate(KeyAlgorithm::Ed25519).unwrap();
let text = key.encode("deploy", "correct horse").unwrap().to_string();
let fields = CredentialFields {
name: Some("部署".into()),
user: Some("deploy".into()),
private_key: Some(Secret::new(text.clone())),
passphrase: Some(Some(Secret::new("correct horse"))),
..CredentialFields::default()
};
let Ok(Reply::Credential(created)) =
apply(cx, handle, &workspace, CliChange::CreateCredential(fields))
else {
panic!("not created");
};
assert!(created.kept);
// Where it is kept is not said: the file is as good as the key.
assert_eq!(created.key_path, None);
assert_eq!(created.passphrase_saved, Some(true));
let store = store(&workspace, cx);
let path = cx.update(|cx| {
let store = store.read(cx);
let credential = &store.credentials()[0];
assert_eq!(credential.keychain_id.as_str(), created.id);
assert_eq!(credential.kind, CredentialKind::Key);
credential.key_path.clone().unwrap()
});
assert!(path.starts_with(data.path().join("keys").to_str().unwrap()));
assert_eq!(std::fs::read_to_string(path.as_ref()).unwrap(), text);
assert_eq!(
secrets
.get(&SecretRef::passphrase(path.as_ref()))
.unwrap()
.as_deref()
.map(String::as_str),
Some("correct horse")
);
}
#[gpui_kit::test]
fn deleting_a_credential_lets_its_hosts_log_in_on_their_own(cx: &mut TestAppContext) {
let mut seeded = HostStore::empty();
let credential = seeded.insert_credential_unnotified(CredentialDraft::new(
"运维",
CredentialKind::Password,
"deploy",
));
let host = seeded.insert_unnotified(
HostDraft::new("db-01", "10.0.2.5", 22, "root", AuthKind::Password, None)
.with_credential(credential),
);
let id = seeded
.credential(credential)
.unwrap()
.keychain_id
.to_string();
let (handle, workspace) = open_workspace_with_store(cx, seeded);
let Ok(Reply::CredentialDeleted(deleted)) = apply(
cx,
handle,
&workspace,
CliChange::DeleteCredential { credential: id },
) else {
panic!("not deleted");
};
assert_eq!(deleted.credential.name, "运维");
let released: Vec<&str> = deleted
.released
.iter()
.map(|host| host.name.as_str())
.collect();
assert_eq!(released, ["db-01"]);
let store = store(&workspace, cx);
cx.update(|cx| {
let store = store.read(cx);
assert!(store.credentials().is_empty());
let host = store.host(host).unwrap();
assert_eq!((host.credential, host.auth), (None, AuthKind::Password));
assert_eq!(host.user.as_ref(), "deploy");
});
}
+18
View File
@@ -30,6 +30,24 @@ fn search_filters_the_tree(cx: &mut TestAppContext) {
.unwrap();
}
#[gpui_kit::test]
fn search_finds_a_group_by_name_with_all_of_it(cx: &mut TestAppContext) {
let (handle, _) = open_workspace(cx);
in_frame(cx, handle, |window, cx| {
window.click("host-search", cx);
window.input("生产", cx);
});
in_frame(cx, handle, |window, _| {
let tree = window.within("host-tree");
assert!(tree.try_find(("group-row", PRODUCTION)).is_some());
for host in [WEB_01, WEB_02, DB_01] {
assert!(tree.try_find(("host-row", host)).is_some(), "{host}");
}
assert!(tree.try_find(("host-row", STAGING_API)).is_none());
assert!(tree.try_find(("group-row", DEVELOPMENT)).is_none());
});
}
#[gpui_kit::test]
fn group_expansion_survives_reopening_the_database(cx: &mut TestAppContext) {
let dir = tempfile::tempdir().unwrap();
+2
View File
@@ -7,6 +7,7 @@
mod support;
mod cli;
mod connection;
mod credential;
mod docker;
@@ -21,6 +22,7 @@ mod processes;
mod services;
mod settings;
mod sftp;
mod shortcuts;
mod snippets;
mod terminal;
mod transfer;
+331 -2
View File
@@ -79,6 +79,8 @@ fn settings_open_from_the_host_list_as_one_tab(cx: &mut TestAppContext) {
window.render_frame(cx);
assert!(window.find("settings").visible());
assert_eq!(window.find("settings").focused(), Some(true));
// Back on the category it showed, 终端, not the first one.
assert!(window.find("terminal-font-preview").visible());
})
.unwrap();
cx.update(|cx| {
@@ -116,7 +118,7 @@ fn settings_open_from_the_host_list_as_one_tab(cx: &mut TestAppContext) {
/// The label of one dropdown in the 外观 page's 常规 group. `Settings` names
/// its groups, items and dropdown buttons by position.
/// Open the settings tab on 外部 CLI, the third category.
/// Open the settings tab on 外部 CLI, the fifth category.
fn open_external_cli_settings(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
@@ -126,7 +128,7 @@ fn open_external_cli_settings(cx: &mut TestAppContext, handle: WindowHandle<Root
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.within("settings").click("0-2", cx);
window.within("settings").click("0-4", cx);
})
.unwrap();
cx.run_until_parked();
@@ -317,3 +319,330 @@ fn the_appearance_setting_drives_the_theme_and_the_title_bar_switch(cx: &mut Tes
})
.unwrap();
}
#[gpui_kit::test]
fn terminal_themes_follow_the_appearance_and_a_card_chooses_one(cx: &mut TestAppContext) {
use gpui_kit::component::ThemeMode;
use shellrs::terminal::{TerminalColors, TerminalTheme};
let (handle, workspace) = open_workspace(cx);
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
let in_effect = |cx: &mut TestAppContext| cx.update(|cx| TerminalColors::current(cx).key());
let set_appearance = |appearance, cx: &mut TestAppContext| {
cx.update(|cx| {
settings.update(cx, |settings, cx| {
settings.update(|settings| settings.appearance = appearance, cx)
})
});
cx.run_until_parked();
};
let card = |theme: &TerminalTheme| {
gpui_kit::SharedString::from(format!("terminal-theme-{}", theme.key()))
};
let light = TerminalTheme::for_mode(ThemeMode::Light).nth(1).unwrap();
let dark = TerminalTheme::for_mode(ThemeMode::Dark).nth(1).unwrap();
set_appearance(Appearance::Light, cx);
assert_eq!(in_effect(cx), "shellrs-light");
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
// 外观 is the first category; its second group holds both columns.
in_frame(cx, handle, |window, cx| {
window.within("settings").click("0-0", cx)
});
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find("terminal-theme-shellrs-light").checked(),
Some(true)
);
assert_eq!(
window.find("terminal-theme-shellrs-dark").checked(),
Some(true)
);
assert_eq!(window.find(card(light)).checked(), Some(false));
window.click(card(light), cx);
});
cx.update(|cx| {
assert_eq!(
settings.read(cx).settings().terminal_theme.light,
light.key()
)
});
// The light column is the one in effect, so the terminals change now.
// The app stays neutral until 界面跟随主题 is on.
assert_eq!(in_effect(cx), light.key());
let neutral = |cx: &mut TestAppContext| {
cx.update(|cx| {
let name = cx.theme().theme_name().clone();
name == "Default Light" || name == "Default Dark"
})
};
assert!(neutral(cx));
in_frame(cx, handle, |window, cx| {
assert_eq!(window.find(card(light)).checked(), Some(true));
assert_eq!(
window.find("terminal-theme-shellrs-light").checked(),
Some(false)
);
window.click(card(dark), cx);
});
// The dark one waits for the dark appearance.
cx.update(|cx| assert_eq!(settings.read(cx).settings().terminal_theme.dark, dark.key()));
assert_eq!(in_effect(cx), light.key());
// 主题 is the second group, the switch its first item.
let switch = |window: &mut gpui_kit::Window, cx: &mut App| {
window
.within("settings")
.within("group-1")
.within("item-0")
.click("check", cx)
};
in_frame(cx, handle, |window, cx| {
let on = window
.within("settings")
.within("group-1")
.within("item-0")
.find("check")
.checked();
assert_eq!(on, Some(false));
switch(window, cx);
});
assert!(cx.update(|cx| settings.read(cx).settings().terminal_theme.app_follows));
// On, the app takes the theme's colors, for either appearance.
let background = |cx: &mut TestAppContext| cx.update(|cx| cx.theme().background.to_rgb());
let near = |a: gpui_kit::Rgba, b: gpui_kit::Rgba| {
(a.r - b.r).abs() + (a.g - b.g).abs() + (a.b - b.b).abs() < 4. / 255.
};
assert!(near(background(cx), light.background().to_rgb()));
set_appearance(Appearance::Dark, cx);
assert_eq!(in_effect(cx), dark.key());
assert!(cx.update(|cx| cx.theme().is_dark()));
assert!(near(background(cx), dark.background().to_rgb()));
set_appearance(Appearance::Light, cx);
assert_eq!(in_effect(cx), light.key());
assert!(near(background(cx), light.background().to_rgb()));
// Off again, the app is neutral and the terminals keep the theme.
in_frame(cx, handle, |window, cx| switch(window, cx));
assert_eq!(in_effect(cx), light.key());
assert!(neutral(cx));
in_frame(cx, handle, |window, cx| switch(window, cx));
// Back on the default, the app is gpui-kit's own light theme again.
in_frame(cx, handle, |window, cx| {
window.click("terminal-theme-shellrs-light", cx)
});
assert_eq!(in_effect(cx), "shellrs-light");
cx.update(|cx| assert_eq!(cx.theme().theme_name().as_ref(), "Default Light"));
}
#[gpui_kit::test]
fn the_window_settings_are_on_until_turned_off(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let window = |cx: &mut TestAppContext| {
cx.update(|cx| workspace.read(cx).settings().read(cx).settings().window)
};
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
// 应用 comes right before 关于.
in_frame(cx, handle, |window, cx| {
window.within("settings").click("0-5", cx)
});
let switch = |item: usize| {
move |window: &mut gpui_kit::Window| {
window
.within("settings")
.within("group-0")
.within(format!("item-{item}"))
.find("check")
.checked()
}
};
in_frame(cx, handle, |window, cx| {
assert_eq!(switch(0)(window), Some(true));
assert_eq!(switch(1)(window), Some(true));
window
.within("settings")
.within("group-0")
.within("item-1")
.click("check", cx);
});
let settings = window(cx);
assert!(settings.remember_size && !settings.remember_position);
}
/// The 关键字高亮 rules, as the settings hold them.
fn highlight_rules(
workspace: &Entity<Workspace>,
cx: &mut TestAppContext,
) -> Vec<shellrs::terminal::HighlightRule> {
cx.update(|cx| {
workspace
.read(cx)
.settings()
.read(cx)
.settings()
.terminal_highlight
.rules
})
}
fn patterns(rules: &[shellrs::terminal::HighlightRule]) -> Vec<&str> {
rules.iter().map(|rule| rule.pattern.as_str()).collect()
}
/// Scroll the settings page down to the rules, below the window's fold.
fn scroll_to_highlight_rules(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
in_frame(cx, handle, |window, cx| {
let position = window.find("highlight-preview").bounds().center();
window.dispatch_event(
gpui_kit::ScrollWheelEvent {
position,
delta: gpui_kit::ScrollDelta::Pixels(point(px(0.), px(-600.))),
modifiers: gpui_kit::Modifiers::default(),
touch_phase: gpui_kit::TouchPhase::Moved,
}
.to_platform_input(),
cx,
);
});
}
#[gpui_kit::test]
async fn highlight_rules_are_edited_in_place_on_their_page(cx: &mut TestAppContext) {
use shellrs::terminal::{HighlightColor, TerminalHighlights};
let (handle, workspace) = open_workspace(cx);
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
// 关键字高亮 comes right after 终端, with the examples a new
// installation starts with: rows 0 to 2.
in_frame(cx, handle, |window, cx| {
window.within("settings").click("0-2", cx)
});
let ip = r"\b\d{1,3}(\.\d{1,3}){3}\b";
assert_eq!(
patterns(&highlight_rules(&workspace, cx)),
["ERROR", "WARN", ip]
);
in_frame(cx, handle, |window, _| {
assert!(window.find(("highlight-rule", 2usize)).visible());
assert!(window.find("highlight-preview").visible());
});
// Off until turned on: the examples color nothing before.
let colored = |cx: &mut TestAppContext| {
cx.update(|cx| !TerminalHighlights::current(cx).spans("ERROR").is_empty())
};
assert!(!colored(cx));
in_frame(cx, handle, |window, cx| {
// 常规 › 启用关键字高亮.
let switch = window
.within("settings")
.within("group-0")
.within("item-0")
.find("check");
assert_eq!(switch.checked(), Some(false));
window
.within("settings")
.within("group-0")
.within("item-0")
.click("check", cx);
});
cx.run_until_parked();
assert!(colored(cx));
// A new row takes the keyboard at once; what does not compile says so
// under it, and the settings have it as typed.
in_frame(cx, handle, |window, cx| {
window.click("add-highlight-rule", cx)
});
scroll_to_highlight_rules(cx, handle);
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find(("highlight-rule-pattern", 3usize)).focused(),
Some(true)
);
window.input("OutOf(Memory", cx);
});
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find(("highlight-rule-error", 3usize)).label(),
Some("正则表达式写法有误")
);
window.press("cmd-a", cx);
window.input(r"OutOfMemory\w*", cx);
});
in_frame(cx, handle, |window, cx| {
assert!(window.try_find(("highlight-rule-error", 3usize)).is_none());
window.click(("highlight-rule-note", 3usize), cx);
window.input("内存溢出", cx);
window.click(("highlight-rule-color", 3usize), cx);
window.press("cmd-a", cx);
window.input("#D946EF", cx);
window.click(("highlight-rule-notify", 3usize), cx);
window.click(("highlight-rule-enabled", 0usize), cx);
});
cx.run_until_parked();
let rules = highlight_rules(&workspace, cx);
assert_eq!(rules[3].pattern, r"OutOfMemory\w*");
assert_eq!(rules[3].note, "内存溢出");
assert_eq!(rules[3].color, HighlightColor::from_hex("#d946ef").unwrap());
assert!(rules[3].enabled && rules[3].notify);
assert!(!rules[0].enabled);
// The color picker opens on its palette. (Its swatches are keyed by
// color; the a11y tree that would catch two of one color is not built
// in tests, see FEATURED_COLORS.)
in_frame(cx, handle, |window, cx| {
window.click(("highlight-rule-picker", 3usize), cx)
});
in_frame(cx, handle, |window, cx| window.press("escape", cx));
// Dragged by its handle, the new row goes first.
in_frame(cx, handle, |window, cx| {
window.drag_to(
("highlight-rule-handle", 3usize),
("highlight-rule", 0usize),
cx,
)
});
cx.run_until_parked();
assert_eq!(
patterns(&highlight_rules(&workspace, cx)),
[r"OutOfMemory\w*", "ERROR", "WARN", ip]
);
// Deleted at once, WARN goes; the terminals follow every change.
in_frame(cx, handle, |window, cx| {
window.click(("delete-highlight-rule", 1usize), cx)
});
cx.run_until_parked();
assert_eq!(
patterns(&highlight_rules(&workspace, cx)),
[r"OutOfMemory\w*", "ERROR", ip]
);
cx.update(|cx| {
let colored = TerminalHighlights::current(cx).spans("OutOfMemoryError ERROR 10.0.0.1");
// ERROR is off; the new rule and the address color.
assert_eq!(
colored
.iter()
.map(|(range, _)| range.clone())
.collect::<Vec<_>>(),
[0..16, 23..31]
);
});
// 启用关键字高亮 off, nothing colors.
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
settings.update(cx, |settings, cx| {
settings.update(|settings| settings.terminal_highlight.enabled = false, cx)
});
cx.run_until_parked();
cx.update(|cx| {
assert!(
TerminalHighlights::current(cx)
.spans("OutOfMemoryError")
.is_empty()
);
});
}
+229
View File
@@ -0,0 +1,229 @@
//! 设置 › 键盘快捷键, and the tab keys it lists.
use gpui_kit::Keystroke;
use shellrs::app::{CenterTab, Shortcut};
use shellrs::terminal::RemoteTerminalId;
use crate::support::*;
fn default_keys(id: &str) -> &'static str {
Shortcut::find(id).unwrap().default_keys()
}
/// cmd on macOS, ctrl elsewhere.
fn primary(key: &str) -> String {
let modifier = if cfg!(target_os = "macos") {
"cmd"
} else {
"ctrl"
};
format!("{modifier}-{key}")
}
fn active_tab(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> Option<CenterTab> {
cx.update(|cx| workspace.read(cx).active_tab())
}
fn terminal(id: u64) -> Option<CenterTab> {
Some(CenterTab::Terminal(RemoteTerminalId(id)))
}
/// Open 设置 › 键盘快捷键, the fourth category.
fn open_shortcuts(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
in_frame(cx, handle, |window, cx| {
window.within("settings").click("0-3", cx)
});
}
fn overrides(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> serde_json::Value {
cx.update(|cx| {
serde_json::to_value(workspace.read(cx).settings().read(cx).settings().shortcuts).unwrap()
})
}
#[gpui_kit::test]
fn tabs_are_switched_from_the_keyboard(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
in_frame(cx, handle, |window, cx| {
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx)
});
assert_eq!(active_tab(&workspace, cx), terminal(INITIAL_WEB_TERMINAL));
let switch = default_keys("switch-to-tab");
let press = |keys: String, cx: &mut TestAppContext| {
in_frame(cx, handle, |window, cx| window.press(&keys, cx))
};
press(default_keys("next-tab").into(), cx);
assert_eq!(
active_tab(&workspace, cx),
terminal(INITIAL_STAGING_TERMINAL)
);
// Around the end and back.
press(default_keys("next-tab").into(), cx);
assert_eq!(active_tab(&workspace, cx), terminal(INITIAL_WEB_TERMINAL));
press(default_keys("previous-tab").into(), cx);
assert_eq!(
active_tab(&workspace, cx),
terminal(INITIAL_STAGING_TERMINAL)
);
press(switch.into(), cx);
assert_eq!(active_tab(&workspace, cx), terminal(INITIAL_WEB_TERMINAL));
// 9 is the last; past the last, nothing.
press(switch.replace('1', "9"), cx);
assert_eq!(
active_tab(&workspace, cx),
terminal(INITIAL_STAGING_TERMINAL)
);
press(switch.replace('1', "5"), cx);
assert_eq!(
active_tab(&workspace, cx),
terminal(INITIAL_STAGING_TERMINAL)
);
}
#[gpui_kit::test]
fn a_shortcut_takes_the_keys_pressed_for_it(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
open_shortcuts(cx, handle);
in_frame(cx, handle, |window, cx| {
assert!(window.find("shortcut-keys-next-tab").visible());
window.click("shortcut-keys-next-tab", cx);
});
in_frame(cx, handle, |window, cx| {
let recorder = window.find("shortcut-recorder-next-tab");
assert!(recorder.visible());
assert_eq!(recorder.focused(), Some(true));
window.press(&primary("j"), cx);
});
in_frame(cx, handle, |window, _| {
assert!(window.try_find("shortcut-recorder-next-tab").is_none());
});
let pressed = Keystroke::parse(&primary("j")).unwrap().unparse();
assert_eq!(
overrides(&workspace, cx),
serde_json::json!({ "next-tab": pressed })
);
// The new keys move on from the settings tab, the last, to the first;
// the old ones no longer do anything.
assert_eq!(active_tab(&workspace, cx), Some(CenterTab::Settings));
in_frame(cx, handle, |window, cx| window.press(&primary("j"), cx));
assert_eq!(active_tab(&workspace, cx), terminal(INITIAL_WEB_TERMINAL));
in_frame(cx, handle, |window, cx| {
window.press(default_keys("next-tab"), cx)
});
assert_eq!(active_tab(&workspace, cx), terminal(INITIAL_WEB_TERMINAL));
}
#[gpui_kit::test]
fn a_shortcut_turned_off_does_nothing_until_put_back(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
open_shortcuts(cx, handle);
in_frame(cx, handle, |window, cx| {
window.click("shortcut-disable-next-tab", cx)
});
assert_eq!(
overrides(&workspace, cx),
serde_json::json!({ "next-tab": null })
);
in_frame(cx, handle, |window, cx| {
assert_eq!(window.find("shortcut-keys-next-tab").label(), Some("无"));
window.press(default_keys("next-tab"), cx);
});
assert_eq!(active_tab(&workspace, cx), Some(CenterTab::Settings));
in_frame(cx, handle, |window, cx| {
window.click("shortcut-reset-next-tab", cx)
});
assert_eq!(overrides(&workspace, cx), serde_json::json!({}));
in_frame(cx, handle, |window, cx| {
window.press(default_keys("next-tab"), cx)
});
assert_eq!(active_tab(&workspace, cx), terminal(INITIAL_WEB_TERMINAL));
}
#[gpui_kit::test]
fn keys_that_are_taken_or_plain_are_refused_and_escape_gives_up(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
open_shortcuts(cx, handle);
in_frame(cx, handle, |window, cx| {
window.click("shortcut-keys-next-tab", cx)
});
// A plain letter, another shortcut's keys, a text field's: each said
// and refused, still listening. ⌘W does not close the tab meanwhile.
for keys in [
"j".to_string(),
default_keys("new-host").to_string(),
default_keys("close-tab").to_string(),
primary("z"),
] {
in_frame(cx, handle, |window, cx| window.press(&keys, cx));
in_frame(cx, handle, |window, _| {
assert!(
window.find("shortcut-recorder-next-tab").visible(),
"{keys}"
);
});
}
in_frame(cx, handle, |window, cx| {
assert_eq!(window.notifications(cx).len(), 4);
});
assert_eq!(active_tab(&workspace, cx), Some(CenterTab::Settings));
in_frame(cx, handle, |window, cx| window.press("escape", cx));
in_frame(cx, handle, |window, _| {
assert!(window.try_find("shortcut-recorder-next-tab").is_none());
assert!(window.find("shortcut-keys-next-tab").visible());
});
assert_eq!(overrides(&workspace, cx), serde_json::json!({}));
}
#[gpui_kit::test]
fn the_zoom_keys_size_a_terminals_text_and_the_interface_elsewhere(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let terminal_size = |cx: &mut TestAppContext| {
cx.update(|cx| {
workspace
.read(cx)
.settings()
.read(cx)
.settings()
.terminal_font
.size
})
};
let interface_size = |cx: &mut TestAppContext| cx.update(|cx| cx.theme().font_size.as_f32());
in_frame(cx, handle, |window, cx| {
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx)
});
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find(("terminal", INITIAL_WEB_TERMINAL)).focused(),
Some(true)
);
});
let press = |keys: String, cx: &mut TestAppContext| {
in_frame(cx, handle, |window, cx| window.press(&keys, cx))
};
// In a terminal: its text, a pixel a step, kept in the settings.
press(primary("="), cx);
assert_eq!((terminal_size(cx), interface_size(cx)), (14., 16.));
press(primary("-"), cx);
press(primary("-"), cx);
assert_eq!((terminal_size(cx), interface_size(cx)), (12., 16.));
// No smaller than 设置 › 终端 › 字号 goes.
for _ in 0..5 {
press(primary("-"), cx);
}
assert_eq!(terminal_size(cx), 10.);
press(primary("0"), cx);
assert_eq!((terminal_size(cx), interface_size(cx)), (13., 16.));
// Elsewhere, the interface.
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
press(primary("="), cx);
assert_eq!((terminal_size(cx), interface_size(cx)), (13., 18.));
press(primary("0"), cx);
assert_eq!((terminal_size(cx), interface_size(cx)), (13., 16.));
}
+109
View File
@@ -1000,3 +1000,112 @@ async fn terminals_notify_where_the_user_will_see_it(cx: &mut TestAppContext) {
);
});
}
/// Have the settings hold `rules`, as the 关键字高亮 page writes them, with
/// highlighting on.
fn set_highlight_rules(
cx: &mut TestAppContext,
workspace: &Entity<Workspace>,
rules: Vec<shellrs::terminal::HighlightRule>,
) {
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
settings.update(cx, |settings, cx| {
settings.update(
|settings| {
settings.terminal_highlight.enabled = true;
settings.terminal_highlight.rules = rules;
},
cx,
)
});
cx.run_until_parked();
}
#[gpui_kit::test]
async fn a_matching_line_notifies_from_a_terminal_out_of_sight(cx: &mut TestAppContext) {
use shellrs::terminal::HighlightRule;
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_running_local_terminal(cx, factory).await;
// WARN only colors; ERROR tells.
set_highlight_rules(
cx,
&workspace,
vec![
HighlightRule {
pattern: "WARN".into(),
..HighlightRule::default()
},
HighlightRule {
pattern: "ERROR".into(),
notify: true,
..HighlightRule::default()
},
],
);
in_frame(cx, handle, |window, _| window.activate_window());
// Behind a second terminal, a finished line that matches shows in the
// window; neither the WARN line nor the line still being written does.
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
workspace
.read(cx)
.local_terminal(LocalTerminalId(2))
.is_some_and(|terminal| {
terminal.read(cx).status(cx).lifecycle() == &TerminalLifecycle::Running
})
})
.await;
print_into(cx, &workspace, 1, "12:01 WARN low disk\n12:02 ERR");
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
local_screen(&workspace, cx).contains("12:02 ERR")
})
.await;
print_into(cx, &workspace, 1, "OR boom\n");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.notifications(cx).len() == 1
})
.await;
// With the window away, the system's notification names the tab and
// the rule, and has the line.
cx.background_executor
.advance_clock(Duration::from_secs(10));
cx.run_until_parked();
gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window();
print_into(cx, &workspace, 1, "12:03 ERROR again\n");
let shown = system_notifications(cx, 1).await;
assert_eq!(shown[0].tag.as_ref(), "terminal:local:1");
assert_eq!(shown[0].title.as_ref(), "本地终端 1:ERROR");
assert_eq!(shown[0].body.as_ref(), "12:03 ERROR again");
// In sight, a matching line says nothing: had it, the next one would
// be held back as too soon after it.
cx.background_executor
.advance_clock(Duration::from_secs(10));
cx.simulate_system_notification_response(gpui_kit::SystemNotificationResponse {
tag: shown[0].tag.clone(),
action_id: None,
});
cx.run_until_parked();
in_frame(cx, handle, |window, _| window.activate_window());
cx.update(|cx| {
assert_eq!(
workspace.read(cx).active_tab(),
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(1)))
);
});
// The program's notice after it comes once the line has been weighed.
print_into(cx, &workspace, 1, "12:04 ERROR seen\n\x1b]9;done\x07");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.notifications(cx).len() == 1
})
.await;
gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window();
print_into(cx, &workspace, 1, "12:05 ERROR later\n");
let shown = system_notifications(cx, 2).await;
assert_eq!(shown[1].body.as_ref(), "12:05 ERROR later");
}
+9 -1
View File
@@ -226,11 +226,19 @@ fn serve_updates(
}
}
#[gpui_kit::test]
fn the_about_page_opens_the_website(cx: &mut TestAppContext) {
let (handle, _) = open_workspace(cx);
open_about_settings(cx, handle);
in_frame(cx, handle, |window, cx| window.click("open-website", cx));
assert_eq!(cx.opened_url().as_deref(), Some("https://shellrs.com"));
}
/// Open 设置 › 关于.
fn open_about_settings(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
in_frame(cx, handle, |window, cx| {
window.within("settings").click("0-3", cx)
window.within("settings").click("0-6", cx)
});
}