From ee8441e30117ee786d6745a805cbc298fc257575 Mon Sep 17 00:00:00 2001 From: Xuz Date: Fri, 2 Oct 2026 20:09:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=91=BD=E4=BB=A4=E7=89=87=E6=AE=B5=EF=BC=9A?= =?UTF-8?q?=E5=8F=B3=E4=BE=A7=E6=A0=8F=E7=9A=84=E6=96=B0=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=8C=E6=8C=89=E5=88=86=E7=B1=BB=E4=BF=9D=E5=AD=98=E5=B8=B8?= =?UTF-8?q?=E7=94=A8=E5=91=BD=E4=BB=A4=EF=BC=8C=E7=82=B9=E4=B8=80=E4=B8=8B?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=88=96=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 13 +- docs/manual.md | 6 +- src/app/actions.rs | 23 +- src/app/assets.rs | 3 +- src/history/history_panel.rs | 25 +- src/history/model.rs | 2 +- src/host/database.rs | 385 +++++++++++++++++++- src/host/mod.rs | 2 + src/host/snippet.rs | 190 ++++++++++ src/host/store.rs | 35 +- src/host/store/snippets.rs | 319 +++++++++++++++++ src/lib.rs | 3 +- src/shared/command_line.rs | 38 ++ src/shared/mod.rs | 2 + src/snippets/mod.rs | 13 + src/snippets/model.rs | 166 +++++++++ src/snippets/snippet_dialog.rs | 350 ++++++++++++++++++ src/snippets/snippet_panel.rs | 604 ++++++++++++++++++++++++++++++++ src/terminal/engine.rs | 54 ++- src/terminal/terminal_view.rs | 8 +- src/workspace/mod.rs | 1 + src/workspace/snippets.rs | 154 ++++++++ src/workspace/tool_sidebar.rs | 88 ++--- src/workspace/tools.rs | 28 +- src/workspace/workspace_view.rs | 8 + tests/ui/main.rs | 1 + tests/ui/snippets.rs | 346 ++++++++++++++++++ tests/ui/workspace.rs | 31 +- 28 files changed, 2760 insertions(+), 138 deletions(-) create mode 100644 src/host/snippet.rs create mode 100644 src/host/store/snippets.rs create mode 100644 src/shared/command_line.rs create mode 100644 src/snippets/mod.rs create mode 100644 src/snippets/model.rs create mode 100644 src/snippets/snippet_dialog.rs create mode 100644 src/snippets/snippet_panel.rs create mode 100644 src/workspace/snippets.rs create mode 100644 tests/ui/snippets.rs diff --git a/CLAUDE.md b/CLAUDE.md index 48d05ac..c8f1fa0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 -已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份,也是清单里每个包的第二个下载地址),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标,点开是更新对话框(更新内容、重启会中断什么、「重启并安装」,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 `v版本号` 和一个按钮:平时「检查更新」,有事要做时换成「重启并安装…」「下载更新」或「前往下载页」,不并排两个)、「更新渠道」(稳定版 / Beta,运行时切换,默认是这个构建自己的渠道;切换就丢掉已找到、已下好的版本,开着自动升级时立刻查新渠道)、「自动升级」开关;构建号和平台只在版本号的 tooltip 里;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。右侧栏:窗口最右边(标题栏和状态栏之间)一列竖排的工具切换,点一个就在右侧 Dock 里显示这个工具、再点一次收起,⌘⌥B / Ctrl+Alt+B 显示或隐藏(显示上次的工具);**只跟着 SSH 远程终端出现**:当前标签是 SFTP、本地终端、设置或开始页时,右侧栏连同按钮列都不显示(用户要的:看的是每个终端里的信息;本地终端试过显示「本机」,用户让先隐藏、专注远程),⌘⌥B 也不起作用;回到任一远程终端时按原样恢复(开着就还开着、同一个工具),内容对应这个终端的主机;它是**工作区级**的一份,不是每个终端标签各带一份(中间区可以分栏,每个标签一份会重复、挤占终端),开合和所选工具在所有终端间共用;分栏时按「当前标签」(最近显示的那个)判断;默认收起、宽 320 px,拖动只能往宽里拉、不能比 320 px 窄(用户定的,系统监控的卡片按这个宽度排),宽度和 Dock 布局一样不持久化;工具有命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控(按钮列从上到下就是这个顺序,同用户给的参考图),**历史命令**、**Docker**、**系统服务**、**进程管理**、**网络连接**和**系统监控**做好了,命令片段是占位(写着将来显示什么和「即将推出」)。系统监控:系统(主机名、架构、发行版、运行时长;运行时长写成「32 天 23:40:26」,不足一天「23:40:26」,随每次读数刷新,不在两次读数之间自己走(用户定的,别过度设计)、CPU(型号、核数、平均使用率的环和条;多于 1 核时平均使用率下面是一排小竖条,每核一根,鼠标停上去提示「核 12:35.0%」,不管多少核都这一种画法(用户定的;试过两列每核一行编号、条和百分比,64 核要 32 行);默认只显示放得下的第一行(大多数主机一行就够,默认宽度一行 23 根),一行放不下时标题行右端才有「N 核 ⌄」按钮,提示「显示更多核的占用」/「收起更多核的占用」,展开后换行显示全部,默认宽度下 64 核排 3 行)、内存(物理内存,有交换空间时再加一行)、网络(每块网卡的上传 / 下载速率,不列 `lo` 和从没收发过的网卡;默认只显示主网卡(默认路由走的那块,读 `/proc/net/route`,没有就取收发最多的),多于 1 块时标题行右端的「N 个网卡 ⌄」展开全部;两个展开状态在所有终端间共用,不保存)、磁盘(每个挂载点的占用,按 `df` 的算法不含 root 保留空间,不列 tmpfs、devtmpfs、overlay、snap 的 loop 设备和 `/dev` `/run` `/sys` `/proc` 下的挂载)五张卡片;只支持 Linux(读 `/proc` 和 `df -kPl`,只看本地文件系统,失效的网络挂载不会卡住):已探测到不是 Linux 的主机(macOS、Windows、FreeBSD、OpenBSD、NetBSD)**不显示系统监控、网络连接、进程管理和系统服务的图标**(用户要的,比点进去看「暂不支持」好),这几个工具正开着时切到这样的主机,右侧栏收起、别的工具图标还在,切回 Linux 主机原样恢复,在这样的主机上按 ⌘⌥B 显示第一个可用的工具;还没探测出系统的主机照样显示,读不了时写明「暂不支持」兜底;Docker 和历史命令只在已知是 Windows 的主机上不显示(`sh` 能跑 `docker`、读得到 bash 历史文件的地方都行,Mac 上的 Docker Desktop 也是);CPU、内存、网络和运行时长每 2 秒读一次(用户定的,试过 3 秒;第二次在 1 秒后,CPU 和网速要两次读数才有),主机名、架构、发行版和 CPU 型号每次连接只读一次,磁盘每 30 秒一次,其余每次只用 shell 内建命令读几个 `/proc` 文件,除了跑它的 `sh` 不在主机上起别的进程;只在它显示时读,隐藏、换工具、切到别的标签就停;用量 ≥ 70% 黄、≥ 90% 红,其余绿。网络连接是右侧栏里单独的一个工具,不是系统监控里的一张卡片(试过卡片,用户要独立工具,并给了参考图):顶上一行「共 96 条 · 15 个监听端口 · 21 条已连接」加刷新按钮,下面是搜索框(地址、端口、状态、PID、进程、用户,不分大小写)和「全部协议 / TCP / UDP」「全部状态 / 监听 / 已连接 / TIME_WAIT / CLOSE_WAIT / 其他状态」两个下拉;每条一张卡片:角色图标、协议和状态两个标签、右端写「监听端口」「入站连接」(本地端口是本机同协议在监听的)或「出站连接」,然后是本地、远端地址(照 `ss` 的写法),分隔线下是「进程 名称 (PID n)」(几个进程共用时「等 N 个」)和右边的用户;状态里监听、已连接用中文,其余用 TCP 的名字(TIME_WAIT 等),UDP 没连对端的算「监听」;绿是监听、蓝是已连接、黄是 CLOSE_WAIT,其余灰;监听的在前,然后已连接、CLOSE_WAIT、握手中、关闭中,TIME_WAIT 最后,同类按协议、本地端口排;同一地址被几个进程一起监听(nginx 的 worker、SO_REUSEPORT)合成一张卡片。**列表不自己刷新**:打开网络连接、换到别的终端时各读一次,之后只有点刷新才读(列表在读的时候自己挪动没法看;系统监控是仪表,才每 2 秒读);切回来先显示上次的列表、同时重读;终端没连接时每 2 秒看一眼,连上就读。读的是 `ss -tuanpe`,不是 root 时只看得到自己的进程,没有 `ss` 的主机读 `/proc/net`、没有进程,这两种情况顶上都写明;最多列 3000 条,超过时顶上写「连接太多,只列出了前 3000 条。」,计数仍是主机上的全部。进程管理(同样是单独的工具,用户给了参考图):顶上一行「共 137 个进程 · 每 15 秒刷新」加刷新按钮,下面是搜索框(进程名、PID、用户)和「内存」「CPU」两个排序按钮,右端是当前列出的个数;每个进程一张卡片:状态圆点(绿运行中、灰休眠 / 空闲、黄等待 I/O / 已暂停、红僵尸,tooltip 是状态)、进程名(`/proc//stat` 的名字,内核截到 15 字节)、右端「PID n」,第二行「用户 · 启动时间」(本机时区,「2026-09-15 17:53:54」),第三行「内存 474.74 MB · 28.1%」(蓝)和右边「CPU 0.5%」(橙),最下面一条内存占比的进度条;不列内核线程(`PF_KTHREAD`);默认按内存从多到少,点另一个按钮按它从多到少,再点同一个按钮反过来,箭头表示方向;CPU 按 `top` 的算法(一个核 100%,两次读数之间的 CPU 节拍差),所以第一次读数是「—」,2 秒后读第二次,之后每 15 秒一次(参考图上写的),只在显示时读,换终端、刷新都从头来;**点进程**打开详情对话框(用户给了参考图):标题是进程名加状态标签,下面一行「PID 2202 · root」;「进程信息」两列 16 项(PID、父进程「systemd (1)」、用户、状态「休眠 (Ssl)」、启动时间、运行时长、终端(没有写「无」)、优先级(`top` 的 PR,nice 0 是 20)、Nice、CPU 使用率、累计 CPU 时间、常驻内存「468.75 MB (24.0%)」、虚拟内存、线程(参考图没有,补上填满两列)、直接子进程、全部后代进程),运行时长和累计 CPU 时间用和系统监控一样的「N 天 HH:MM:SS」;「完整命令」是打开时才读的 `/proc//cmdline`(列表不带它),右边「复制命令」;底部左边「复制 PID」,右边「结束进程」「强制结束」(参考图写「强制杀死」,统一成右键菜单和确认框的叫法),点了先关掉详情再弹确认框(不叠两层);详情是打开那一刻的列表,不跟着刷新。**右键进程**有「查看详情」「结束进程…」(SIGTERM)和「强制结束进程…」(SIGKILL),都先弹确认框(写明 PID、用户和后果,危险按钮「结束进程」/「强制结束」),结果是通知(「已向 java(PID 2202)发送结束信号」「已强制结束…」或「无法结束…:没有权限…」),1 秒后重读列表;最多列 2500 个进程,超过时顶上写明。系统服务(用户给了参考图):顶上「systemd 249 · 降级运行」(`is-system-running` 是 degraded 时这行是黄字)加刷新按钮,搜索框(服务名、描述),gpui-kit 下划线 `TabBar` 的四个标签「全部 / 运行中 / 已停止 / 失败」,数量是标签的子元素、淡色紧跟文字(数量随搜索变;用户定的:试过和 Docker 一样均分整行的分段控件,「已停止 202」在均分的一段里被缩略成「…」,下划线标签按文字宽度排、放不下时可以左右拖动);列表分「自定义服务」(unit 文件在 `/etc/systemd/system/`)和「系统服务」两组,各带数量,组内按名字(systemd 的顺序,大写在前);每个服务一张卡片:状态圆点(绿运行中、红失败、黄启动或停止中、灰已停止)、名字、描述、运行状态标签和开机启动标签(已启用绿,其余灰),右边是按钮:运行中「停止」(浅红)「重启」,没运行「启动」(浅绿),再一个「…」打开详情(参考图的「…」,我们没做每张卡片的下拉菜单,那些命令在右键菜单和详情里);点卡片也打开详情。「运行中」包括 active / reloading / activating / deactivating,一次性服务的 active (exited) 也算运行中(参考图上 apparmor 就是)。详情对话框:标题是名字加状态标签、下面描述;`TabBar` 的「状态」「日志」两页,状态页两列(加载状态、运行状态「运行中 / 已退出」、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动时间、停止时间(运行中时写「—」,参考图写 n/a)),最后一整行 Unit 文件;日志页是 `journalctl -u` 最近 200 行,第一次切过去才读,滚到最底;底部靠左是按钮:运行中「停止」(danger)「重启」,否则「启动」(primary),再加「禁用开机启动」或「启用开机启动」(只有 enabled / disabled 的 unit 有,static、generated 之类没有)。停止和重启先弹确认框(`ssh.service` / `sshd.service` 的停止特别写明新的连接会连不上),启动和开机启动直接执行(参考图上没有确认,这两样不拿走什么);结果是通知,之后重读列表。**不是 root 时用 `sudo -n`**:云主机常以 ecs-user、ubuntu 之类登录、配了免密码 sudo;sudo 要密码就报「需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo」。列表不自己刷新(服务很少变,一次读数起好几个 `systemctl`):打开、换终端、刷新、执行命令之后各读一次。Docker(用户给了参考图):顶上「Docker 26.0.0 · Compose v2.25.0」加刷新按钮,下面 `count_tabs` 的「容器 / 卷 / 镜像 / 网络」带数量;容器页按 compose 项目(`com.docker.compose.project` 标签)成卡片:折叠箭头、圆点(全在跑绿、部分黄、都停灰)、项目名、「运行中/总数」标签、项目目录(`…project.working_dir`),右边停止或启动、重启(对项目里全部容器,用容器 ID 直接 `docker stop/start/restart`,不靠 compose 文件);有容器在跑的项目默认展开、都停了的默认折叠,点项目那一行切换(记在面板里,不保存);展开后每个容器一行:圆点、名字、端口(`0.0.0.0:` 省掉,`->` 写成 `→`)或没有端口时的镜像,右边停止或启动、重启、「…」(详情);没有项目的容器在「独立容器」小标题下,同样的行。卷 / 镜像 / 网络页每项一张卡片:名字、一行说明(卷:驱动 · 挂载点;镜像:ID · 大小 · 创建时间,没名字的写「未命名镜像」排最后;网络:驱动 · 范围)、标签(使用中 / 未使用、N 个容器、内置),右边删除按钮,有容器在用或是 bridge / host / none 时禁用(tooltip 说为什么);点卡片打开它的详情(用户要的):标题是名字加卡片上那个标签、下面「种类 · 那行说明」,正文是 `docker volume|image|network inspect` 排成的区块(卷:基础信息、使用它的容器(来自列表,inspect 不说)、选项、标签;镜像:基础信息(完整 ID、标签、摘要、创建时间、大小、平台、层数、作者)、使用它的容器、运行配置、环境变量、标签;网络:基础信息、子网(「网关 …」)、容器(inspect 只列运行中的,带地址)、选项、标签),没有的写「—」,底部只有「删除」(不能删时禁用、tooltip 说为什么),点了先关详情再确认。点容器打开详情对话框:标题是容器名加状态标签、下面镜像(参考图标题写「容器详情」,统一成进程、服务详情的样子),「详情」「日志」两页;详情按参考图分「基础信息(名称、ID、镜像、创建时间、入口、命令)」「端口映射」「挂载」「环境变量(等宽、可滚动)」「标签」,没有的写「—」;日志是 `docker logs --tail 200 --timestamps`,第一次切过去才读;底部停止或启动、重启,已停止的再加「删除」。停止、重启、删除先确认,启动直接执行。**不是 root 又不在 docker 组时用 `sudo -n docker`**。列表不自己刷新,同系统服务。历史命令(用户定的:从主机上 bash 的历史文件 `~/.bash_history` 读,不是 ShellRS 自己记录终端里敲的命令;点一条是「输入到终端、不执行」,另有执行按钮):顶上「共 401 条 · ~/.bash_history」加刷新按钮,下面一行淡色的「这个终端里执行的命令,bash 退出后才会出现在这里。」(bash 退出时才写文件,不说明的话用户会以为刚敲的命令丢了),搜索框(几个词都要有,不分大小写);每条命令一张卡片:等宽的命令(一行,多行命令的换行写成「↵」,长的截断、鼠标停上去看全文)、下面「3 分钟前 · 执行 2 次」(文件里没有时间就只有「执行 2 次」;一个月以前写日期),右边执行按钮;最近的在前,同一条只列一次(排在最近一次的位置,计次数)。点卡片把命令放进终端的输入行、替换已经输入的内容、不执行,终端拿到键盘可以接着改;执行按钮直接执行;右键「输入到终端」「执行」「复制」;终端没连接、正在跑全屏程序时不输入,弹通知说明。列表不自己刷新,同网络连接;文件太长时只读最后 512 KB,顶上写明。 +已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份,也是清单里每个包的第二个下载地址),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标,点开是更新对话框(更新内容、重启会中断什么、「重启并安装」,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 `v版本号` 和一个按钮:平时「检查更新」,有事要做时换成「重启并安装…」「下载更新」或「前往下载页」,不并排两个)、「更新渠道」(稳定版 / Beta,运行时切换,默认是这个构建自己的渠道;切换就丢掉已找到、已下好的版本,开着自动升级时立刻查新渠道)、「自动升级」开关;构建号和平台只在版本号的 tooltip 里;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。右侧栏:窗口最右边(标题栏和状态栏之间)一列竖排的工具切换,点一个就在右侧 Dock 里显示这个工具、再点一次收起,⌘⌥B / Ctrl+Alt+B 显示或隐藏(显示上次的工具);**只跟着 SSH 远程终端出现**:当前标签是 SFTP、本地终端、设置或开始页时,右侧栏连同按钮列都不显示(用户要的:看的是每个终端里的信息;本地终端试过显示「本机」,用户让先隐藏、专注远程),⌘⌥B 也不起作用;回到任一远程终端时按原样恢复(开着就还开着、同一个工具),内容对应这个终端的主机;它是**工作区级**的一份,不是每个终端标签各带一份(中间区可以分栏,每个标签一份会重复、挤占终端),开合和所选工具在所有终端间共用;分栏时按「当前标签」(最近显示的那个)判断;默认收起、宽 320 px,拖动只能往宽里拉、不能比 320 px 窄(用户定的,系统监控的卡片按这个宽度排),宽度和 Dock 布局一样不持久化;工具有命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控(按钮列从上到下就是这个顺序,同用户给的参考图),七个工具都做好了。系统监控:系统(主机名、架构、发行版、运行时长;运行时长写成「32 天 23:40:26」,不足一天「23:40:26」,随每次读数刷新,不在两次读数之间自己走(用户定的,别过度设计)、CPU(型号、核数、平均使用率的环和条;多于 1 核时平均使用率下面是一排小竖条,每核一根,鼠标停上去提示「核 12:35.0%」,不管多少核都这一种画法(用户定的;试过两列每核一行编号、条和百分比,64 核要 32 行);默认只显示放得下的第一行(大多数主机一行就够,默认宽度一行 23 根),一行放不下时标题行右端才有「N 核 ⌄」按钮,提示「显示更多核的占用」/「收起更多核的占用」,展开后换行显示全部,默认宽度下 64 核排 3 行)、内存(物理内存,有交换空间时再加一行)、网络(每块网卡的上传 / 下载速率,不列 `lo` 和从没收发过的网卡;默认只显示主网卡(默认路由走的那块,读 `/proc/net/route`,没有就取收发最多的),多于 1 块时标题行右端的「N 个网卡 ⌄」展开全部;两个展开状态在所有终端间共用,不保存)、磁盘(每个挂载点的占用,按 `df` 的算法不含 root 保留空间,不列 tmpfs、devtmpfs、overlay、snap 的 loop 设备和 `/dev` `/run` `/sys` `/proc` 下的挂载)五张卡片;只支持 Linux(读 `/proc` 和 `df -kPl`,只看本地文件系统,失效的网络挂载不会卡住):已探测到不是 Linux 的主机(macOS、Windows、FreeBSD、OpenBSD、NetBSD)**不显示系统监控、网络连接、进程管理和系统服务的图标**(用户要的,比点进去看「暂不支持」好),这几个工具正开着时切到这样的主机,右侧栏收起、别的工具图标还在,切回 Linux 主机原样恢复,在这样的主机上按 ⌘⌥B 显示第一个可用的工具;还没探测出系统的主机照样显示,读不了时写明「暂不支持」兜底;Docker 和历史命令只在已知是 Windows 的主机上不显示(`sh` 能跑 `docker`、读得到 bash 历史文件的地方都行,Mac 上的 Docker Desktop 也是);CPU、内存、网络和运行时长每 2 秒读一次(用户定的,试过 3 秒;第二次在 1 秒后,CPU 和网速要两次读数才有),主机名、架构、发行版和 CPU 型号每次连接只读一次,磁盘每 30 秒一次,其余每次只用 shell 内建命令读几个 `/proc` 文件,除了跑它的 `sh` 不在主机上起别的进程;只在它显示时读,隐藏、换工具、切到别的标签就停;用量 ≥ 70% 黄、≥ 90% 红,其余绿。网络连接是右侧栏里单独的一个工具,不是系统监控里的一张卡片(试过卡片,用户要独立工具,并给了参考图):顶上一行「共 96 条 · 15 个监听端口 · 21 条已连接」加刷新按钮,下面是搜索框(地址、端口、状态、PID、进程、用户,不分大小写)和「全部协议 / TCP / UDP」「全部状态 / 监听 / 已连接 / TIME_WAIT / CLOSE_WAIT / 其他状态」两个下拉;每条一张卡片:角色图标、协议和状态两个标签、右端写「监听端口」「入站连接」(本地端口是本机同协议在监听的)或「出站连接」,然后是本地、远端地址(照 `ss` 的写法),分隔线下是「进程 名称 (PID n)」(几个进程共用时「等 N 个」)和右边的用户;状态里监听、已连接用中文,其余用 TCP 的名字(TIME_WAIT 等),UDP 没连对端的算「监听」;绿是监听、蓝是已连接、黄是 CLOSE_WAIT,其余灰;监听的在前,然后已连接、CLOSE_WAIT、握手中、关闭中,TIME_WAIT 最后,同类按协议、本地端口排;同一地址被几个进程一起监听(nginx 的 worker、SO_REUSEPORT)合成一张卡片。**列表不自己刷新**:打开网络连接、换到别的终端时各读一次,之后只有点刷新才读(列表在读的时候自己挪动没法看;系统监控是仪表,才每 2 秒读);切回来先显示上次的列表、同时重读;终端没连接时每 2 秒看一眼,连上就读。读的是 `ss -tuanpe`,不是 root 时只看得到自己的进程,没有 `ss` 的主机读 `/proc/net`、没有进程,这两种情况顶上都写明;最多列 3000 条,超过时顶上写「连接太多,只列出了前 3000 条。」,计数仍是主机上的全部。进程管理(同样是单独的工具,用户给了参考图):顶上一行「共 137 个进程 · 每 15 秒刷新」加刷新按钮,下面是搜索框(进程名、PID、用户)和「内存」「CPU」两个排序按钮,右端是当前列出的个数;每个进程一张卡片:状态圆点(绿运行中、灰休眠 / 空闲、黄等待 I/O / 已暂停、红僵尸,tooltip 是状态)、进程名(`/proc//stat` 的名字,内核截到 15 字节)、右端「PID n」,第二行「用户 · 启动时间」(本机时区,「2026-09-15 17:53:54」),第三行「内存 474.74 MB · 28.1%」(蓝)和右边「CPU 0.5%」(橙),最下面一条内存占比的进度条;不列内核线程(`PF_KTHREAD`);默认按内存从多到少,点另一个按钮按它从多到少,再点同一个按钮反过来,箭头表示方向;CPU 按 `top` 的算法(一个核 100%,两次读数之间的 CPU 节拍差),所以第一次读数是「—」,2 秒后读第二次,之后每 15 秒一次(参考图上写的),只在显示时读,换终端、刷新都从头来;**点进程**打开详情对话框(用户给了参考图):标题是进程名加状态标签,下面一行「PID 2202 · root」;「进程信息」两列 16 项(PID、父进程「systemd (1)」、用户、状态「休眠 (Ssl)」、启动时间、运行时长、终端(没有写「无」)、优先级(`top` 的 PR,nice 0 是 20)、Nice、CPU 使用率、累计 CPU 时间、常驻内存「468.75 MB (24.0%)」、虚拟内存、线程(参考图没有,补上填满两列)、直接子进程、全部后代进程),运行时长和累计 CPU 时间用和系统监控一样的「N 天 HH:MM:SS」;「完整命令」是打开时才读的 `/proc//cmdline`(列表不带它),右边「复制命令」;底部左边「复制 PID」,右边「结束进程」「强制结束」(参考图写「强制杀死」,统一成右键菜单和确认框的叫法),点了先关掉详情再弹确认框(不叠两层);详情是打开那一刻的列表,不跟着刷新。**右键进程**有「查看详情」「结束进程…」(SIGTERM)和「强制结束进程…」(SIGKILL),都先弹确认框(写明 PID、用户和后果,危险按钮「结束进程」/「强制结束」),结果是通知(「已向 java(PID 2202)发送结束信号」「已强制结束…」或「无法结束…:没有权限…」),1 秒后重读列表;最多列 2500 个进程,超过时顶上写明。系统服务(用户给了参考图):顶上「systemd 249 · 降级运行」(`is-system-running` 是 degraded 时这行是黄字)加刷新按钮,搜索框(服务名、描述),gpui-kit 下划线 `TabBar` 的四个标签「全部 / 运行中 / 已停止 / 失败」,数量是标签的子元素、淡色紧跟文字(数量随搜索变;用户定的:试过和 Docker 一样均分整行的分段控件,「已停止 202」在均分的一段里被缩略成「…」,下划线标签按文字宽度排、放不下时可以左右拖动);列表分「自定义服务」(unit 文件在 `/etc/systemd/system/`)和「系统服务」两组,各带数量,组内按名字(systemd 的顺序,大写在前);每个服务一张卡片:状态圆点(绿运行中、红失败、黄启动或停止中、灰已停止)、名字、描述、运行状态标签和开机启动标签(已启用绿,其余灰),右边是按钮:运行中「停止」(浅红)「重启」,没运行「启动」(浅绿),再一个「…」打开详情(参考图的「…」,我们没做每张卡片的下拉菜单,那些命令在右键菜单和详情里);点卡片也打开详情。「运行中」包括 active / reloading / activating / deactivating,一次性服务的 active (exited) 也算运行中(参考图上 apparmor 就是)。详情对话框:标题是名字加状态标签、下面描述;`TabBar` 的「状态」「日志」两页,状态页两列(加载状态、运行状态「运行中 / 已退出」、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动时间、停止时间(运行中时写「—」,参考图写 n/a)),最后一整行 Unit 文件;日志页是 `journalctl -u` 最近 200 行,第一次切过去才读,滚到最底;底部靠左是按钮:运行中「停止」(danger)「重启」,否则「启动」(primary),再加「禁用开机启动」或「启用开机启动」(只有 enabled / disabled 的 unit 有,static、generated 之类没有)。停止和重启先弹确认框(`ssh.service` / `sshd.service` 的停止特别写明新的连接会连不上),启动和开机启动直接执行(参考图上没有确认,这两样不拿走什么);结果是通知,之后重读列表。**不是 root 时用 `sudo -n`**:云主机常以 ecs-user、ubuntu 之类登录、配了免密码 sudo;sudo 要密码就报「需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo」。列表不自己刷新(服务很少变,一次读数起好几个 `systemctl`):打开、换终端、刷新、执行命令之后各读一次。Docker(用户给了参考图):顶上「Docker 26.0.0 · Compose v2.25.0」加刷新按钮,下面 `count_tabs` 的「容器 / 卷 / 镜像 / 网络」带数量;容器页按 compose 项目(`com.docker.compose.project` 标签)成卡片:折叠箭头、圆点(全在跑绿、部分黄、都停灰)、项目名、「运行中/总数」标签、项目目录(`…project.working_dir`),右边停止或启动、重启(对项目里全部容器,用容器 ID 直接 `docker stop/start/restart`,不靠 compose 文件);有容器在跑的项目默认展开、都停了的默认折叠,点项目那一行切换(记在面板里,不保存);展开后每个容器一行:圆点、名字、端口(`0.0.0.0:` 省掉,`->` 写成 `→`)或没有端口时的镜像,右边停止或启动、重启、「…」(详情);没有项目的容器在「独立容器」小标题下,同样的行。卷 / 镜像 / 网络页每项一张卡片:名字、一行说明(卷:驱动 · 挂载点;镜像:ID · 大小 · 创建时间,没名字的写「未命名镜像」排最后;网络:驱动 · 范围)、标签(使用中 / 未使用、N 个容器、内置),右边删除按钮,有容器在用或是 bridge / host / none 时禁用(tooltip 说为什么);点卡片打开它的详情(用户要的):标题是名字加卡片上那个标签、下面「种类 · 那行说明」,正文是 `docker volume|image|network inspect` 排成的区块(卷:基础信息、使用它的容器(来自列表,inspect 不说)、选项、标签;镜像:基础信息(完整 ID、标签、摘要、创建时间、大小、平台、层数、作者)、使用它的容器、运行配置、环境变量、标签;网络:基础信息、子网(「网关 …」)、容器(inspect 只列运行中的,带地址)、选项、标签),没有的写「—」,底部只有「删除」(不能删时禁用、tooltip 说为什么),点了先关详情再确认。点容器打开详情对话框:标题是容器名加状态标签、下面镜像(参考图标题写「容器详情」,统一成进程、服务详情的样子),「详情」「日志」两页;详情按参考图分「基础信息(名称、ID、镜像、创建时间、入口、命令)」「端口映射」「挂载」「环境变量(等宽、可滚动)」「标签」,没有的写「—」;日志是 `docker logs --tail 200 --timestamps`,第一次切过去才读;底部停止或启动、重启,已停止的再加「删除」。停止、重启、删除先确认,启动直接执行。**不是 root 又不在 docker 组时用 `sudo -n docker`**。列表不自己刷新,同系统服务。历史命令(用户定的:从主机上 bash 的历史文件 `~/.bash_history` 读,不是 ShellRS 自己记录终端里敲的命令;点一条是「输入到终端、不执行」,另有执行按钮):顶上「共 401 条 · ~/.bash_history」加刷新按钮,下面一行淡色的「这个终端里执行的命令,bash 退出后才会出现在这里。」(bash 退出时才写文件,不说明的话用户会以为刚敲的命令丢了),搜索框(几个词都要有,不分大小写);每条命令一张卡片:等宽的命令(一行,多行命令的换行写成「↵」,长的截断、鼠标停上去看全文)、下面「3 分钟前 · 执行 2 次」(文件里没有时间就只有「执行 2 次」;一个月以前写日期),右边执行按钮;最近的在前,同一条只列一次(排在最近一次的位置,计次数)。点卡片把命令放进终端的输入行、替换已经输入的内容、不执行,终端拿到键盘可以接着改;执行按钮直接执行;右键「输入到终端」「执行」「复制」;终端没连接、正在跑全屏程序时不输入,弹通知说明。列表不自己刷新,同网络连接;文件太长时只读最后 512 KB,顶上写明。命令片段(用户定的:所有主机共用,不绑定分组或主机,但库里预留了「适用范围」的列;一层分类加搜索):顶上「共 12 个片段 · 3 个分类」和「新建分类」「新建片段」两个按钮,搜索框(名称和命令,几个词都要有,不分大小写);按分类分组,分类按名称、组内按名称(不分大小写),没分类的在最后的「未分类」下,一个分类都没有时不画分类标题;新建的空分类也显示(「部署 0」);点分类标题折叠 / 展开(记在面板里,不保存),搜索时只列有匹配的分类、全部展开;每个片段一张卡片:名称、下面一行等宽的命令(多行命令的换行写成「↵」,长的鼠标停上去看全文),右边的执行按钮平时不显示、鼠标移到卡片上才出现(用户要的:一看就知道点了会怎样):点击时自动执行的是黄色闪电(提示「点击时自动执行」),只输入的是 ▶(提示「执行」),两种点了都执行;按钮的位置一直留着(`invisible` 加卡片的 `group_hover`),出现时别的不挪;点卡片输入到终端、执行按钮直接执行,和历史命令一样,勾了「点击时自动执行」的片段点卡片也直接执行(用户要的设置);右键片段「输入到终端」「执行」「复制命令」「编辑…」「删除…」,分类标题「新建片段…」(预选这个分类)「重命名分类…」「删除分类…」,空白处「新建片段…」「新建分类…」;一个片段都没有时是「还没有命令片段」和「新建片段」按钮。片段对话框是名称、分类(下拉,「未分类」在最前)、命令(多行、等宽)和勾选框「点击时自动执行」(下面的说明「启用后将自动执行命令,否则只将内容填入终端」,新建时默认勾上,用户定的),打开时名称就有键盘;分类对话框只有名称,不许重名;删除分类连同里面的片段一起删,确认框写明个数。不读主机,所以 Windows 主机上也有;Windows 的 shell 不认 Ctrl-E / Ctrl-U,那里点片段不替换已经输入的内容,接在后面输入。 做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/.md` 为准。 @@ -42,7 +42,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。 -- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 +- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 - `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。 - `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`(`Latency` / `LatencyLevel` 在 `connection.rs`,画法是 `shared::LatencyLabel`)。SFTP 标签同样显示:工作线程另起一个 `measure_latency` 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 `SftpEvent::Latency`;`ExplorerPanel` 在 `Connecting` / `Disconnected` 时清掉,只在已连接时由 `title_suffix` 显示。这个任务只在一次 ping 期间攥着 `Arc`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「SFTP 空闲时也要发现断网」的约束)。 - **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 @@ -58,6 +58,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。 - `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。 - `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`、`ExecTarget`),不依赖 `workspace`。 +- `snippets/` — 命令片段,右侧栏的第七个真工具,片段本身在 `HostStore` 里(`host::Snippet`),这里是列表和对话框:`model.rs`(`rows`:分类标题和片段排成列表的行,纯函数)、`snippet_panel.rs`(`SnippetPanel`:搜索框、gpui `list` 画的分类标题和卡片、挂在列表上的右键菜单,菜单在打开时读 store)、`snippet_dialog.rs`(`open_snippet_dialog(SnippetDialog::{New(分类), Edit(id)}, ..)` 和 `open_category_dialog(CategoryDialog::{New, Rename(id)}, ..)`)。依赖 `host` 和 `shared`,不依赖 `terminal`(输入到终端是工作区处理 `EnterCommand`)。 - `history/` — 历史命令,右侧栏的第六个真工具:`bash.rs`(读 `~/.bash_history` 的命令 `command()` 和解析 `parse`)、`model.rs`(`History::new` 去重、按最近排、计次数,`matching` 搜索,`format_when` 写「3 分钟前」,都是纯函数)、`history_panel.rs`(`HistoryPanel`:读取、搜索框、`uniform_list` 画的卡片和挂在列表上的右键菜单)。依赖 `terminal`(读取用 `TerminalView::exec`,把命令放进输入行是工作区经 `TerminalView::enter_command` 做的),不依赖 `workspace`。 - `docker/` — Docker,右侧栏的第五个真工具:`linux.rs`(读数的命令 `command()` 和解析 `parse`(每行一个 JSON,`serde_json`),`valid_id`,容器命令 `control_command`、删除的 `remove_command`、详情的 `inspect_command` / `parse_details`、日志的 `logs_command`,结果的 `done`)、`model.rs`(`Container` / `Volume` / `Image` / `Network`,`DockerTable::new` 按项目分组、标出哪些卷 / 镜像 / 网络有容器在用,`rows` 按标签页和折叠排出行,都是纯函数)、`docker_panel.rs`(`DockerPanel`:读取、`count_tabs`、gpui `list` 画的项目卡片、容器行和卷 / 镜像 / 网络卡片)、`container_details.rs`(`open_container_dialog`:详情对话框,正文 `ContainerDetailsView` 打开时 `docker inspect`、切到日志页时读日志)、`object_details.rs`(`open_object_dialog`:卷、镜像、网络的详情对话框,正文 `ObjectDetailsView`)、`details.rs`(两种对话框共用的 `Read`、在终端连接上执行的 `run`,以及把 `DetailSection` 画成标题加内容的 `render_sections`)。卷、镜像、网络的卡片和详情都从 `ObjectSummary`(`Volume::summary` 等:名字、一行说明、标签及其 `Tone`、能不能删、被哪些容器用着)画,`DockerTable::new` 记下的是用着它们的容器名(`used_by`),不只是有没有。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区。 - `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、下划线 `TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。 @@ -114,11 +115,11 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。 - **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。 - **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。 -- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`、`NetstatPanel`、`ProcessPanel`、`ServicePanel`、`DockerPanel`、`HistoryPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它);没做的工具画占位。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。 +- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`、`NetstatPanel`、`ProcessPanel`、`ServicePanel`、`DockerPanel`、`HistoryPanel`、`SnippetPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它)。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。 - **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms,`terminal::exec_answer`,几个工具共用;它们的目标是 `terminal::ExecTarget`),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。 - **系统监控(`monitor/`)是纯解析加一个面板。** `linux.rs` 是命令 `command(Parts { system, disks })`(每部分以 `@@名称` 行开头:`SYSTEM` 是 `uname`、`/etc/os-release` 和 CPU 型号,每次连接一次;`LOAD` 每次都有,只用 `read` / `case` / `echo` 这些内建命令读 `/proc/uptime`、`/proc/stat`(读到第一行不是 `cpu` 就停,后面很长的 `intr` 行不读)、`/proc/meminfo`(读到 `SwapFree` 就停)、`/proc/net/dev` 和 `/proc/net/route`(找默认路由的网卡);`DISKS` 是 `df`)和纯函数 `parse`(→ `Parsed::Reading(Box)` 或 `Unsupported(uname -s)`;没有 `/proc/stat` 的读数就算不支持,cmd.exe 什么都不打印也落在这里;`system` 和 `disks` 只在这次读数要了时才有)。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`、把路径换过去,用本机的 `sh` 真跑脚本再解析,守着内建命令读到的和 `cat` / `grep` 一样;`every_command_is_valid_sh` 对四种组合跑 `sh -n`。`model.rs` 是读数 `Reading`(CPU 的 busy / idle 节拍、内存、网卡的累计字节,要了时才有的系统信息和磁盘)、`Tracker`(和上一次读数比出 `Snapshot`:CPU 百分比、每核百分比、网速;第一次、相隔超过 10 秒、计数变小(重启)都不出比率)和格式化(`format_bytes` 按 1024、两位小数,`format_rate`、`format_percent`、`format_uptime`)、`Load::of`(70 / 90 分档)。`MonitorPanel` 按终端 id 存每台的 `Tracker`、最近一次 `Snapshot`、系统信息(连同读它时终端的连接代次 `TerminalView::generation`,重连可能连到另一台机器,代次变了就重读)和磁盘(连同读它的时刻,满 30 秒 `DISK_INTERVAL` 再读),切回来马上有数;每次读数前 `HostState::parts_due` 决定要哪几部分;`set_target(target, active)` 变了就重启读数任务(丢掉任务就停);时间用 `cx.background_executor().now()`,所以测试时钟下比率也对。卡片是 gpui-kit 的 `GroupBox::fill()`,环是 `ProgressCircle`,条是 `Progress`,会变的数字用等宽字体(`numeric`)免得跳动。一行放几根竖条按上一帧量到的宽度算(同路径标签:竖条那一行里的 `canvas` 在 prepaint 记下宽度,变了就 `on_next_frame` 通知面板重画;`bars_per_row` 是纯函数,竖条 0.375 rem、间隔 0.125 rem),还没量到之前那一行限高一行、`overflow_hidden`,不会先闪出几行。展开 / 折叠按钮(`unfold_button`:ghost xsmall,文字后面跟 ⌄ / ⌃,`toggled`)派发 `ToggleMonitorDetail(MonitorDetail::{Cores, Interfaces})`,工作区经 `ToolSidebar::toggle_monitor_detail` 转给 `MonitorPanel::toggle`(一个命令一个处理器,同 SFTP 队列的展开);按钮派发在工作区的 focus handle 上(`MonitorPanel::new(dispatch)`),终端没焦点也能用。主网卡的挑选是纯函数 `Snapshot::main_interface`(`busy_interfaces` 是有过流量、不是 `lo` 的那些)。面板的滚动用 `overflow_y_scrollbar()`(见下面「滚动条挂在不滚动的外层上」)。 - **Docker(`docker/`)一次读出四类对象。** 命令(`linux::command()`)先补 PATH(`/usr/local/bin`、`/opt/homebrew/bin`:Docker Desktop、Homebrew),再定 `$d`:root 或 `docker version` 能连上 daemon(在 docker 组里)就是 `docker`,否则 `sudo -n docker`(所有 Docker 命令都用这一段前缀 `DOCKER`);`@@version` 是 `$d version --format "{{.Server.Version}}" 2>&1`,不以数字开头就是连不上,`complaint` 把「Cannot connect」翻成「Docker 没有运行」、权限问题和 sudo 要密码翻成「没有权限:以 root 登录、把这个用户加入 docker 组…」;其后 `ps -a --no-trunc`、`volume ls`、`images`、`network ls --no-trunc` 都用 `--format "{{json .}}"`,每行一个对象(Docker 1.13 以后都有),按行 `serde_json` 解析,认不出的行跳过。`ps` 的标签是「k=v,k=v」,值里也可能有逗号(compose 的配置文件列表),没有 `=` 的一段接到前一个值上;老 Docker 没有 `State` 字段,从 `Status`(「Up 3 days」)推。卷 / 镜像 / 网络是否在用由 `DockerTable::new` 按容器的 `Mounts`、`Image`(`nginx` 就是 `nginx:latest`;镜像没名字了容器会显示它的 ID)、`Networks` 算。拼进命令的 ID 和名字先过 `valid_id`(字母数字开头,只许字母数字和 `_.:-`),加双引号。`the_script_asks_docker_for_json` 用一个假 `docker` 脚本(按前两个参数回答)真跑读数命令,查模板 `{{json .}}` 原样到了 docker 手里。标签页是 `shared::count_tabs`(见下面「表单里的几选一用 `shared::SegmentedControl`」)。卷 / 镜像 / 网络卡片的名字用 GPUI 自带的中间省略(`overflow_hidden().whitespace_nowrap().text_ellipsis_middle()`,用户要的:「registry.ap-…hello-world:latest」),右边的标签 `flex_shrink_0` 不被挤;镜像的创建时间只写日期,带上时分在 320 px 的卡片里会被截掉。列表同系统服务用 gpui 的 `list`(项目卡片展开后高度变,行里带着展开状态,变了就 `splice`);项目那一行的单击派发 `ToggleDockerProject`,按钮 `stop_propagation`。详情一律是 `DetailSection { id, title, body }` 的列表(`SectionBody::Rows` 标签和值、`List` 一行一个名字、`Text` 等宽文本),`rows()` 把空值写成「—」;容器的 `ContainerDetails::sections()` 也转成它,所以五种详情长得一样,测试 id 都是「{前缀}-{区块}:{标签}」(`container-basics:名称`、`volume-basics:挂载点`、`image-users:<容器>`、`network-subnets:<子网>`),等宽文本是「{前缀}-{区块}」(`container-environment`、`image-environment`)。打开详情是一个动作 `ShowDockerObject { object, id }`(容器也是),工作区按种类开容器的或另外三种的对话框。`ControlContainers { subject, ids, command }` 和 `RemoveDockerObject { object, id, name }` 由工作区处理,执行、通知和之后重读列表走 `tools.rs` 的 `run_on_host(HostCommand { script, outcome, done, failed, then })`,系统服务的命令也用它。 -- **历史命令(`history/`)读 bash 的历史文件,点了往终端的输入行里打字。** 命令(`bash::command()`)是一行 `sh -c '…'`:`~/.bash_history` 读得到就印 `@@size`(`wc -c`)和 `@@history` 加文件最后 `LIMIT`(512 KB)字节(`tail -c`),否则 `@@missing`;两样都没有(没有 `sh`)是不支持。`@@history` 之后原样是文件,命令本身可能以 `@@` 开头,所以只认第一个;文件比 `LIMIT` 长时丢掉第一行(从行中间切开的)。设了 `HISTTIMEFORMAT` 的 bash 在每条命令前写一行「#秒数」,多行命令写成几行:照 bash 自己读回的规则,跟在带时间的命令后面、自己没有时间的行接到它上面,没有时间的文件一行一条(`#` 后面不全是数字的是注释命令,不是时间)。`the_script_reads_the_end_of_the_history_file` 把 `$HOME` 换成临时目录、用本机的 `sh` 真跑。卡片的单击和执行按钮派发 `EnterCommand { command, run }`(按钮 `stop_propagation`),工作区的 `on_enter_command` 对当前远程终端调 `TerminalView::enter_command`,成功就把焦点给终端;引擎的 `encode_command` 先发 Ctrl-E、Ctrl-U(bash、zsh、fish 的 emacs 键:到行尾、删掉前面所有的,替换已经输入的内容),再把命令当粘贴发(bracketed paste 时多行命令留在输入行上),`run` 时再加回车;`ALT_SCREEN`(vim、less)拒绝,没有 bracketed paste 时多行命令只能执行、不能只输入(第一个换行就会执行它)。复制是 `CopyCommand(String)`。 +- **历史命令(`history/`)读 bash 的历史文件,点了往终端的输入行里打字。** 命令(`bash::command()`)是一行 `sh -c '…'`:`~/.bash_history` 读得到就印 `@@size`(`wc -c`)和 `@@history` 加文件最后 `LIMIT`(512 KB)字节(`tail -c`),否则 `@@missing`;两样都没有(没有 `sh`)是不支持。`@@history` 之后原样是文件,命令本身可能以 `@@` 开头,所以只认第一个;文件比 `LIMIT` 长时丢掉第一行(从行中间切开的)。设了 `HISTTIMEFORMAT` 的 bash 在每条命令前写一行「#秒数」,多行命令写成几行:照 bash 自己读回的规则,跟在带时间的命令后面、自己没有时间的行接到它上面,没有时间的文件一行一条(`#` 后面不全是数字的是注释命令,不是时间)。`the_script_reads_the_end_of_the_history_file` 把 `$HOME` 换成临时目录、用本机的 `sh` 真跑。卡片的单击和执行按钮派发 `EnterCommand { command, run }`(按钮 `stop_propagation`),工作区的 `on_enter_command` 对当前远程终端调 `TerminalView::enter_command`,成功就把焦点给终端;引擎的 `encode_command` 先发 Ctrl-E、Ctrl-U(bash、zsh、fish 的 emacs 键:到行尾、删掉前面所有的,替换已经输入的内容;`replace` 为假时不发,工作区对已知是 Windows 的主机这样做),再把命令当粘贴发(bracketed paste 时多行命令留在输入行上),`run` 时再加回车;`ALT_SCREEN`(vim、less)拒绝,没有 bracketed paste 时多行命令只能执行、不能只输入(第一个换行就会执行它)。复制是 `CopyCommand(String)`。 - **系统服务(`services/`)一次 `systemctl show` 读全部服务。** 列表命令(`linux::command()`)先判 `/run/systemd/system` 存不存在(sd_booted 的判法,不在就 `@@unsupported` 加 `uname -s`,Linux 没 systemd 时界面写「这台主机没有使用 systemd」),`@@version`、`@@state` 是 `systemctl --version` 和 `is-system-running`;然后 `list-unit-files` 和 `list-units --all --plain` 的第一列合起来,去掉模板(`*@.service`,只能以实例运行),交给**一次** `systemctl show -p Id -p Description … -- $names`(属性一个 `-p` 一个,systemd 230 以前不认逗号)。别名(`sshd.service`)在 `show` 里的 `Id` 是本名,`ServiceTable::new` 按名字去重;`LoadState=not-found`(被引用但没有 unit 文件)的只在失败时列出;自定义与否看 `FragmentPath` 在不在 `/etc/systemd/system/`。`$(…)` 里的 `case` 分支写成 `(pattern)`:bash 3.2 会把分支裸的 `)` 当成命令替换的结尾(macOS 的 `sh` 就是它,`the_commands_are_valid_sh` 在本机这样失败过);服务名用 `printf "%s\n"` 印,dash 的 `echo` 会解释反斜杠。**拼进命令的服务名先过 `valid_name`**(只许字母、数字和 `:_.@-\`,systemd 自己的规则,且不许连续两个反斜杠),放在双引号里(`\x2d` 这种转义原样留下),不合格就不执行。命令(`control_command`)是 `if test "$(id -u)" = 0; then systemctl <动词> -- "名字"; else sudo -n systemctl …; fi 2>&1; echo @@status $?`,`controlled` 把要密码、要交互认证、不在 sudoers 都翻成「需要 root 权限…」,别的取 systemctl 的第一行(「Job for … failed because …」)。日志是 `journalctl -u … -n 200 --no-pager`,非 root 先试 `sudo -n`、不行就看自己看得到的。`the_script_shows_every_service_but_templates` 用一个假 `systemctl` 脚本(按第一个参数回答)真跑列表命令。列表用 gpui 的 `list`(`ListState`,量每行的高度)而不是 `uniform_list`,因为分组标题和卡片不一样高;`ServicePanel::sync_list` 在行变了时 `splice`、换标签或搜索时 `reset`(回到顶上)。卡片上的按钮 `stop_propagation`,免得连卡片的单击(打开详情)一起触发。`ControlService { name, command }` 由工作区处理:停止、重启走 `confirm_danger`,其余直接执行;详情对话框的按钮同进程详情,先 `close_dialog` 再在工作区的 focus handle 上派发。 - **进程管理(`processes/`)只用 shell 内建命令读 `/proc`。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:`@@uptime`、`@@memory`(`MemTotal`)、`@@clock` / `@@page` 是 `getconf CLK_TCK` / `getconf PAGESIZE`(没有 `getconf` 时按 100 和 4096),`@@users` 读 `/etc/passwd`,然后 `for d in /proc/[0-9]*` 每个进程读一行 `stat`、再读 `status` 读到 `Uid:` 为止,印出「uid stat 行」;进程在列出和读取之间退出时 `read … || continue` 跳过(dash、bash 遇到重定向失败都不退出),整个循环的错误输出丢掉;数到 `LIMIT`(2500,一行约 300 字节,exec 的输出最多 1 MB)就印 `@@more` 停下。`stat` 的名字在括号里、可能带空格和括号,所以 `parse_process` 从最后一个 `)` 往后数字段(第 9 个是 flags,14、15 是 utime、stime,22 是启动时刻,24 是 rss 页数);flags 带 `PF_KTHREAD`(0x200000)的是内核线程,不列。`Tracker` 以「PID + 启动时刻」认进程(PID 会被新进程重用),用主机自己的 uptime 差做分母,uptime 变小(重启、另一台机器)不出比率;终端重连(`TerminalView::generation` 变了)就换一个新的 `Tracker`。启动时间是「本机现在 − uptime + 启动节拍 / 每秒节拍」,按本机时区显示,和主机的时钟差多少就偏多少。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`(含一个内核线程和一个读不到 `stat` 的进程)用本机的 `sh` 真跑。结束进程:右键菜单挂在 `processes-list` 上(同主机树,卡片是 `uniform_list` 在布局之后画的),卡片的右键按下记下 PID(`menu_hit`)并把焦点交给面板,菜单项才有派发路径;菜单派发 `EndProcess { pid, force }`,工作区的 `on_end_process` 从 `ToolSidebar::process` 取名称和用户,用 `shared::confirm_danger` 确认(`confirm_delete` 也是它),确认后在同一个终端上执行 `end_command`(`kill -TERM|-KILL 2>&1; echo @@status $?`,PID 是数字,命令里不拼进程名之类主机给的文字),`ended` 把「Operation not permitted」「No such process」翻成中文,通知结果,再 `refresh_processes_soon`(1 秒后重读)。排序按钮派发 `SortProcesses(ProcessSort)`、刷新派发 `RefreshProcesses`,都经工作区转给面板;搜索框是面板自己的状态。详情:卡片的单击派发 `ShowProcess(pid)`,工作区的 `on_show_process` 从 `ToolSidebar::process_details` 取 `ProcessDetails`(`Snapshot::details`:进程、父进程名、按 ppid 数出的直接子进程和后代,列表里没有父进程时写「PID n」,ppid 0 是「无」),交给 `processes::open_process_dialog`;详情要的 `stat` 字段(父进程、进程组、会话、终端、前台进程组、优先级、nice、线程、虚拟内存)和读数一起解析进 `ProcessInfo`,`ps` 的状态字母(`<` / `N`、`s`、`l`、`+`)和终端名(136–143 是 `pts/n`,4 是 `ttyN` / `ttySN`)在 `parse_process` 里算好;命令行由 `ProcessDetailsView` 打开时在同一个终端上执行 `cat /proc//cmdline`(`command_line_command`),NUL 分隔的参数用空格连起来(`command_line`),空的是僵尸或已退出。对话框的标题和页脚在 `open_dialog` 的构造闭包里,每帧读正文实体(复制按钮按一下变成「已复制」两秒,状态在正文实体里);「结束进程」「强制结束」先 `close_dialog` 再在工作区的 focus handle 上派发 `EndProcess`(关掉对话框后焦点是空的,`window.dispatch_action` 没有落点)。卡片的行数必须一样(同网络连接,`uniform_list` 按第一项的高度排)。 - **网络连接(`netstat/`)读一次,按需刷新。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:先 `export PATH=$PATH:/usr/sbin:/sbin`(Debian 上普通用户的 PATH 里没有 sbin,有的发行版把 `ss` 放在那里),`@@uid` 是 `id -u`(是不是 root),`@@users` 用内建命令读 `/etc/passwd` 得到「uid 名称」,然后有 `ss` 就 `ss -tuanpe | awk …`:awk 跳过表头、只印前 `LIMIT`(3000)行、在 `@@counts` 下按「协议 状态」数**全部**的,所以列表被截断时计数仍然准确(`ss` 先列 UDP 和监听的,截掉的只会是连接);没有 `ss` 就对 `/proc/net/{tcp,tcp6,udp,udp6}` 中存在的每个文件跑一个 awk(`-v p=$f`),印出地址、对端、状态、uid、inode,同样截断和计数。awk 程序按原样写在常量里,由 `double_quoted` 转义成 sh 的双引号串(`$`、`"`);不能用 `!`(csh 会展开,所以写 `$1 == "Netid" { next }` 而不是 `!=`),也不能出现 `\\`(fish 在单引号里把它读成一个反斜杠),`the_command_survives_any_login_shell` 守着。`ss` 省略 `uid:0`,所以有 inode、没有 uid 的是 root 的;`ino:0` 的没有主人(TIME_WAIT),用户留空。进程名可能带空格,所以 `parse_ss` 按位置跳过前 6 个字段再找 `users:((…))`,老版本 `ss` 的 `("name",pid,fd)` 也认。`/proc` 的地址是内存里的 32 位字(小端),IPv6 是四个这样的字,`proc_address` 照 `ss` 的写法输出(端口 0 写 `*`,IPv6 加方括号)。`the_script_reads_ss_or_else_proc` 在临时目录里摆好假的 `/proc/net`、`/etc/passwd` 和一个假 `ss` 脚本,用本机的 `sh` 和 awk 真跑两条路。面板:`set_target` 变了(换终端、显示 / 隐藏、换工具)就重读,之外只有刷新按钮读(派发 `RefreshConnections`,工作区经 `ToolSidebar::refresh_connections` 转给 `NetstatPanel::refresh`);搜索框和两个下拉是面板自己的状态(同左侧列表的搜索,不是动作),改了就滚回顶上。列表用 `uniform_list`,它按第一项的高度排所有项,所以**每张卡片的行数必须一样**(没有进程也画「进程 —」那一行),改卡片时别加只有部分卡片才有的行;滚动条挂在不滚动的 `netstat-list` 上。 @@ -128,7 +129,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **`ListItem` 里会增长的文字要零宽加裁剪。** `ListItem` 把内容包在两层 `w_full` 里、`suffix` 是它们的兄弟,整行靠内容的最小宽度收缩来给 `suffix` 让位。`flex_1().min_w_0()` 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 `suffix`(状态和启停按钮)挤出列表。转发行的文字列因此是 `w_0().flex_grow(1.).overflow_x_hidden()`(`the_forward_dialog_and_a_long_row_fit_the_smallest_window` 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:`RadioGroup::horizontal` 的那一行是 `flex_wrap`,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 `flex_1().min_w_0().self_stretch()`(那一行是 `items_center`,不拉伸的话高度不齐),卡片里那句用途同样是 `w_0().flex_grow(1.).overflow_x_hidden()`,放不下时在卡片内折行。`Radio` 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(`RADIO_DOT_COLUMN`:small 圆点 `size_3p5` 加 `gap_x_2`)拉回到圆点下面,占满卡片的整个宽度。对话框宽 `rems(42.)` 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 `Radio` 的圆点尺寸或间距时 `RADIO_DOT_COLUMN` 要跟着改,`the_three_kinds_of_forward_share_one_row` 查用途这一行的两端离卡片边缘的距离。 - **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。 - **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。 -- **表结构(`SCHEMA_VERSION` 是 13)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件;`STEPS` 目前是空的,`Step` 枚举留着给下一次改表用。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。 +- **表结构(`SCHEMA_VERSION` 是 14)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件。14 加了命令片段的 `snippet_categories` 和 `snippets`(`category_id` 级联;`scope_group_id` / `scope_host_id` 是预留的「适用范围」,都为空就是所有主机,最多一个有值,对 `groups` / `hosts` 级联,内存里 `remove_unnotified` / `remove_group_unnotified` 照样丢掉;最后一列 `run_on_click` 是「点击时自动执行」,在 14 发布之前加进来的,所以没有再升版本),`STEPS` 里是 13 → 14 这一步(`Step::Sql`,步骤是历史,`SCHEMA` 以后再改它也不跟着改),`a_version_13_file_gets_the_snippets_and_keeps_its_hosts` 用冻结的 13 版 schema 守着。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。 - **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path())`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate` 后直接退出。 - 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。 - 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。 @@ -193,4 +194,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-services` / `tool-processes` / `tool-connections` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在)与占位说明 `tool-placeholder`(`aria_label` 是说明文字);当前标签不是远程终端时 `tool-switch` 也不存在。历史命令的有:面板 `history`、汇总 `history-summary`(「共 3 条 · ~/.bash_history」)、`history-refresh`、说明 `history-note`、`history-truncated`、`history-search`、列表 `history-list`、`history-empty`、`history-loading`、问题说明 `history-message`(没连接、没有历史文件、不支持、读取失败)、卡片 `history-entry:<命令>`(`aria_label` 形如「ls -l · 3 分钟前 · 执行 2 次」)与它的执行按钮 `history-run:<命令>`;点了之后查 `FakeTerminalFactory::written_text()` 新增的那一段(「\x05\x15ls -l」,执行的末尾多一个 `\r`)。系统监控的有:面板 `monitor`、展开按钮 `monitor-cores-toggle` / `monitor-interfaces-toggle`(`checked()` 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 `monitor-loading`、问题说明 `monitor-message`(`aria_label` 是文字:没连接、不支持、读取失败)、`monitor-host` / `monitor-arch` / `monitor-os` / `monitor-uptime`(`aria_label` 是值,运行时长形如「62 天 00:39:24」)、`monitor-cpu-usage`(`aria_label` 形如「50.0%」,第一次读数时是「—」)、每核 `monitor-cpu-cores`(多于 1 核就有,`aria_label` 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 `("monitor-core", 序号)`(`sixty_four_cores_show_one_row_until_unfolded` 量它们的位置;收起时一行的个数要等第二帧,测试里先 `simulate_next_frame`)、`monitor-memory-usage` / `monitor-memory-amount`(「421.02 MB / 973.25 MB」)、有交换空间时的 `monitor-swap-usage` / `monitor-swap-amount`、网卡 `monitor-interface:<名称>`(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 `monitor-disk:<挂载点>`(「35.1%,3.09 GB / 9.21 GB」)。网络连接的有:面板 `netstat`、汇总 `netstat-summary`(`aria_label` 形如「共 5 条 · 3 个监听端口 · 1 条已连接」)、`netstat-refresh`、`netstat-search`、下拉 `netstat-protocol` / `netstat-state`(UI 测试不点下拉,筛选由 `model.rs` 的单元测试覆盖)、有搜索或筛选时的 `netstat-matches`(「筛选出 2 条」)、列表 `netstat-list`、一行都没有时的 `netstat-empty`、读到第一次之前的 `netstat-loading`、问题说明 `netstat-message`(没连接、不支持、读取失败)、`netstat-truncated`、看不全进程时的 `netstat-processes`(不是 root,或主机没有 `ss`)、卡片 `netstat-socket:<协议> <本地> <远端>`(如 `netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*`,`aria_label` 形如「TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root」,没有进程或用户的写「—」;列表是 `uniform_list`,只有看得见的卡片在元素树里)。进程管理的有:面板 `processes`、汇总 `processes-summary`(「共 3 个进程 · 每 15 秒刷新」)、`processes-refresh`、`processes-search`、排序按钮 `processes-sort-memory` / `processes-sort-cpu`(`checked()` 是正按它排的那个)、当前列出的个数 `processes-count`(`aria_label` 是数字)、列表 `processes-list`、一个都没有时的 `processes-empty`、`processes-loading`、`processes-message`、`processes-truncated`、卡片 `("process", PID)`(`aria_label` 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 `bounds` 看顺序);结束进程直接派发 `EndProcess { pid, force }`,确认框点 `ok`。详情对话框:正文 `process-details`、每项 `process-field:<标签>`(如 `process-field:父进程`,`aria_label` 是值)、命令行 `process-command`(`aria_label` 是整行)与读取中的 `process-command-reading`、读不到时的 `process-command-unknown`、按钮 `process-copy-command` / `process-copy-pid` / `process-end` / `process-force-end`;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。系统服务的有:面板 `services`、汇总 `services-summary`(「systemd 249 · 降级运行」)、`services-refresh`、`services-search`、标签栏外包的 `services-tabs`(`within("services-tabs").click(3usize)` 是「失败」,`aria_label` 形如「失败 1」)、列表 `services-list`、分组标题 `services-group:custom` / `services-group:system`(`aria_label` 是「自定义服务 1」)、卡片 `service:<名字>`(`aria_label` 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 `service-stop:<名字>` / `service-restart:<名字>` / `service-start:<名字>` / `service-more:<名字>`、`services-empty`、`services-loading`、`services-message`;详情的正文 `service-details`、标签栏 `service-tabs`(`1usize` 是日志)、每项 `service-field:<标签>`、日志 `service-journal`(`aria_label` 是全文)与读取中的 `service-status-reading` / `service-journal-reading`、读不到的 `service-status-unknown` / `service-journal-unknown`、底部按钮 `service-dialog-<动词>`(`stop` / `restart` / `start` / `enable` / `disable`);假终端按顺序回答列表、`@@status 0`、状态、日志。Docker 的有:面板 `docker`、汇总 `docker-summary`(「Docker 26.0.0 · Compose v2.25.0」)、`docker-refresh`、标签栏外包的 `docker-tabs`(序号 0–3 是容器、卷、镜像、网络,`aria_label` 形如「容器 4」)、列表 `docker-list`、项目卡片 `docker-project:<项目>`(`aria_label` 形如「php-56 · 1/1 运行中」)与点了折叠展开的那一行 `docker-project-line:<项目>`、容器行 `docker-container:<名字>`(`aria_label` 形如「php56 · 运行中 · 56000→9000/tcp」)、按钮 `docker-stop:<名字>` / `docker-start:<名字>` / `docker-restart:<名字>`(项目的是 `docker-stop:project:<项目>`)/ `docker-more:<名字>`、卡片 `docker-volume:<名字>` / `docker-image:` / `docker-network:<名字>` 与它们的删除按钮 `<卡片 id>-remove`、`docker-empty`、`docker-loading`、`docker-message`;详情的标签栏 `container-tabs`、正文 `container-details`、各行 `container-basics:<标签>` / `container-ports:<端口>` / `container-mounts:<来源>` / `container-labels:<键>`、`container-environment`、日志 `container-output`、底部 `container-dialog-stop` / `-start` / `-restart` / `-remove`;卷、镜像、网络的详情正文 `docker-details`(读取中 `docker-details-reading`、读不到 `docker-details-unknown`),各行同上换前缀 `volume-` / `image-` / `network-`,底部 `docker-dialog-remove`。假终端 `FakeTerminalFactory::answering(&[输出…])` 依次回答旁路命令(最后一个重复,不看命令内容),`exec_commands()` / `exec_count()` 是执行过的命令,没给输出就丢掉请求、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`(对话框的校验错误,在错误通知里,`aria_label` 是错误文字)、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-services` / `tool-processes` / `tool-connections` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在);当前标签不是远程终端时 `tool-switch` 也不存在。命令片段的有:面板 `snippets`、汇总 `snippets-summary`(「共 4 个片段 · 2 个分类」,没有分类时只有前半)、按钮 `snippets-new` / `snippets-new-category`、`snippets-search`、列表 `snippets-list`、一个片段都没有时的 `snippets-empty` 与其中的 `snippets-empty-new`、搜不到时的 `snippets-no-match`、分类标题 `snippet-category:<分类 id>` 与未分类的 `snippet-category:none`(`aria_label` 形如「Docker 2」,搜索时数的是匹配的),卡片 `("snippet", 片段 id)`(`aria_label` 形如「容器 · docker ps -a」,点击时自动执行的后面再加「 · 点击时自动执行」)与它的执行按钮 `("snippet-run", 片段 id)`(指针不在卡片上时 `visible()` 为假、点不到:先把指针移到卡片上再点,`snippets.rs` 的 `point_at`);对话框的 `snippet-name`、`snippet-category`(包着下拉的 `div`)、`snippet-command`(包着 `Textarea` 的 `div`,点它再输入)、勾选框 `snippet-run-on-click`、分类对话框的 `snippet-category-name`。对话框关掉后焦点是空的,接着 `window.dispatch_action` 之前先点一下面板(`snippets.rs` 的 `dispatch_from_panel`);假终端的 shell 要先跑起来(`remote_lifecycle(..).accepts_input()`),点片段才有输入。历史命令的有:面板 `history`、汇总 `history-summary`(「共 3 条 · ~/.bash_history」)、`history-refresh`、说明 `history-note`、`history-truncated`、`history-search`、列表 `history-list`、`history-empty`、`history-loading`、问题说明 `history-message`(没连接、没有历史文件、不支持、读取失败)、卡片 `history-entry:<命令>`(`aria_label` 形如「ls -l · 3 分钟前 · 执行 2 次」)与它的执行按钮 `history-run:<命令>`;点了之后查 `FakeTerminalFactory::written_text()` 新增的那一段(「\x05\x15ls -l」,执行的末尾多一个 `\r`)。系统监控的有:面板 `monitor`、展开按钮 `monitor-cores-toggle` / `monitor-interfaces-toggle`(`checked()` 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 `monitor-loading`、问题说明 `monitor-message`(`aria_label` 是文字:没连接、不支持、读取失败)、`monitor-host` / `monitor-arch` / `monitor-os` / `monitor-uptime`(`aria_label` 是值,运行时长形如「62 天 00:39:24」)、`monitor-cpu-usage`(`aria_label` 形如「50.0%」,第一次读数时是「—」)、每核 `monitor-cpu-cores`(多于 1 核就有,`aria_label` 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 `("monitor-core", 序号)`(`sixty_four_cores_show_one_row_until_unfolded` 量它们的位置;收起时一行的个数要等第二帧,测试里先 `simulate_next_frame`)、`monitor-memory-usage` / `monitor-memory-amount`(「421.02 MB / 973.25 MB」)、有交换空间时的 `monitor-swap-usage` / `monitor-swap-amount`、网卡 `monitor-interface:<名称>`(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 `monitor-disk:<挂载点>`(「35.1%,3.09 GB / 9.21 GB」)。网络连接的有:面板 `netstat`、汇总 `netstat-summary`(`aria_label` 形如「共 5 条 · 3 个监听端口 · 1 条已连接」)、`netstat-refresh`、`netstat-search`、下拉 `netstat-protocol` / `netstat-state`(UI 测试不点下拉,筛选由 `model.rs` 的单元测试覆盖)、有搜索或筛选时的 `netstat-matches`(「筛选出 2 条」)、列表 `netstat-list`、一行都没有时的 `netstat-empty`、读到第一次之前的 `netstat-loading`、问题说明 `netstat-message`(没连接、不支持、读取失败)、`netstat-truncated`、看不全进程时的 `netstat-processes`(不是 root,或主机没有 `ss`)、卡片 `netstat-socket:<协议> <本地> <远端>`(如 `netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*`,`aria_label` 形如「TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root」,没有进程或用户的写「—」;列表是 `uniform_list`,只有看得见的卡片在元素树里)。进程管理的有:面板 `processes`、汇总 `processes-summary`(「共 3 个进程 · 每 15 秒刷新」)、`processes-refresh`、`processes-search`、排序按钮 `processes-sort-memory` / `processes-sort-cpu`(`checked()` 是正按它排的那个)、当前列出的个数 `processes-count`(`aria_label` 是数字)、列表 `processes-list`、一个都没有时的 `processes-empty`、`processes-loading`、`processes-message`、`processes-truncated`、卡片 `("process", PID)`(`aria_label` 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 `bounds` 看顺序);结束进程直接派发 `EndProcess { pid, force }`,确认框点 `ok`。详情对话框:正文 `process-details`、每项 `process-field:<标签>`(如 `process-field:父进程`,`aria_label` 是值)、命令行 `process-command`(`aria_label` 是整行)与读取中的 `process-command-reading`、读不到时的 `process-command-unknown`、按钮 `process-copy-command` / `process-copy-pid` / `process-end` / `process-force-end`;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。系统服务的有:面板 `services`、汇总 `services-summary`(「systemd 249 · 降级运行」)、`services-refresh`、`services-search`、标签栏外包的 `services-tabs`(`within("services-tabs").click(3usize)` 是「失败」,`aria_label` 形如「失败 1」)、列表 `services-list`、分组标题 `services-group:custom` / `services-group:system`(`aria_label` 是「自定义服务 1」)、卡片 `service:<名字>`(`aria_label` 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 `service-stop:<名字>` / `service-restart:<名字>` / `service-start:<名字>` / `service-more:<名字>`、`services-empty`、`services-loading`、`services-message`;详情的正文 `service-details`、标签栏 `service-tabs`(`1usize` 是日志)、每项 `service-field:<标签>`、日志 `service-journal`(`aria_label` 是全文)与读取中的 `service-status-reading` / `service-journal-reading`、读不到的 `service-status-unknown` / `service-journal-unknown`、底部按钮 `service-dialog-<动词>`(`stop` / `restart` / `start` / `enable` / `disable`);假终端按顺序回答列表、`@@status 0`、状态、日志。Docker 的有:面板 `docker`、汇总 `docker-summary`(「Docker 26.0.0 · Compose v2.25.0」)、`docker-refresh`、标签栏外包的 `docker-tabs`(序号 0–3 是容器、卷、镜像、网络,`aria_label` 形如「容器 4」)、列表 `docker-list`、项目卡片 `docker-project:<项目>`(`aria_label` 形如「php-56 · 1/1 运行中」)与点了折叠展开的那一行 `docker-project-line:<项目>`、容器行 `docker-container:<名字>`(`aria_label` 形如「php56 · 运行中 · 56000→9000/tcp」)、按钮 `docker-stop:<名字>` / `docker-start:<名字>` / `docker-restart:<名字>`(项目的是 `docker-stop:project:<项目>`)/ `docker-more:<名字>`、卡片 `docker-volume:<名字>` / `docker-image:` / `docker-network:<名字>` 与它们的删除按钮 `<卡片 id>-remove`、`docker-empty`、`docker-loading`、`docker-message`;详情的标签栏 `container-tabs`、正文 `container-details`、各行 `container-basics:<标签>` / `container-ports:<端口>` / `container-mounts:<来源>` / `container-labels:<键>`、`container-environment`、日志 `container-output`、底部 `container-dialog-stop` / `-start` / `-restart` / `-remove`;卷、镜像、网络的详情正文 `docker-details`(读取中 `docker-details-reading`、读不到 `docker-details-unknown`),各行同上换前缀 `volume-` / `image-` / `network-`,底部 `docker-dialog-remove`。假终端 `FakeTerminalFactory::answering(&[输出…])` 依次回答旁路命令(最后一个重复,不看命令内容),`exec_commands()` / `exec_count()` 是执行过的命令,没给输出就丢掉请求、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`(对话框的校验错误,在错误通知里,`aria_label` 是错误文字)、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/docs/manual.md b/docs/manual.md index f986e49..d949523 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -137,7 +137,11 @@ shellrs download <远程路径> <本地路径> ## 右侧栏 -当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。目前历史命令、Docker、系统服务、进程管理、网络连接和系统监控可用,命令片段即将推出。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统服务、进程管理、网络连接和系统监控的图标;历史命令和 Docker 除了 Windows 主机都有(Mac 上装了 Docker Desktop 也能用)。 +当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统服务、进程管理、网络连接和系统监控的图标;历史命令和 Docker 除了 Windows 主机都有(Mac 上装了 Docker Desktop 也能用)。 + +**命令片段**保存常用的命令,所有主机共用。点顶部的加号新建片段:名称、分类和命令,命令可以有多行。片段按分类分组显示,没有分类的放在最后的「未分类」下;点分类标题折叠或展开,右键分类标题可以在这个分类里新建片段、重命名或删除分类(分类里的片段会一起删除,确认时写明个数);顶部的文件夹按钮新建分类。可以按名称或命令搜索,输入几个词时列出同时包含这些词的片段。 + +点一个片段就在终端里执行它的命令(片段默认「点击时自动执行」)。鼠标移到片段上时,右边会显示点它会怎样:闪电表示点击就执行,▶ 表示点击只输入。在片段对话框里取消勾选「点击时自动执行」,点它就只把命令放进终端的输入行(替换掉已经输入的内容),可以修改后回车。点右边的闪电或 ▶ 总是直接执行;右键还可以输入到终端、复制命令、编辑和删除。终端里正在运行 vim、less 这类全屏程序时不会输入。Windows 主机上不会替换已经输入的内容,命令接在后面输入。 **历史命令**列出远程主机上 bash 的历史命令(`~/.bash_history`),最近执行的在最前,同一条命令只列一次,下面写着执行过几次;bash 记录了时间(设置了 `HISTTIMEFORMAT`)时还写明最近一次是什么时候,多行的命令也能认出来。可以搜索,输入几个词时列出同时包含这些词的命令,不分大小写。点一条命令把它放进终端的输入行(替换掉已经输入的内容,不执行),可以修改后回车;点右边的执行按钮直接执行;右键还可以复制。终端里正在运行 vim、less 这类全屏程序时不会输入。 diff --git a/src/app/actions.rs b/src/app/actions.rs index db2d865..275f60a 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -7,7 +7,9 @@ use crate::{ cli::AgentKind, docker::{ContainerCommand, DockerObject}, explorer::{ExplorerId, FileSizeFormat}, - host::{CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop}, + host::{ + CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop, SnippetCategoryId, SnippetId, + }, monitor::MonitorDetail, processes::ProcessSort, services::ServiceCommand, @@ -52,6 +54,10 @@ gpui_kit::actions!( RefreshDocker, /// Read the bash history of the SSH terminal's host again. RefreshHistory, + /// Open the new-snippet dialog, for a snippet of no category. + NewSnippet, + /// Open the new-category dialog of 命令片段. + NewSnippetCategory, /// Show the host list in the left dock. ShowHosts, /// Show the port-forwarding list in the left dock. @@ -211,8 +217,21 @@ id_actions! { /// Fold a compose project of the Docker tool away, or unfold it, by /// its name. ToggleDockerProject(String); - /// Copy a command of 历史命令 to the clipboard. + /// Copy a command of 历史命令 or 命令片段 to the clipboard. CopyCommand(String); + /// Open the new-snippet dialog with this category picked. + NewSnippetIn(SnippetCategoryId); + /// Open the edit-snippet dialog. + EditSnippet(SnippetId); + /// Ask for confirmation, then delete a snippet. + DeleteSnippet(SnippetId); + /// Open the rename dialog of a snippet category. + RenameSnippetCategory(SnippetCategoryId); + /// Ask for confirmation, then delete a snippet category with the + /// snippets in it. + DeleteSnippetCategory(SnippetCategoryId); + /// Fold a category of 命令片段 away, or unfold it; `None` is 未分类. + ToggleSnippetCategory(Option); /// Start a port forward over a connection of its own. StartForward(ForwardId); diff --git a/src/app/assets.rs b/src/app/assets.rs index 61d700f..fffb757 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -64,7 +64,8 @@ icon_assets!( CircleCheck, Ban, CircleDot, - FileText + FileText, + Zap ] ); diff --git a/src/history/history_panel.rs b/src/history/history_panel.rs index a5adfc1..84df5cb 100644 --- a/src/history/history_panel.rs +++ b/src/history/history_panel.rs @@ -11,7 +11,6 @@ use gpui_kit::component::{ menu::{ContextMenuExt as _, PopupMenu}, scroll::ScrollableElement as _, spinner::Spinner, - tooltip::Tooltip, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; @@ -20,15 +19,12 @@ use gpui_kit::*; use super::bash::{self, Parsed}; use super::model::{Entry, History}; use crate::app::{CatalogIcon, CopyCommand, EnterCommand, RefreshHistory}; +use crate::shared::command_tooltip; use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer}; /// How long a terminal not connected is left before it is asked again. const RETRY: Duration = Duration::from_secs(2); -/// About as many characters as a command line shows in the panel at its -/// narrowest; a longer one has the whole of it in a tooltip. -const SHOWN_CHARACTERS: usize = 32; - /// 历史命令: the commands bash kept in `~/.bash_history` on the host of the /// SSH terminal in front, the newest first, each once. A click puts one on /// the terminal's input line to edit; 执行 runs it there. @@ -459,7 +455,7 @@ fn render_entry( command: command.clone(), run: true, }; - let long = line.chars().count() > SHOWN_CHARACTERS || command.contains('\n'); + let tooltip = command_tooltip(&command, cx); let mono = cx.theme().mono_font_family.clone(); h_flex() .id(id.clone()) @@ -494,20 +490,7 @@ fn render_entry( } }) // The whole of a command too long for its line. - .when(long, |card| { - let (command, mono) = (command.clone(), mono.clone()); - card.tooltip(move |window, cx| { - let (command, mono) = (command.clone(), mono.clone()); - Tooltip::element(move |_, _| { - div() - .max_w(rems(24.)) - .font_family(mono.clone()) - .text_xs() - .child(command.clone()) - }) - .build(window, cx) - }) - }) + .when_some(tooltip, |card, tooltip| card.tooltip(tooltip)) .child( v_flex() .flex_1() @@ -523,9 +506,9 @@ fn render_entry( ), ) .child( + // As tall as the card's two lines allow: an easy target. Button::new(SharedString::from(format!("history-run:{command}"))) .ghost() - .xsmall() .icon(Icon::new(CatalogIcon::Play)) .tooltip("执行") .accessibility_label("执行") diff --git a/src/history/model.rs b/src/history/model.rs index eaffa49..5291272 100644 --- a/src/history/model.rs +++ b/src/history/model.rs @@ -17,7 +17,7 @@ impl Entry { /// The command on one line: a command of several lines shows its line /// breaks as 「↵」. pub fn one_line(&self) -> String { - self.command.replace('\n', " ↵ ") + crate::shared::one_line(&self.command) } /// 「3 分钟前 · 执行 5 次」, or without a time 「执行 5 次」. diff --git a/src/host/database.rs b/src/host/database.rs index f90b721..aae94a5 100644 --- a/src/host/database.rs +++ b/src/host/database.rs @@ -15,7 +15,8 @@ use rusqlite::{Connection, params}; use super::{ AuthKind, BookmarkSide, Credential, CredentialDraft, CredentialId, CredentialKind, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GroupDraft, GroupId, Host, - HostDraft, HostGroup, HostId, HostOs, ProxyKind, ProxySettings, PublicId, Route, + HostDraft, HostGroup, HostId, HostOs, ProxyKind, ProxySettings, PublicId, Route, Snippet, + SnippetCategory, SnippetCategoryId, SnippetDraft, SnippetId, SnippetScope, }; /// SQLite's only integer type is `i64`, so the `u64` ids cross the boundary @@ -33,8 +34,9 @@ fn from_sql(id: i64) -> u64 { /// schema bumps it and gives `migrate` a step from the version before. /// /// 13 is the schema as it was rebuilt before the first release. Versions up -/// to 12 were development builds, and their files are refused. -const SCHEMA_VERSION: i64 = 13; +/// to 12 were development builds, and their files are refused. 14 adds the +/// command snippets. +const SCHEMA_VERSION: i64 = 14; /// The whole schema, as a new database gets it. /// @@ -66,6 +68,11 @@ const SCHEMA_VERSION: i64 = 13; /// host, the way WinSCP drops a site's bookmarks with the site. /// - **forwards** go with the host they go through. A dynamic forward has /// no target; the other two kinds must have one. +/// - **snippet_categories** are one level of folders for **snippets**, and +/// take their snippets with them. A snippet with neither scope column is +/// for every host, which every snippet is for now; one kept to a group or +/// a host will go with it. `run_on_click` runs it on a click rather than +/// only typing it. /// /// A column added later goes after the table's last column here and is /// added to existing files with `ALTER TABLE ADD COLUMN`, which puts it in @@ -147,11 +154,28 @@ CREATE TABLE forwards ( OR (kind <> 'dynamic' AND target_host IS NOT NULL AND target_port BETWEEN 1 AND 65535)) ) STRICT; -CREATE INDEX forwards_host_id ON forwards(host_id);"; +CREATE INDEX forwards_host_id ON forwards(host_id); +CREATE TABLE snippet_categories ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL CHECK (name <> '') +) STRICT; +CREATE TABLE snippets ( + id INTEGER PRIMARY KEY, + category_id INTEGER REFERENCES snippet_categories(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK (name <> ''), + command TEXT NOT NULL CHECK (command <> ''), + scope_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, + scope_host_id INTEGER REFERENCES hosts(id) ON DELETE CASCADE, + run_on_click INTEGER NOT NULL DEFAULT 0 CHECK (run_on_click IN (0, 1)), + CHECK (scope_group_id IS NULL OR scope_host_id IS NULL) +) STRICT; +CREATE INDEX snippets_category_id ON snippets(category_id); +CREATE INDEX snippets_scope_group_id ON snippets(scope_group_id); +CREATE INDEX snippets_scope_host_id ON snippets(scope_host_id);"; /// One step from a version to the next: one transaction that ends by /// recording the version it reached. -// No step exists yet; the first change to `SCHEMA` adds one. +// No step needs `Code` yet. #[allow(dead_code)] enum Step { /// Statements that make their own transaction. @@ -163,7 +187,35 @@ enum Step { /// The steps from each older version to the next, in order. A file older /// than the first of them, or than `SCHEMA_VERSION` while there are none, /// cannot be opened. -const STEPS: [(i64, Step); 0] = []; +/// +/// A step is history: it says what that version's change was, and stays as +/// written when `SCHEMA` changes again. +const STEPS: [(i64, Step); 1] = [( + 13, + // The command snippets. + Step::Sql( + "BEGIN; +CREATE TABLE snippet_categories ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL CHECK (name <> '') +) STRICT; +CREATE TABLE snippets ( + id INTEGER PRIMARY KEY, + category_id INTEGER REFERENCES snippet_categories(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK (name <> ''), + command TEXT NOT NULL CHECK (command <> ''), + scope_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, + scope_host_id INTEGER REFERENCES hosts(id) ON DELETE CASCADE, + run_on_click INTEGER NOT NULL DEFAULT 0 CHECK (run_on_click IN (0, 1)), + CHECK (scope_group_id IS NULL OR scope_host_id IS NULL) +) STRICT; +CREATE INDEX snippets_category_id ON snippets(category_id); +CREATE INDEX snippets_scope_group_id ON snippets(scope_group_id); +CREATE INDEX snippets_scope_host_id ON snippets(scope_host_id); +PRAGMA user_version = 14; +COMMIT;", + ), +)]; /// Everything one launch reads back from disk. pub struct StoredData { @@ -181,6 +233,10 @@ pub struct StoredData { pub forwards: Vec, /// Credentials in the order the credential list shows them. pub credentials: Vec, + /// Snippet categories in id order. + pub snippet_categories: Vec, + /// Snippets in id order. + pub snippets: Vec, } pub struct HostDatabase { @@ -385,6 +441,47 @@ impl HostDatabase { .filter_map(Result::transpose) .collect::>>()?; + let snippet_categories = self + .connection + .prepare("SELECT id, name FROM snippet_categories ORDER BY id")? + .query_map([], |row| { + Ok(SnippetCategory { + id: SnippetCategoryId(from_sql(row.get(0)?)), + name: row.get::<_, String>(1)?.into(), + }) + })? + .collect::>>()?; + + let snippets = self + .connection + .prepare( + "SELECT id, category_id, name, command, scope_group_id, scope_host_id, \ + run_on_click FROM snippets ORDER BY id", + )? + .query_map([], |row| { + let id: i64 = row.get(0)?; + let category: Option = row.get(1)?; + let name: String = row.get(2)?; + let command: String = row.get(3)?; + let group: Option = row.get(4)?; + let host: Option = row.get(5)?; + let run_on_click: bool = row.get(6)?; + let mut draft = SnippetDraft::new( + name, + command, + category.map(|id| SnippetCategoryId(from_sql(id))), + ) + .with_run_on_click(run_on_click); + // The table's CHECK keeps at most one of the two. + draft.scope = match (group, host) { + (Some(group), _) => SnippetScope::Group(GroupId(from_sql(group))), + (None, Some(host)) => SnippetScope::Host(HostId(from_sql(host))), + (None, None) => SnippetScope::All, + }; + Ok(Snippet::new(SnippetId(from_sql(id)), draft)) + })? + .collect::>>()?; + Ok(StoredData { groups, hosts, @@ -392,6 +489,8 @@ impl HostDatabase { bookmarks, forwards, credentials, + snippet_categories, + snippets, }) } @@ -723,6 +822,73 @@ impl HostDatabase { transaction.commit() } + pub fn insert_snippet_category(&self, category: &SnippetCategory) -> rusqlite::Result<()> { + self.connection.execute( + "INSERT INTO snippet_categories (id, name) VALUES (?1, ?2)", + params![to_sql(category.id.0), category.name.as_ref()], + )?; + Ok(()) + } + + pub fn update_snippet_category(&self, category: &SnippetCategory) -> rusqlite::Result<()> { + self.connection.execute( + "UPDATE snippet_categories SET name = ?2 WHERE id = ?1", + params![to_sql(category.id.0), category.name.as_ref()], + )?; + Ok(()) + } + + /// Delete a category with the snippets in it: the foreign key cascades. + pub fn remove_snippet_category(&self, id: SnippetCategoryId) -> rusqlite::Result<()> { + self.connection.execute( + "DELETE FROM snippet_categories WHERE id = ?1", + params![to_sql(id.0)], + )?; + Ok(()) + } + + pub fn insert_snippet(&self, snippet: &Snippet) -> rusqlite::Result<()> { + let (group, host) = scope_columns(snippet.scope); + self.connection.execute( + "INSERT INTO snippets (id, category_id, name, command, scope_group_id, scope_host_id, \ + run_on_click) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + to_sql(snippet.id.0), + snippet.category.map(|id| to_sql(id.0)), + snippet.name.as_ref(), + snippet.command, + group, + host, + snippet.run_on_click, + ], + )?; + Ok(()) + } + + pub fn update_snippet(&self, snippet: &Snippet) -> rusqlite::Result<()> { + let (group, host) = scope_columns(snippet.scope); + self.connection.execute( + "UPDATE snippets SET category_id = ?2, name = ?3, command = ?4, \ + scope_group_id = ?5, scope_host_id = ?6, run_on_click = ?7 WHERE id = ?1", + params![ + to_sql(snippet.id.0), + snippet.category.map(|id| to_sql(id.0)), + snippet.name.as_ref(), + snippet.command, + group, + host, + snippet.run_on_click, + ], + )?; + Ok(()) + } + + pub fn remove_snippet(&self, id: SnippetId) -> rusqlite::Result<()> { + self.connection + .execute("DELETE FROM snippets WHERE id = ?1", params![to_sql(id.0)])?; + Ok(()) + } + /// Record the operating system a probe found on the host. `None` clears /// it, which is what a failed probe on a rebuilt host leaves behind. pub fn set_host_os(&self, id: HostId, os: Option) -> rusqlite::Result<()> { @@ -846,6 +1012,15 @@ fn migrate(connection: &Connection) -> rusqlite::Result<()> { Ok(()) } +/// A snippet's scope as its two columns: the group's, the host's. +fn scope_columns(scope: SnippetScope) -> (Option, Option) { + match scope { + SnippetScope::All => (None, None), + SnippetScope::Group(group) => (Some(to_sql(group.0)), None), + SnippetScope::Host(host) => (None, Some(to_sql(host.0))), + } +} + /// An error that refuses a file without touching it. fn refusal(message: String) -> rusqlite::Error { rusqlite::Error::SqliteFailure( @@ -1032,6 +1207,204 @@ ALTER TABLE t ADD COLUMN notes TEXT NOT NULL DEFAULT '';", assert_eq!(schema_of(&old), schema_of(&new)); } + /// The schema of version 13, as files of that version have it. + const SCHEMA_13: &str = "\ +CREATE TABLE groups ( + id INTEGER PRIMARY KEY, + parent_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK (name <> ''), + sort_order INTEGER NOT NULL DEFAULT 0, + expanded INTEGER NOT NULL DEFAULT 1 CHECK (expanded IN (0, 1)) +) STRICT; +CREATE INDEX groups_parent_id ON groups(parent_id); +CREATE TABLE credentials ( + id INTEGER PRIMARY KEY, + keychain_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL CHECK (name <> ''), + kind TEXT NOT NULL CHECK (kind IN ('password', 'key', 'agent')), + username TEXT NOT NULL, + key_path TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + CHECK ((kind = 'key') = (key_path IS NOT NULL)) +) STRICT; +CREATE TABLE hosts ( + id INTEGER PRIMARY KEY, + public_id TEXT UNIQUE, + group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL CHECK (name <> ''), + address TEXT NOT NULL CHECK (address <> ''), + port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535), + auth TEXT NOT NULL CHECK (auth IN ('password', 'no-password', 'credential')), + username TEXT, + credential_id INTEGER REFERENCES credentials(id), + route TEXT NOT NULL DEFAULT 'direct' CHECK (route IN ('direct', 'jump', 'proxy')), + proxy_kind TEXT CHECK (proxy_kind IN ('http', 'socks5')), + proxy_host TEXT, + proxy_port INTEGER CHECK (proxy_port BETWEEN 1 AND 65535), + proxy_username TEXT, + notes TEXT NOT NULL DEFAULT '', + os TEXT, + last_connected_at INTEGER, + CHECK ((auth = 'credential') = (credential_id IS NOT NULL)), + CHECK ((auth = 'credential') = (username IS NULL)), + CHECK ((route = 'proxy') = (proxy_kind IS NOT NULL)), + CHECK ((proxy_kind IS NULL) = (proxy_host IS NULL) + AND (proxy_kind IS NULL) = (proxy_port IS NULL)), + CHECK (proxy_kind IS NOT NULL OR proxy_username IS NULL) +) STRICT; +CREATE INDEX hosts_group_id ON hosts(group_id); +CREATE INDEX hosts_credential_id ON hosts(credential_id); +CREATE TABLE host_jumps ( + host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + position INTEGER NOT NULL CHECK (position >= 0), + jump_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL, + PRIMARY KEY (host_id, position), + CHECK (jump_id <> host_id) +) STRICT, WITHOUT ROWID; +CREATE INDEX host_jumps_jump_id ON host_jumps(jump_id); +CREATE TABLE bookmarks ( + host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + side TEXT NOT NULL CHECK (side IN ('local', 'remote')), + path TEXT NOT NULL, + sort_order INTEGER NOT NULL, + PRIMARY KEY (host_id, side, path) +) STRICT, WITHOUT ROWID; +CREATE TABLE forwards ( + id INTEGER PRIMARY KEY, + host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL CHECK (kind IN ('local', 'remote', 'dynamic')), + bind_host TEXT NOT NULL, + bind_port INTEGER NOT NULL CHECK (bind_port BETWEEN 1 AND 65535), + target_host TEXT, + target_port INTEGER, + auto_start INTEGER NOT NULL DEFAULT 0 CHECK (auto_start IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + CHECK ((kind = 'dynamic' AND target_host IS NULL AND target_port IS NULL) + OR (kind <> 'dynamic' AND target_host IS NOT NULL + AND target_port BETWEEN 1 AND 65535)) +) STRICT; +CREATE INDEX forwards_host_id ON forwards(host_id);"; + + #[test] + fn a_version_13_file_gets_the_snippets_and_keeps_its_hosts() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + { + let old = Connection::open(&path).unwrap(); + old.execute_batch(&format!( + "BEGIN;\n{SCHEMA_13}\nPRAGMA user_version = 13;\nCOMMIT;" + )) + .unwrap(); + old.execute( + "INSERT INTO hosts (id, name, address, port, auth, username) \ + VALUES (1, 'web', '10.0.0.1', 22, 'password', 'root')", + [], + ) + .unwrap(); + } + + let db = HostDatabase::open(&path).unwrap(); + let version: i64 = db + .connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + assert!(dir.path().join("shellrs.db.v13.bak").exists()); + let fresh = HostDatabase::in_memory().unwrap(); + assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection)); + let data = db.load().unwrap(); + assert_eq!(data.hosts.len(), 1); + assert!(data.snippets.is_empty()); + } + + fn snippet(id: u64, name: &str, category: Option, scope: SnippetScope) -> Snippet { + let mut draft = SnippetDraft::new( + name, + format!("echo {name}"), + category.map(SnippetCategoryId), + ); + draft.scope = scope; + Snippet::new(SnippetId(id), draft) + } + + #[test] + fn snippets_round_trip_and_go_with_their_category() { + let db = HostDatabase::in_memory().unwrap(); + let docker = SnippetCategory { + id: SnippetCategoryId(1), + name: "Docker".into(), + }; + db.insert_snippet_category(&docker).unwrap(); + let mut ps = snippet(1, "列出容器", Some(1), SnippetScope::All); + ps.command = "docker ps -a\ndocker images".into(); + db.insert_snippet(&ps).unwrap(); + db.insert_snippet(&snippet(2, "磁盘", None, SnippetScope::All)) + .unwrap(); + let data = db.load().unwrap(); + assert_eq!(data.snippet_categories, std::slice::from_ref(&docker)); + assert_eq!(data.snippets[0], ps); + assert_eq!(data.snippets[1].category, None); + + db.update_snippet_category(&SnippetCategory { + name: "容器".into(), + ..docker + }) + .unwrap(); + ps.name = "所有容器".into(); + ps.category = None; + ps.run_on_click = true; + db.update_snippet(&ps).unwrap(); + let data = db.load().unwrap(); + assert_eq!(data.snippet_categories[0].name, "容器"); + assert_eq!(data.snippets[0], ps); + + db.insert_snippet(&snippet(3, "日志", Some(1), SnippetScope::All)) + .unwrap(); + db.remove_snippet_category(SnippetCategoryId(1)).unwrap(); + db.remove_snippet(SnippetId(2)).unwrap(); + let names: Vec<_> = db + .load() + .unwrap() + .snippets + .into_iter() + .map(|snippet| snippet.name) + .collect(); + assert_eq!(names, ["所有容器"]); + } + + #[test] + fn a_snippet_kept_to_a_group_or_a_host_goes_with_it() { + let db = HostDatabase::in_memory().unwrap(); + db.insert_group(&group(1, "生产", None)).unwrap(); + db.insert_group(&group(2, "web", Some(1))).unwrap(); + db.insert_host(&host(1, "web-01", Some(2))).unwrap(); + db.insert_host(&host(2, "db-01", None)).unwrap(); + let scoped = [ + snippet(1, "全部", None, SnippetScope::All), + snippet(2, "分组", None, SnippetScope::Group(GroupId(2))), + snippet(3, "主机", None, SnippetScope::Host(HostId(2))), + snippet(4, "分组里的主机", None, SnippetScope::Host(HostId(1))), + ]; + for snippet in &scoped { + db.insert_snippet(snippet).unwrap(); + } + assert_eq!(db.load().unwrap().snippets, scoped); + + db.remove_host(HostId(2)).unwrap(); + // The group's subgroup and the host in it go, and so do theirs. + db.remove_group(GroupId(1)).unwrap(); + let left: Vec<_> = db + .load() + .unwrap() + .snippets + .into_iter() + .map(|snippet| snippet.id) + .collect(); + assert_eq!(left, [SnippetId(1)]); + } + #[test] fn a_database_from_an_early_development_build_is_refused_untouched() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/host/mod.rs b/src/host/mod.rs index 6c97ee0..9c1c7de 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -12,6 +12,7 @@ mod model; mod outline; mod private_key; mod secret_fields; +mod snippet; mod store; pub use credential::*; @@ -25,4 +26,5 @@ pub use model::*; pub use outline::{HostNode, NodeDrop, group_options, host_tree_items, matches_query}; pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key}; pub use secret_fields::SecretFields; +pub use snippet::*; pub use store::{HostStore, HostStoreEvent}; diff --git a/src/host/snippet.rs b/src/host/snippet.rs new file mode 100644 index 0000000..ecb430c --- /dev/null +++ b/src/host/snippet.rs @@ -0,0 +1,190 @@ +//! Command snippets: the commands 命令片段 keeps, in categories one level +//! deep. They live with the hosts because they are stored in the same +//! database, and a snippet may one day be kept to a group or a host and go +//! with it; the `snippets` module that shows them depends on this one. + +use std::cmp::Ordering; + +use gpui_kit::SharedString; + +use super::{GroupId, HostId}; + +/// Stable identity of a snippet. Never reused within a process. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SnippetId(pub u64); + +/// Stable identity of a snippet category. Never reused within a process. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SnippetCategoryId(pub u64); + +/// Where a snippet is offered. Every snippet is for every host for now; +/// the database has room for one kept to a group (with its subgroups) or +/// to a host, which goes when they do, and nothing sets that yet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum SnippetScope { + #[default] + All, + Group(GroupId), + Host(HostId), +} + +/// What the snippet form edits. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SnippetDraft { + pub name: SharedString, + /// What it types into the terminal, one line or several. + pub command: String, + /// `None` is 未分类. + pub category: Option, + pub scope: SnippetScope, + /// Whether a click on it runs it, rather than only putting it on the + /// terminal's line. + pub run_on_click: bool, +} + +impl SnippetDraft { + pub fn new( + name: impl Into, + command: impl Into, + category: Option, + ) -> Self { + Self { + name: name.into(), + command: command.into(), + category, + scope: SnippetScope::All, + run_on_click: false, + } + } + + pub fn with_run_on_click(mut self, run_on_click: bool) -> Self { + self.run_on_click = run_on_click; + self + } + + /// The draft as the form's fields give it, tidied, or why it cannot be + /// saved. Blank lines and spaces around the command go; inside it, + /// every line stays as written. + pub fn validated( + name: &str, + command: &str, + category: Option, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("请输入名称"); + } + let command = command.trim_end().trim_start_matches(['\n', '\r']); + if command.trim().is_empty() { + return Err("请输入命令"); + } + Ok(Self::new(name.to_owned(), command.to_owned(), category)) + } +} + +/// A saved command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Snippet { + pub id: SnippetId, + pub name: SharedString, + pub command: String, + pub category: Option, + pub scope: SnippetScope, + pub run_on_click: bool, +} + +impl Snippet { + pub fn new(id: SnippetId, draft: SnippetDraft) -> Self { + Self { + id, + name: draft.name, + command: draft.command, + category: draft.category, + scope: draft.scope, + run_on_click: draft.run_on_click, + } + } + + pub fn draft(&self) -> SnippetDraft { + SnippetDraft { + name: self.name.clone(), + command: self.command.clone(), + category: self.category, + scope: self.scope, + run_on_click: self.run_on_click, + } + } + + /// Whether the snippet holds every word of `query` in its name or its + /// command, regardless of case. + pub fn matches(&self, query: &str) -> bool { + let name = self.name.to_lowercase(); + let command = self.command.to_lowercase(); + query.split_whitespace().all(|word| { + let word = word.to_lowercase(); + name.contains(&word) || command.contains(&word) + }) + } +} + +/// A folder of snippets: one level, no nesting. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SnippetCategory { + pub id: SnippetCategoryId, + pub name: SharedString, +} + +/// The order snippets and categories are listed in: by name, regardless of +/// case, then by when they were made. +pub fn by_name(a: (&str, u64), b: (&str, u64)) -> Ordering { + a.0.to_lowercase() + .cmp(&b.0.to_lowercase()) + .then(a.0.cmp(b.0)) + .then(a.1.cmp(&b.1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_snippet_needs_a_name_and_a_command() { + assert_eq!(SnippetDraft::validated(" ", "ls", None), Err("请输入名称")); + assert_eq!( + SnippetDraft::validated("列表", " \n \n", None), + Err("请输入命令") + ); + let draft = SnippetDraft::validated( + " 清理日志 ", + "\n\n find /var/log -mtime +7\n -delete \n\n", + None, + ) + .unwrap(); + assert_eq!(draft.name, "清理日志"); + assert_eq!(draft.command, " find /var/log -mtime +7\n -delete"); + assert_eq!(draft.scope, SnippetScope::All); + } + + #[test] + fn the_search_looks_in_the_name_and_the_command() { + let snippet = Snippet::new( + SnippetId(1), + SnippetDraft::new("查看容器", "docker ps -a\ndocker images", None), + ); + assert!(snippet.matches("")); + assert!(snippet.matches("容器")); + assert!(snippet.matches("DOCKER images")); + assert!(snippet.matches("容器 ps")); + assert!(!snippet.matches("kubectl")); + } + + #[test] + fn names_sort_regardless_of_case() { + let mut names = vec![("nginx", 1), ("Docker", 2), ("apt", 3), ("docker", 4)]; + names.sort_by(|a, b| by_name(*a, *b)); + assert_eq!( + names, + [("apt", 3), ("Docker", 2), ("docker", 4), ("nginx", 1)] + ); + } +} diff --git a/src/host/store.rs b/src/host/store.rs index 945d580..605d0de 100644 --- a/src/host/store.rs +++ b/src/host/store.rs @@ -13,12 +13,14 @@ use super::{ AuthKind, BookmarkSide, ConnectionState, Credential, CredentialDraft, CredentialId, CredentialKind, ForwardDraft, ForwardId, ForwardRule, GroupDraft, GroupId, Host, HostDatabase, HostDraft, HostGroup, HostId, HostLogin, HostNode, HostOs, JumpLogin, LoginRoute, NodeDrop, - ProxyLogin, PublicId, Route, StoredData, + ProxyLogin, PublicId, Route, Snippet, SnippetCategory, SnippetScope, StoredData, }; +mod snippets; + /// The single source of truth for hosts, groups, the rules that belong -/// to hosts (bookmarks, port forwards) and the credentials hosts log in -/// with. Created once by the workspace and shared with every panel and +/// to hosts (bookmarks, port forwards), the credentials hosts log in with +/// and the command snippets. Created once by the workspace and shared with every panel and /// dialog; consumers observe it. /// /// A host using a credential keeps a copy of the credential's user name, @@ -56,6 +58,11 @@ pub struct HostStore { /// Credentials, in the order the credential list shows them. credentials: Vec, next_credential_id: u64, + /// Command snippets and their categories, in the order they were made. + snippets: Vec, + next_snippet_id: u64, + snippet_categories: Vec, + next_snippet_category_id: u64, /// `None` for a memory-only store, as used by tests. database: Option, /// Where passwords go. Defaults to a store that keeps nothing, so unit @@ -101,6 +108,10 @@ impl HostStore { next_forward_id: 1, credentials: Vec::new(), next_credential_id: 1, + snippets: Vec::new(), + next_snippet_id: 1, + snippet_categories: Vec::new(), + next_snippet_category_id: 1, database: None, secrets: Arc::new(NoSecretStore), key_dir: None, @@ -131,6 +142,8 @@ impl HostStore { bookmarks: stored_bookmarks, forwards, credentials, + snippet_categories, + snippets, } = database.load()?; recent.truncate(MAX_RECENT); let mut bookmarks: HashMap<_, Vec> = HashMap::new(); @@ -150,8 +163,17 @@ impl HostStore { next_host_id: hosts.iter().map(|s| s.id.0).max().unwrap_or(0) + 1, next_forward_id: forwards.iter().map(|rule| rule.id.0).max().unwrap_or(0) + 1, next_credential_id: credentials.iter().map(|c| c.id.0).max().unwrap_or(0) + 1, + next_snippet_id: snippets.iter().map(|s| s.id.0).max().unwrap_or(0) + 1, + next_snippet_category_id: snippet_categories + .iter() + .map(|category| category.id.0) + .max() + .unwrap_or(0) + + 1, forwards, credentials, + snippets, + snippet_categories, groups, hosts, active: None, @@ -429,6 +451,8 @@ impl HostStore { // The database drops both through `ON DELETE CASCADE`. self.bookmarks.retain(|(host, _), _| *host != id); self.forwards.retain(|rule| rule.host != id); + self.snippets + .retain(|snippet| snippet.scope != SnippetScope::Host(id)); if self.active == Some(id) { self.active = None; } @@ -1121,6 +1145,11 @@ impl HostStore { let mut doomed = self.descendant_groups(id); doomed.push(id); self.groups.retain(|group| !doomed.contains(&group.id)); + // As the database's `ON DELETE CASCADE` does. + self.snippets.retain(|snippet| match snippet.scope { + SnippetScope::Group(group) => !doomed.contains(&group), + _ => true, + }); for host in &removed { self.remove_unnotified(*host); } diff --git a/src/host/store/snippets.rs b/src/host/store/snippets.rs new file mode 100644 index 0000000..4579149 --- /dev/null +++ b/src/host/store/snippets.rs @@ -0,0 +1,319 @@ +//! The command snippets' part of `HostStore`: the snippets and their +//! categories, written through like everything else. + +use gpui_kit::{Context, SharedString}; + +use super::HostStore; +use crate::host::{ + Snippet, SnippetCategory, SnippetCategoryId, SnippetDraft, SnippetId, SnippetScope, +}; + +impl HostStore { + /// Every snippet, in the order they were made. + pub fn snippets(&self) -> &[Snippet] { + &self.snippets + } + + pub fn snippet(&self, id: SnippetId) -> Option<&Snippet> { + self.snippets.iter().find(|snippet| snippet.id == id) + } + + /// Every snippet category, in the order they were made. + pub fn snippet_categories(&self) -> &[SnippetCategory] { + &self.snippet_categories + } + + pub fn snippet_category(&self, id: SnippetCategoryId) -> Option<&SnippetCategory> { + self.snippet_categories + .iter() + .find(|category| category.id == id) + } + + /// How many snippets are in `category`: what deleting it takes along. + pub fn snippets_in(&self, category: SnippetCategoryId) -> usize { + self.snippets + .iter() + .filter(|snippet| snippet.category == Some(category)) + .count() + } + + pub fn insert_snippet(&mut self, draft: SnippetDraft, cx: &mut Context) -> SnippetId { + let id = self.insert_snippet_unnotified(draft); + if let Some(snippet) = self.snippet(id) { + self.persist("新建命令片段", cx, |db| db.insert_snippet(snippet)); + } + cx.notify(); + id + } + + pub fn insert_snippet_unnotified(&mut self, draft: SnippetDraft) -> SnippetId { + let id = SnippetId(self.next_snippet_id); + self.next_snippet_id += 1; + let draft = self.normalized_snippet(draft); + self.snippets.push(Snippet::new(id, draft)); + id + } + + pub fn update_snippet( + &mut self, + id: SnippetId, + draft: SnippetDraft, + cx: &mut Context, + ) -> bool { + if !self.update_snippet_unnotified(id, draft) { + return false; + } + if let Some(snippet) = self.snippet(id) { + self.persist("保存命令片段", cx, |db| db.update_snippet(snippet)); + } + cx.notify(); + true + } + + pub fn update_snippet_unnotified(&mut self, id: SnippetId, draft: SnippetDraft) -> bool { + let draft = self.normalized_snippet(draft); + let Some(snippet) = self.snippets.iter_mut().find(|snippet| snippet.id == id) else { + return false; + }; + *snippet = Snippet::new(id, draft); + true + } + + pub fn remove_snippet(&mut self, id: SnippetId, cx: &mut Context) -> bool { + if !self.remove_snippet_unnotified(id) { + return false; + } + self.persist("删除命令片段", cx, |db| db.remove_snippet(id)); + cx.notify(); + true + } + + pub fn remove_snippet_unnotified(&mut self, id: SnippetId) -> bool { + let before = self.snippets.len(); + self.snippets.retain(|snippet| snippet.id != id); + self.snippets.len() != before + } + + pub fn insert_snippet_category( + &mut self, + name: impl Into, + cx: &mut Context, + ) -> SnippetCategoryId { + let id = self.insert_snippet_category_unnotified(name); + if let Some(category) = self.snippet_category(id) { + self.persist("新建片段分类", cx, |db| { + db.insert_snippet_category(category) + }); + } + cx.notify(); + id + } + + pub fn insert_snippet_category_unnotified( + &mut self, + name: impl Into, + ) -> SnippetCategoryId { + let id = SnippetCategoryId(self.next_snippet_category_id); + self.next_snippet_category_id += 1; + self.snippet_categories.push(SnippetCategory { + id, + name: name.into(), + }); + id + } + + pub fn rename_snippet_category( + &mut self, + id: SnippetCategoryId, + name: impl Into, + cx: &mut Context, + ) -> bool { + if !self.rename_snippet_category_unnotified(id, name) { + return false; + } + if let Some(category) = self.snippet_category(id) { + self.persist("重命名片段分类", cx, |db| { + db.update_snippet_category(category) + }); + } + cx.notify(); + true + } + + pub fn rename_snippet_category_unnotified( + &mut self, + id: SnippetCategoryId, + name: impl Into, + ) -> bool { + let Some(category) = self + .snippet_categories + .iter_mut() + .find(|category| category.id == id) + else { + return false; + }; + category.name = name.into(); + true + } + + /// Delete a category with the snippets in it. + pub fn remove_snippet_category( + &mut self, + id: SnippetCategoryId, + cx: &mut Context, + ) -> bool { + if !self.remove_snippet_category_unnotified(id) { + return false; + } + // The database takes the snippets through `ON DELETE CASCADE`. + self.persist("删除片段分类", cx, |db| { + db.remove_snippet_category(id) + }); + cx.notify(); + true + } + + pub fn remove_snippet_category_unnotified(&mut self, id: SnippetCategoryId) -> bool { + let before = self.snippet_categories.len(); + self.snippet_categories.retain(|category| category.id != id); + if self.snippet_categories.len() == before { + return false; + } + self.snippets.retain(|snippet| snippet.category != Some(id)); + true + } + + /// A draft that names a category, a group or a host no longer there + /// is saved without it: 未分类, for every host. + fn normalized_snippet(&self, mut draft: SnippetDraft) -> SnippetDraft { + if draft + .category + .is_some_and(|category| self.snippet_category(category).is_none()) + { + draft.category = None; + } + let gone = match draft.scope { + SnippetScope::All => false, + SnippetScope::Group(group) => self.group(group).is_none(), + SnippetScope::Host(host) => self.host(host).is_none(), + }; + if gone { + draft.scope = SnippetScope::All; + } + draft + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host::{AuthKind, GroupDraft, Host, HostDatabase, HostDraft, HostId}; + + fn draft(name: &str, category: Option) -> SnippetDraft { + SnippetDraft::new(name, format!("echo {name}"), category) + } + + fn names(store: &HostStore) -> Vec { + store + .snippets() + .iter() + .map(|snippet| snippet.name.to_string()) + .collect() + } + + #[test] + fn a_category_takes_its_snippets_with_it() { + let mut store = HostStore::empty(); + let docker = store.insert_snippet_category_unnotified("Docker"); + let logs = store.insert_snippet_category_unnotified("日志"); + store.insert_snippet_unnotified(draft("ps", Some(docker))); + store.insert_snippet_unnotified(draft("tail", Some(logs))); + store.insert_snippet_unnotified(draft("df", None)); + assert_eq!(store.snippets_in(docker), 1); + + assert!(store.remove_snippet_category_unnotified(docker)); + assert_eq!(names(&store), ["tail", "df"]); + assert!(!store.remove_snippet_category_unnotified(docker)); + // A draft naming it now goes to 未分类. + let moved = store.insert_snippet_unnotified(draft("images", Some(docker))); + assert_eq!(store.snippet(moved).unwrap().category, None); + } + + #[test] + fn a_snippet_is_edited_in_place_and_removed() { + let mut store = HostStore::empty(); + let ps = store.insert_snippet_unnotified(draft("ps", None)); + let df = store.insert_snippet_unnotified(draft("df", None)); + assert!(store.update_snippet_unnotified(ps, SnippetDraft::new("容器", "docker ps", None))); + assert_eq!(store.snippet(ps).unwrap().command, "docker ps"); + assert_eq!(names(&store), ["容器", "df"]); + assert!(store.remove_snippet_unnotified(df)); + assert!(!store.update_snippet_unnotified(df, draft("df", None))); + assert_eq!(names(&store), ["容器"]); + } + + #[test] + fn a_snippet_kept_to_a_group_or_a_host_goes_with_it() { + let mut store = HostStore::empty(); + let production = store.insert_group_unnotified(GroupDraft::new("生产", None)); + let web = store.insert_group_unnotified(GroupDraft::new("web", Some(production))); + let host = store.insert_unnotified(HostDraft::new( + "db-01", + "10.0.0.2", + 22, + "root", + AuthKind::Password, + None, + )); + let scoped = |name: &str, scope| SnippetDraft { + scope, + ..draft(name, None) + }; + store.insert_snippet_unnotified(scoped("all", SnippetScope::All)); + store.insert_snippet_unnotified(scoped("web", SnippetScope::Group(web))); + store.insert_snippet_unnotified(scoped("db", SnippetScope::Host(host))); + + store.remove_unnotified(host); + assert_eq!(names(&store), ["all", "web"]); + // A subgroup goes with its group, and its snippets with it. + store.remove_group_unnotified(production); + assert_eq!(names(&store), ["all"]); + // Kept to what is not there, a snippet is for every host. + let stray = store.insert_snippet_unnotified(scoped("stray", SnippetScope::Host(host))); + assert_eq!(store.snippet(stray).unwrap().scope, SnippetScope::All); + } + + #[test] + fn load_resumes_the_snippet_id_sequences() { + let database = HostDatabase::in_memory().unwrap(); + database + .insert_host(&Host::new( + HostId(1), + HostDraft::new("web", "10.0.0.1", 22, "root", AuthKind::Password, None), + )) + .unwrap(); + database + .insert_snippet_category(&SnippetCategory { + id: SnippetCategoryId(3), + name: "Docker".into(), + }) + .unwrap(); + database + .insert_snippet(&Snippet::new( + SnippetId(7), + draft("ps", Some(SnippetCategoryId(3))), + )) + .unwrap(); + + let mut store = HostStore::load(database).unwrap(); + assert_eq!(names(&store), ["ps"]); + assert_eq!( + store.insert_snippet_category_unnotified("日志"), + SnippetCategoryId(4) + ); + assert_eq!( + store.insert_snippet_unnotified(draft("df", None)), + SnippetId(8) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 80bf933..f0bc2fc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,7 @@ //! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP //! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential` //! (凭据), `docker` (Docker), `monitor` (系统监控), `services` (系统服务), `processes` (进程管理), -//! `netstat` (网络连接), `history` (历史命令), +//! `netstat` (网络连接), `history` (历史命令), `snippets` (命令片段), //! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与 //! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源). @@ -25,6 +25,7 @@ pub mod services; pub mod settings; pub mod sftp; pub mod shared; +pub mod snippets; pub mod ssh; pub mod terminal; pub mod update; diff --git a/src/shared/command_line.rs b/src/shared/command_line.rs new file mode 100644 index 0000000..2478112 --- /dev/null +++ b/src/shared/command_line.rs @@ -0,0 +1,38 @@ +//! A shell command shown on one line of a card: 历史命令's and 命令片段's. + +use gpui_kit::component::{ActiveTheme as _, tooltip::Tooltip}; +use gpui_kit::*; + +/// About as many characters as a command line shows in a right-sidebar +/// card at its narrowest; a longer one has the whole of it in a tooltip. +const SHOWN_CHARACTERS: usize = 32; + +/// `command` on one line: a command of several lines shows its line breaks +/// as 「↵」. +pub fn one_line(command: &str) -> String { + command.replace('\n', " ↵ ") +} + +/// The tooltip that shows the whole of `command`, when its line is likely +/// cut short; `None` for one that fits, which would only say it again. +pub fn command_tooltip( + command: &str, + cx: &App, +) -> Option AnyView + 'static> { + if command.chars().count() <= SHOWN_CHARACTERS && !command.contains('\n') { + return None; + } + let command = SharedString::from(command.to_owned()); + let mono = cx.theme().mono_font_family.clone(); + Some(move |window: &mut Window, cx: &mut App| { + let (command, mono) = (command.clone(), mono.clone()); + Tooltip::element(move |_, _| { + div() + .max_w(rems(24.)) + .font_family(mono.clone()) + .text_xs() + .child(command.clone()) + }) + .build(window, cx) + }) +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 17e5227..c528476 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -1,6 +1,7 @@ //! Small presentation pieces shared by more than one feature. mod closable_tab; +mod command_line; mod count_tabs; mod dialog; mod format; @@ -13,6 +14,7 @@ mod tab_menu; mod tint; pub use closable_tab::ClosableTabTitle; +pub use command_line::{command_tooltip, one_line}; pub use count_tabs::count_tabs; pub use dialog::{ DeleteHandler, commit_footer, confirm_danger, confirm_delete, dismiss_form_error, diff --git a/src/snippets/mod.rs b/src/snippets/mod.rs new file mode 100644 index 0000000..4febd15 --- /dev/null +++ b/src/snippets/mod.rs @@ -0,0 +1,13 @@ +//! 命令片段: the right sidebar's tool that keeps commands for every host, +//! in categories one level deep, and puts them on the input line of the +//! SSH terminal in front. The snippets themselves are in the host store +//! (`host::Snippet`); this is their list and their dialogs. + +mod model; +mod snippet_dialog; +mod snippet_panel; + +pub use snippet_dialog::{ + CategoryDialog, SnippetDialog, open_category_dialog, open_snippet_dialog, +}; +pub use snippet_panel::SnippetPanel; diff --git a/src/snippets/model.rs b/src/snippets/model.rs new file mode 100644 index 0000000..5021efd --- /dev/null +++ b/src/snippets/model.rs @@ -0,0 +1,166 @@ +//! The lines of the snippet list, from the store's snippets and categories. + +use std::collections::HashSet; + +use crate::host::{Snippet, SnippetCategory, SnippetCategoryId, SnippetId, by_name}; + +/// A line of the snippet list. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SnippetRow { + /// A category's heading, its snippets after it unless `folded`. `None` + /// is 未分类. `count` is the snippets it lists: while searching, those + /// that match. + Category { + id: Option, + count: usize, + folded: bool, + }, + Snippet(SnippetId), +} + +/// The list: each category by name with its snippets by name, and the +/// snippets of none last under 未分类. Without categories there are no +/// headings at all. A search lists what matches, in every category, folded +/// or not, and leaves out the categories with nothing that does. +pub fn rows( + categories: &[SnippetCategory], + snippets: &[Snippet], + query: &str, + folded: &HashSet>, +) -> Vec { + let searching = !query.trim().is_empty(); + let mut matching: Vec<&Snippet> = snippets + .iter() + .filter(|snippet| snippet.matches(query)) + .collect(); + matching.sort_by(|a, b| by_name((&a.name, a.id.0), (&b.name, b.id.0))); + let in_category = |category: Option| -> Vec { + matching + .iter() + .filter(|snippet| snippet.category == category) + .map(|snippet| snippet.id) + .collect() + }; + if categories.is_empty() { + return in_category(None) + .into_iter() + .map(SnippetRow::Snippet) + .collect(); + } + let mut sorted: Vec<&SnippetCategory> = categories.iter().collect(); + sorted.sort_by(|a, b| by_name((&a.name, a.id.0), (&b.name, b.id.0))); + let mut rows = Vec::new(); + let mut section = |id: Option, shown_empty: bool| { + let ids = in_category(id); + if ids.is_empty() && !shown_empty { + return; + } + let folded = !searching && folded.contains(&id); + rows.push(SnippetRow::Category { + id, + count: ids.len(), + folded, + }); + if !folded { + rows.extend(ids.into_iter().map(SnippetRow::Snippet)); + } + }; + for category in sorted { + // A new category shows, empty, until something goes in it. + section(Some(category.id), !searching); + } + section(None, false); + rows +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host::SnippetDraft; + + fn category(id: u64, name: &str) -> SnippetCategory { + SnippetCategory { + id: SnippetCategoryId(id), + name: name.into(), + } + } + + fn snippet(id: u64, name: &str, category: Option) -> Snippet { + Snippet::new( + SnippetId(id), + SnippetDraft::new( + name, + format!("echo {name}"), + category.map(SnippetCategoryId), + ), + ) + } + + fn heading(id: Option, count: usize, folded: bool) -> SnippetRow { + SnippetRow::Category { + id: id.map(SnippetCategoryId), + count, + folded, + } + } + + fn item(id: u64) -> SnippetRow { + SnippetRow::Snippet(SnippetId(id)) + } + + #[test] + fn without_categories_the_snippets_are_listed_by_name() { + let snippets = [snippet(1, "uptime", None), snippet(2, "df", None)]; + assert_eq!( + rows(&[], &snippets, "", &HashSet::new()), + [item(2), item(1)] + ); + } + + #[test] + fn categories_by_name_then_the_rest_under_none() { + let categories = [ + category(1, "日志"), + category(2, "Docker"), + category(3, "空"), + ]; + let snippets = [ + snippet(1, "tail", Some(1)), + snippet(2, "ps", Some(2)), + snippet(3, "images", Some(2)), + snippet(4, "df", None), + ]; + assert_eq!( + rows(&categories, &snippets, "", &HashSet::new()), + [ + heading(Some(2), 2, false), + item(3), + item(2), + heading(Some(1), 1, false), + item(1), + heading(Some(3), 0, false), + heading(None, 1, false), + item(4), + ] + ); + + // Folded, a category keeps its heading and count. + let folded = HashSet::from([Some(SnippetCategoryId(2)), None]); + assert_eq!( + rows(&categories, &snippets, "", &folded), + [ + heading(Some(2), 2, true), + heading(Some(1), 1, false), + item(1), + heading(Some(3), 0, false), + heading(None, 1, true), + ] + ); + + // A search opens what it finds and leaves out the rest. + assert_eq!( + rows(&categories, &snippets, "S", &folded), + [heading(Some(2), 2, false), item(3), item(2)] + ); + } +} diff --git a/src/snippets/snippet_dialog.rs b/src/snippets/snippet_dialog.rs new file mode 100644 index 0000000..d792e04 --- /dev/null +++ b/src/snippets/snippet_dialog.rs @@ -0,0 +1,350 @@ +//! The dialogs of 命令片段: a snippet's, and a category's. + +use gpui_kit::component::{ + ActiveTheme as _, IndexPath, Sizable as _, WindowExt as _, + checkbox::Checkbox, + form::{Field, Form}, + input::{Input, InputState, Textarea, TextareaState}, + select::{Select, SelectState}, +}; +use gpui_kit::*; + +use crate::host::{HostStore, SnippetCategoryId, SnippetDraft, SnippetId, SnippetScope, by_name}; +use crate::shared::{commit_footer, dismiss_form_error, form_error_notification}; + +/// The category list's row for a snippet of none. +const NO_CATEGORY: &str = "未分类"; + +/// How wide the snippet dialog is: room for a command of a fair length on +/// one line. +const DIALOG_WIDTH: f32 = 36.; + +/// Which snippet dialog to open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SnippetDialog { + /// A new snippet, in this category or none. + New(Option), + Edit(SnippetId), +} + +/// The body of the new and edit snippet dialog. Validates on commit; the +/// store is only touched when that passes. +pub struct SnippetForm { + store: Entity, + editing: Option, + /// Kept as it is: nothing sets it yet. + scope: SnippetScope, + name: Entity, + category: Entity>>, + /// Parallel to the category select's rows; `None` is 未分类. + category_ids: Vec>, + command: Entity, + run_on_click: bool, +} + +impl SnippetForm { + fn new( + dialog: SnippetDialog, + store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let (editing, draft, mut categories) = { + let read = store.read(cx); + let (editing, draft) = match dialog { + SnippetDialog::Edit(id) => match read.snippet(id) { + Some(snippet) => (Some(id), snippet.draft()), + None => (None, SnippetDraft::new("", "", None)), + }, + // A new snippet runs on a click unless told otherwise. + SnippetDialog::New(category) => ( + None, + SnippetDraft::new("", "", category).with_run_on_click(true), + ), + }; + let categories: Vec<(SnippetCategoryId, SharedString)> = read + .snippet_categories() + .iter() + .map(|category| (category.id, category.name.clone())) + .collect(); + (editing, draft, categories) + }; + categories.sort_by(|a, b| by_name((&a.1, a.0.0), (&b.1, b.0.0))); + let mut category_ids: Vec> = vec![None]; + let mut labels: Vec = vec![NO_CATEGORY.into()]; + for (id, name) in categories { + category_ids.push(Some(id)); + labels.push(name); + } + let selected = category_ids + .iter() + .position(|id| *id == draft.category) + .unwrap_or(0); + + let name = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("例如 查看容器") + .default_value(draft.name.clone()) + }); + let category = + cx.new(|cx| SelectState::new(labels, Some(IndexPath::new(selected)), window, cx)); + let command = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(4, 12) + .placeholder("例如 docker ps -a") + .default_value(draft.command.clone()) + }); + Self { + store, + editing, + scope: draft.scope, + name, + category, + category_ids, + command, + run_on_click: draft.run_on_click, + } + } + + /// Validate and write to the store. Returns whether the dialog may close. + fn commit(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let category = self + .category + .read(cx) + .selected_index(cx) + .and_then(|index| self.category_ids.get(index.row).copied()) + .unwrap_or(None); + let draft = match SnippetDraft::validated( + &self.name.read(cx).value(), + &self.command.read(cx).value(), + category, + ) { + Ok(draft) => SnippetDraft { + scope: self.scope, + ..draft.with_run_on_click(self.run_on_click) + }, + Err(error) => { + window.push_notification(form_error_notification(error), cx); + return false; + } + }; + let editing = self.editing; + self.store.update(cx, |store, cx| match editing { + Some(id) => { + store.update_snippet(id, draft, cx); + } + None => { + store.insert_snippet(draft, cx); + } + }); + true + } +} + +impl Render for SnippetForm { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + Form::new() + .child( + Field::new() + .label("名称") + .required(true) + .child(Input::new(&self.name).id("snippet-name").small()), + ) + .child( + Field::new().label("分类").child( + div() + .id("snippet-category") + .test_support() + .w_full() + .child(Select::new(&self.category).small()), + ), + ) + .child( + Field::new() + .label("命令") + .required(true) + .description("可以有多行。") + .child( + div().id("snippet-command").test_support().w_full().child( + Textarea::new(&self.command) + .text_sm() + .font_family(cx.theme().mono_font_family.clone()), + ), + ), + ) + .child( + Field::new().child( + Checkbox::new("snippet-run-on-click") + .label("点击时自动执行") + .checked(self.run_on_click) + .small() + .on_click(cx.listener(|this, checked: &bool, _, cx| { + this.run_on_click = *checked; + cx.notify(); + })) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("启用后将自动执行命令,否则只将内容填入终端"), + ), + ), + ) + } +} + +/// Open the new or the edit snippet dialog. +pub fn open_snippet_dialog( + dialog: SnippetDialog, + store: Entity, + window: &mut Window, + cx: &mut App, +) { + let form = cx.new(|cx| SnippetForm::new(dialog, store, window, cx)); + let editing = form.read(cx).editing.is_some(); + let (title, commit): (SharedString, SharedString) = if editing { + ("编辑命令片段".into(), "保存".into()) + } else { + ("新建命令片段".into(), "创建".into()) + }; + window.open_dialog(cx, { + let form = form.clone(); + move |dialog, window, _| { + dialog + .title(title.clone()) + // Dialog geometry is an API boundary that takes `Pixels`; the + // width follows the interface zoom through the rem. + .w(rems(DIALOG_WIDTH).to_pixels(window.rem_size())) + // Closed by its buttons or Escape, not by a click beside it. + .overlay_closable(false) + .child(form.clone()) + .footer(commit_footer("commit", commit.clone())) + .on_ok({ + let form = form.clone(); + move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx)) + }) + .on_close(|_, window, cx| dismiss_form_error(window, cx)) + } + }); + // Straight to typing: in the same update as the opening, or the + // dialog's own focus wins. + let name = form.read(cx).name.clone(); + name.update(cx, |name, cx| name.focus(window, cx)); +} + +/// Which category dialog to open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CategoryDialog { + New, + Rename(SnippetCategoryId), +} + +/// The body of the new and rename category dialog. +pub struct CategoryForm { + store: Entity, + editing: Option, + name: Entity, +} + +impl CategoryForm { + fn new( + dialog: CategoryDialog, + store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let (editing, current) = match dialog { + CategoryDialog::New => (None, SharedString::default()), + CategoryDialog::Rename(id) => match store.read(cx).snippet_category(id) { + Some(category) => (Some(id), category.name.clone()), + None => (None, SharedString::default()), + }, + }; + let name = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("例如 Docker") + .default_value(current) + }); + Self { + store, + editing, + name, + } + } + + fn commit(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let name = self.name.read(cx).value().trim().to_string(); + let editing = self.editing; + let taken = self + .store + .read(cx) + .snippet_categories() + .iter() + .any(|category| category.name == name.as_str() && Some(category.id) != editing); + let error = if name.is_empty() { + Some("请输入分类名称") + } else if taken { + Some("已有同名分类") + } else { + None + }; + if let Some(error) = error { + window.push_notification(form_error_notification(error), cx); + return false; + } + self.store.update(cx, |store, cx| match editing { + Some(id) => { + store.rename_snippet_category(id, name, cx); + } + None => { + store.insert_snippet_category(name, cx); + } + }); + true + } +} + +impl Render for CategoryForm { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Form::new().child( + Field::new() + .label("名称") + .required(true) + .child(Input::new(&self.name).id("snippet-category-name").small()), + ) + } +} + +/// Open the new or the rename category dialog. +pub fn open_category_dialog( + dialog: CategoryDialog, + store: Entity, + window: &mut Window, + cx: &mut App, +) { + let form = cx.new(|cx| CategoryForm::new(dialog, store, window, cx)); + let renaming = form.read(cx).editing.is_some(); + let (title, commit): (SharedString, SharedString) = if renaming { + ("重命名分类".into(), "保存".into()) + } else { + ("新建分类".into(), "创建".into()) + }; + window.open_dialog(cx, { + let form = form.clone(); + move |dialog, _, _| { + dialog + .title(title.clone()) + // Closed by its buttons or Escape, not by a click beside it. + .overlay_closable(false) + .child(form.clone()) + .footer(commit_footer("commit", commit.clone())) + .on_ok({ + let form = form.clone(); + move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx)) + }) + .on_close(|_, window, cx| dismiss_form_error(window, cx)) + } + }); + let name = form.read(cx).name.clone(); + name.update(cx, |name, cx| name.focus(window, cx)); +} diff --git a/src/snippets/snippet_panel.rs b/src/snippets/snippet_panel.rs new file mode 100644 index 0000000..6340499 --- /dev/null +++ b/src/snippets/snippet_panel.rs @@ -0,0 +1,604 @@ +use std::cell::RefCell; +use std::collections::HashSet; +use std::rc::Rc; + +use gpui_kit::component::{ + ActiveTheme as _, Icon, IconName, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputEvent, InputState}, + menu::{ContextMenuExt as _, PopupMenu}, + scroll::ScrollableElement as _, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use super::model::{SnippetRow, rows}; +use crate::app::{ + CatalogIcon, CopyCommand, DeleteSnippet, DeleteSnippetCategory, EditSnippet, EnterCommand, + NewSnippet, NewSnippetCategory, NewSnippetIn, RenameSnippetCategory, ToggleSnippetCategory, +}; +use crate::host::{HostStore, Snippet, SnippetCategoryId, SnippetId}; +use crate::shared::{command_tooltip, one_line}; + +/// What 未分类 is called, as a category's heading. +const UNCATEGORIZED: &str = "未分类"; + +/// 命令片段: the commands kept for every host, in categories one level +/// deep. A click puts one on the input line of the SSH terminal in front to +/// edit; 执行 runs it there. +/// +/// The snippets are in the host store, so they are the same whichever +/// terminal is in front, and nothing is read from the host. Which +/// categories are folded and the search stay as they are from one terminal +/// to the next; the folds are not saved. +pub struct SnippetPanel { + store: Entity, + search: Entity, + query: String, + folded: HashSet>, + list: ListState, + /// The lines the list was last told about. + rows: Vec, + /// The search changed: back to the top. + back_to_top: bool, + /// What a right click landed on, for the list's menu; `None` off every + /// line. + menu_hit: Rc>>, + /// The workspace's focus handle: the panel's buttons dispatch on it, so + /// they work whatever is focused. + dispatch: FocusHandle, + focus_handle: FocusHandle, + _subscriptions: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum MenuHit { + Snippet(SnippetId), + Category(Option), +} + +impl SnippetPanel { + pub fn new( + store: Entity, + dispatch: FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("搜索名称或命令") + .clean_on_escape() + }); + let subscriptions = vec![ + cx.observe(&store, |_, _, cx| cx.notify()), + cx.subscribe(&search, |this, input, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.query = input.read(cx).value().to_string(); + this.back_to_top = true; + cx.notify(); + } + }), + ]; + Self { + store, + search, + query: String::new(), + folded: HashSet::new(), + list: ListState::new(0, ListAlignment::Top, px(400.)), + rows: Vec::new(), + back_to_top: false, + menu_hit: Rc::new(RefCell::new(None)), + dispatch, + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + } + } + + /// Fold a category away, or unfold it; `None` is 未分类. + pub fn toggle_category(&mut self, id: Option, cx: &mut Context) { + if !self.folded.remove(&id) { + self.folded.insert(id); + } + cx.notify(); + } + + /// Tell the list about `rows` when they are not the ones it has; back + /// at the top when the search changed. + fn sync_list(&mut self, rows: &[SnippetRow]) { + if std::mem::take(&mut self.back_to_top) { + self.list.reset(rows.len()); + } else if self.rows != rows { + self.list.splice(0..self.rows.len(), rows.len()); + } + self.rows = rows.to_vec(); + } + + fn render_header(&self, snippets: usize, categories: usize, cx: &App) -> impl IntoElement { + let summary = if categories == 0 { + format!("共 {snippets} 个片段") + } else { + format!("共 {snippets} 个片段 · {categories} 个分类") + }; + let (new_category, new_snippet) = (self.dispatch.clone(), self.dispatch.clone()); + v_flex() + .flex_shrink_0() + .px_3() + .pt_3() + .pb_2() + .gap_2() + .child( + h_flex() + .gap_1() + .child( + div() + .id("snippets-summary") + .test_support() + .aria_label(summary.clone()) + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(summary), + ) + .child( + Button::new("snippets-new") + .ghost() + .xsmall() + .icon(Icon::new(IconName::Plus)) + .tooltip("新建片段") + .accessibility_label("新建片段") + .on_click(move |_, window, cx| { + new_snippet.dispatch_action(&NewSnippet, window, cx) + }), + ) + .child( + Button::new("snippets-new-category") + .ghost() + .xsmall() + .icon(Icon::new(CatalogIcon::FolderPlus)) + .tooltip("新建分类") + .accessibility_label("新建分类") + .on_click(move |_, window, cx| { + new_category.dispatch_action(&NewSnippetCategory, window, cx) + }), + ), + ) + .child( + Input::new(&self.search) + .id("snippets-search") + .small() + .cleanable(true) + .prefix(Icon::new(IconName::Search).small()), + ) + } + + /// What shows before there is any snippet: what they are for, and the + /// way to make one. + fn render_empty(&self, cx: &App) -> AnyElement { + let dispatch = self.dispatch.clone(); + v_flex() + .id("snippets-empty") + .test_support() + .aria_label("还没有命令片段") + .items_center() + .gap_2() + .px_4() + .py_8() + .child(div().text_sm().child("还没有命令片段")) + .child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().muted_foreground) + .child("把常用的命令存成片段,点一下就输入到当前终端。"), + ) + .child( + Button::new("snippets-empty-new") + .small() + .primary() + .mt_2() + .icon(Icon::new(IconName::Plus)) + .label("新建片段") + .on_click(move |_, window, cx| { + dispatch.dispatch_action(&NewSnippet, window, cx) + }), + ) + .into_any_element() + } +} + +impl Focusable for SnippetPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for SnippetPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let store = self.store.read(cx); + let snippets: Rc> = Rc::new(store.snippets().to_vec()); + let names: Rc> = Rc::new( + store + .snippet_categories() + .iter() + .map(|category| (category.id, category.name.clone())) + .collect(), + ); + let rows = rows( + store.snippet_categories(), + &snippets, + &self.query, + &self.folded, + ); + let nothing = snippets.is_empty() && names.is_empty(); + self.sync_list(&rows); + let header = self.render_header(snippets.len(), names.len(), cx); + + let clear_hit = self.menu_hit.clone(); + let menu_hit = self.menu_hit.clone(); + // The scrollbar goes on the list's box, which does not scroll: on + // the scrolled lines it would scroll away with them. The menu hangs + // off the box too, not off the lines, which are drawn after layout + // (see the host tree). + let list = div() + .id("snippets-list") + .test_support() + .relative() + .flex_1() + .min_h_0() + .border_t_1() + .border_color(cx.theme().border) + .map(|list| { + if nothing { + list.child(self.render_empty(cx)) + } else if rows.is_empty() { + let empty = "没有符合条件的片段"; + list.child( + div() + .id("snippets-no-match") + .test_support() + .aria_label(empty) + .py_8() + .text_sm() + .text_center() + .text_color(cx.theme().muted_foreground) + .child(empty), + ) + } else { + let rows = Rc::new(rows); + let (hit, focus, dispatch) = ( + self.menu_hit.clone(), + self.focus_handle.clone(), + self.dispatch.clone(), + ); + list.child( + gpui_kit::list(self.list.clone(), move |index, _, cx| { + let last = index + 1 == rows.len(); + match rows[index] { + SnippetRow::Category { id, count, folded } => { + let name = id + .and_then(|id| names.iter().find(|(each, _)| *each == id)) + .map_or(UNCATEGORIZED.into(), |(_, name)| name.clone()); + render_category( + Heading { + id, + name, + count, + folded, + last, + }, + hit.clone(), + focus.clone(), + dispatch.clone(), + cx, + ) + .into_any_element() + } + SnippetRow::Snippet(id) => { + let Some(snippet) = + snippets.iter().find(|snippet| snippet.id == id) + else { + return div().into_any_element(); + }; + div() + .px_3() + .pt_2() + // The last one clears the bottom. + .when(last, |row| row.pb_3()) + .child(render_snippet( + snippet, + hit.clone(), + focus.clone(), + dispatch.clone(), + cx, + )) + .into_any_element() + } + } + }) + .size_full(), + ) + } + }) + .vertical_scrollbar(&self.list) + .capture_any_mouse_down(move |event, _, _| { + if matches!(event.button, MouseButton::Left | MouseButton::Right) { + clear_hit.replace(None); + } + }) + // A right click off every line still focuses the panel, so the + // menu's commands have somewhere to be dispatched from. + .on_mouse_down(MouseButton::Right, { + let focus = self.focus_handle.clone(); + move |_, window, cx| focus.focus(window, cx) + }) + .context_menu({ + let store = self.store.clone(); + move |menu, _, cx| build_context_menu(*menu_hit.borrow(), store.read(cx), menu) + }); + v_flex() + .id("snippets") + .test_support() + .track_focus(&self.focus_handle) + .size_full() + .child(header) + .child(list) + } +} + +/// The list's menu, for what a right click landed on: a snippet, a +/// category's heading, or nothing. Built as it opens, from the store as it +/// is then. +fn build_context_menu(hit: Option, store: &HostStore, menu: PopupMenu) -> PopupMenu { + let new_snippet = |menu: PopupMenu, action: Box| { + menu.menu_with_icon("新建片段…", Icon::new(IconName::Plus), action) + }; + match hit { + None => new_snippet(menu, Box::new(NewSnippet)).menu_with_icon( + "新建分类…", + Icon::new(CatalogIcon::FolderPlus), + Box::new(NewSnippetCategory), + ), + Some(MenuHit::Category(None)) => new_snippet(menu, Box::new(NewSnippet)), + Some(MenuHit::Category(Some(id))) => new_snippet(menu, Box::new(NewSnippetIn(id))) + .menu_with_icon( + "重命名分类…", + Icon::new(CatalogIcon::Pencil), + Box::new(RenameSnippetCategory(id)), + ) + .separator() + .menu_with_icon( + "删除分类…", + Icon::new(CatalogIcon::Trash), + Box::new(DeleteSnippetCategory(id)), + ), + Some(MenuHit::Snippet(id)) => { + let Some(snippet) = store.snippet(id) else { + return menu; + }; + let command = snippet.command.clone(); + menu.menu_with_icon( + "输入到终端", + Icon::new(CatalogIcon::SquareTerminal), + Box::new(EnterCommand { + command: command.clone(), + run: false, + }), + ) + .menu_with_icon( + "执行", + Icon::new(CatalogIcon::Play), + Box::new(EnterCommand { + command: command.clone(), + run: true, + }), + ) + .menu_with_icon( + "复制命令", + Icon::new(IconName::Copy), + Box::new(CopyCommand(command)), + ) + .separator() + .menu_with_icon( + "编辑…", + Icon::new(CatalogIcon::Pencil), + Box::new(EditSnippet(id)), + ) + .menu_with_icon( + "删除…", + Icon::new(CatalogIcon::Trash), + Box::new(DeleteSnippet(id)), + ) + } + } +} + +/// What a category's heading shows. +struct Heading { + /// `None` is 未分类. + id: Option, + name: SharedString, + count: usize, + folded: bool, + /// The list's last line, which clears the bottom. + last: bool, +} + +/// A category's heading: a click folds it away or unfolds it. +fn render_category( + heading: Heading, + menu_hit: Rc>>, + focus: FocusHandle, + dispatch: FocusHandle, + cx: &App, +) -> impl IntoElement { + let Heading { + id, + name, + count, + folded, + last, + } = heading; + let key = id.map_or("none".to_string(), |id| id.0.to_string()); + h_flex() + .id(SharedString::from(format!("snippet-category:{key}"))) + .test_support() + .aria_label(format!("{name} {count}")) + .aria_expanded(!folded) + .px_3() + .pt_3() + .pb_1() + .when(last, |line| line.pb_3()) + .gap_1p5() + .cursor_pointer() + .on_click(move |_, window, cx| { + dispatch.dispatch_action(&ToggleSnippetCategory(id), window, cx) + }) + .on_mouse_down(MouseButton::Right, move |_, window, cx| { + menu_hit.replace(Some(MenuHit::Category(id))); + focus.focus(window, cx); + }) + .child( + Icon::new(if folded { + IconName::ChevronRight + } else { + IconName::ChevronDown + }) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .min_w_0() + .truncate() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child(name), + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(count.to_string()), + ) +} + +/// A snippet's card: its name, and its command on one line below. +/// +/// What a click does is not the same for every snippet, so the pointer on +/// a card shows it at the right: ⚡ for one a click runs, ▶ 执行 for one a +/// click only types, which runs it. Out of the way otherwise, the slot +/// keeps its room, so nothing moves as it shows. +fn render_snippet( + snippet: &Snippet, + menu_hit: Rc>>, + focus: FocusHandle, + dispatch: FocusHandle, + cx: &App, +) -> impl IntoElement { + let id = snippet.id; + let line = one_line(&snippet.command); + let runs = snippet.run_on_click; + // A click runs it when the snippet says so, and only types it + // otherwise. + let click = EnterCommand { + command: snippet.command.clone(), + run: runs, + }; + let run = EnterCommand { + command: snippet.command.clone(), + run: true, + }; + let tooltip = command_tooltip(&snippet.command, cx); + let card = SharedString::from(format!("snippet-card:{}", id.0)); + h_flex() + .id(("snippet", id.0)) + .test_support() + .group(card.clone()) + .aria_label(if runs { + format!("{} · {line} · 点击时自动执行", snippet.name) + } else { + format!("{} · {line}", snippet.name) + }) + .gap_2() + .py_2() + .pl_3() + .pr_2() + .rounded(cx.theme().radius) + .bg(cx.theme().tokens.group_box) + .text_color(cx.theme().group_box_foreground) + // A click types it, or runs it; the border says it does something. + .border_1() + .border_color(cx.theme().tokens.group_box) + .hover({ + let border = cx.theme().border; + move |style| style.border_color(border) + }) + .cursor_pointer() + .on_click({ + let dispatch = dispatch.clone(); + move |_, window, cx| dispatch.dispatch_action(&click, window, cx) + }) + // A right click puts the menu on this snippet. The menu's commands + // are dispatched from what is focused, which may be nothing. + .on_mouse_down(MouseButton::Right, move |_, window, cx| { + menu_hit.replace(Some(MenuHit::Snippet(id))); + focus.focus(window, cx); + }) + // The whole of a command too long for its line. + .when_some(tooltip, |card, tooltip| card.tooltip(tooltip)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + div() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(snippet.name.clone()), + ) + .child( + div() + .truncate() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(line), + ), + ) + .child( + div() + .flex_shrink_0() + .invisible() + .group_hover(card, |style| style.visible()) + .child( + // As tall as the card's two lines allow: an easy target. + Button::new(("snippet-run", id.0)) + .ghost() + .map(|button| { + if runs { + button + .icon( + Icon::new(CatalogIcon::Zap).text_color(cx.theme().warning), + ) + .tooltip("点击时自动执行") + } else { + button.icon(Icon::new(CatalogIcon::Play)).tooltip("执行") + } + }) + .accessibility_label("执行") + .on_click(move |_, window, cx| { + // Not the card's click too, which would run it + // again or only type it. + cx.stop_propagation(); + dispatch.dispatch_action(&run, window, cx); + }), + ), + ) +} diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 3253411..7561f4b 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -255,15 +255,20 @@ impl TerminalEngine { self.send_user_input(encode_paste(text, self.mode())); } - /// Put `command` on the shell's input line in place of what is typed - /// there, as a paste, and with `run` press Enter: what a click in 历史命令 - /// does. Says why not while the shell is not running or a full-screen - /// program has the keys. - pub fn enter_command(&self, command: &str, run: bool) -> Result<(), &'static str> { + /// Put `command` on the shell's input line, as a paste, and with `run` + /// press Enter: what a click in 历史命令 and 命令片段 does. With + /// `replace`, in place of what is typed there. Says why not while the + /// shell is not running or a full-screen program has the keys. + pub fn enter_command( + &self, + command: &str, + run: bool, + replace: bool, + ) -> Result<(), &'static str> { if !self.lifecycle.accepts_input() { return Err("终端没有连接"); } - self.send_user_input(encode_command(command, run, self.mode())?); + self.send_user_input(encode_command(command, run, replace, self.mode())?); Ok(()) } @@ -799,21 +804,31 @@ fn encode_paste(text: &str, mode: TermMode) -> Vec { } } -/// What `TerminalEngine::enter_command` types: Ctrl-E and Ctrl-U first, to -/// the line's end and everything before it away (the emacs keys of bash, -/// zsh and fish alike), then the command as a paste, then Enter to run it. +/// What `TerminalEngine::enter_command` types: with `replace` Ctrl-E and +/// Ctrl-U first, to the line's end and everything before it away (the +/// emacs keys of bash, zsh and fish alike), then the command as a paste, +/// then Enter to run it. /// /// A full-screen program would take those keys for its own. A command of /// several lines can only be put on the line in a bracketed paste: without /// one, its first newline would run it. -fn encode_command(command: &str, run: bool, mode: TermMode) -> Result, &'static str> { +fn encode_command( + command: &str, + run: bool, + replace: bool, + mode: TermMode, +) -> Result, &'static str> { if mode.contains(TermMode::ALT_SCREEN) { return Err("终端里正在运行全屏程序,先退出它"); } if !run && command.contains('\n') && !mode.contains(TermMode::BRACKETED_PASTE) { return Err("这是一条多行命令,在这个终端里只能直接执行"); } - let mut bytes = b"\x05\x15".to_vec(); + let mut bytes = if replace { + b"\x05\x15".to_vec() + } else { + Vec::new() + }; bytes.extend(encode_paste(command, mode)); if run { bytes.push(b'\r'); @@ -1207,19 +1222,24 @@ mod tests { #[test] fn a_command_takes_the_place_of_the_input_line_and_runs_on_enter() { assert_eq!( - encode_command("ls -l", false, TermMode::BRACKETED_PASTE), + encode_command("ls -l", false, true, TermMode::BRACKETED_PASTE), Ok(b"\x05\x15\x1b[200~ls -l\x1b[201~".to_vec()) ); assert_eq!( - encode_command("ls -l", true, TermMode::default()), + encode_command("ls -l", true, true, TermMode::default()), Ok(b"\x05\x15ls -l\r".to_vec()) ); + // Not replacing, only the command. + assert_eq!( + encode_command("dir", true, false, TermMode::default()), + Ok(b"dir\r".to_vec()) + ); // Several lines wait on the line only in a bracketed paste. let lines = "for f in *\ndo echo $f\ndone"; - assert!(encode_command(lines, false, TermMode::BRACKETED_PASTE).is_ok()); - assert!(encode_command(lines, false, TermMode::default()).is_err()); - assert!(encode_command(lines, true, TermMode::default()).is_ok()); + assert!(encode_command(lines, false, true, TermMode::BRACKETED_PASTE).is_ok()); + assert!(encode_command(lines, false, true, TermMode::default()).is_err()); + assert!(encode_command(lines, true, true, TermMode::default()).is_ok()); // vim and less have the keys to themselves. - assert!(encode_command("ls", true, TermMode::ALT_SCREEN).is_err()); + assert!(encode_command("ls", true, true, TermMode::ALT_SCREEN).is_err()); } } diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index 4d2d161..89b700e 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -352,15 +352,17 @@ impl TerminalView { cx.notify(); } - /// Put `command` on the shell's input line in place of what is typed - /// there, and with `run` run it; see [`TerminalEngine::enter_command`]. + /// Put `command` on the shell's input line, with `replace` in place of + /// what is typed there, and with `run` run it; see + /// [`TerminalEngine::enter_command`]. pub fn enter_command( &mut self, command: &str, run: bool, + replace: bool, cx: &mut Context, ) -> Result<(), &'static str> { - self.engine.read(cx).enter_command(command, run)?; + self.engine.read(cx).enter_command(command, run, replace)?; self.selecting = false; self.scroll_accumulator.reset(); self.cursor_visible = true; diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 0a5c58a..450dcdb 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -7,6 +7,7 @@ mod dock_skin; mod forwards; mod recent_hosts; mod sidebar; +mod snippets; mod status_bar; mod title_bar; mod tool_sidebar; diff --git a/src/workspace/snippets.rs b/src/workspace/snippets.rs new file mode 100644 index 0000000..893764a --- /dev/null +++ b/src/workspace/snippets.rs @@ -0,0 +1,154 @@ +//! The workspace's share of 命令片段: the commands of the snippet list, +//! which open the dialogs and ask before deleting. + +use std::rc::Rc; + +use gpui_kit::*; + +use crate::app::{ + DeleteSnippet, DeleteSnippetCategory, EditSnippet, NewSnippet, NewSnippetCategory, + NewSnippetIn, RenameSnippetCategory, ToggleSnippetCategory, +}; +use crate::shared::confirm_delete; +use crate::snippets::{CategoryDialog, SnippetDialog, open_category_dialog, open_snippet_dialog}; + +use super::workspace_view::Workspace; + +impl Workspace { + pub(super) fn on_new_snippet( + &mut self, + _: &NewSnippet, + window: &mut Window, + cx: &mut Context, + ) { + open_snippet_dialog(SnippetDialog::New(None), self.store.clone(), window, cx); + } + + pub(super) fn on_new_snippet_in( + &mut self, + action: &NewSnippetIn, + window: &mut Window, + cx: &mut Context, + ) { + open_snippet_dialog( + SnippetDialog::New(Some(action.0)), + self.store.clone(), + window, + cx, + ); + } + + pub(super) fn on_edit_snippet( + &mut self, + action: &EditSnippet, + window: &mut Window, + cx: &mut Context, + ) { + if self.store.read(cx).snippet(action.0).is_some() { + open_snippet_dialog( + SnippetDialog::Edit(action.0), + self.store.clone(), + window, + cx, + ); + } + } + + pub(super) fn on_delete_snippet( + &mut self, + action: &DeleteSnippet, + window: &mut Window, + cx: &mut Context, + ) { + let id = action.0; + let Some(name) = self + .store + .read(cx) + .snippet(id) + .map(|snippet| snippet.name.clone()) + else { + return; + }; + let store = self.store.clone(); + confirm_delete( + &name, + None, + Rc::new(move |_, cx| { + store.update(cx, |store, cx| { + store.remove_snippet(id, cx); + }); + }), + window, + cx, + ); + } + + pub(super) fn on_new_snippet_category( + &mut self, + _: &NewSnippetCategory, + window: &mut Window, + cx: &mut Context, + ) { + open_category_dialog(CategoryDialog::New, self.store.clone(), window, cx); + } + + pub(super) fn on_rename_snippet_category( + &mut self, + action: &RenameSnippetCategory, + window: &mut Window, + cx: &mut Context, + ) { + if self.store.read(cx).snippet_category(action.0).is_some() { + open_category_dialog( + CategoryDialog::Rename(action.0), + self.store.clone(), + window, + cx, + ); + } + } + + /// Ask, saying how many snippets go with it, then delete a category. + pub(super) fn on_delete_snippet_category( + &mut self, + action: &DeleteSnippetCategory, + window: &mut Window, + cx: &mut Context, + ) { + let id = action.0; + let store = self.store.read(cx); + let Some(name) = store + .snippet_category(id) + .map(|category| category.name.clone()) + else { + return; + }; + let snippets = store.snippets_in(id); + let description = (snippets > 0) + .then(|| SharedString::from(format!("分类里的 {snippets} 个片段会一起删除。"))); + let store = self.store.clone(); + confirm_delete( + &name, + description, + Rc::new(move |_, cx| { + store.update(cx, |store, cx| { + store.remove_snippet_category(id, cx); + }); + }), + window, + cx, + ); + } + + /// A category's heading: fold it away, or unfold it. + pub(super) fn on_toggle_snippet_category( + &mut self, + action: &ToggleSnippetCategory, + _: &mut Window, + cx: &mut Context, + ) { + let id = action.0; + self.tools + .update(cx, |tools, cx| tools.toggle_snippet_category(id, cx)); + } +} diff --git a/src/workspace/tool_sidebar.rs b/src/workspace/tool_sidebar.rs index 49c1e50..0ebcad5 100644 --- a/src/workspace/tool_sidebar.rs +++ b/src/workspace/tool_sidebar.rs @@ -10,11 +10,12 @@ use gpui_kit::*; use crate::app::{CatalogIcon, DOCKER_ICON, ToggleTool, ToolKind}; use crate::docker::{Container, DockerObject, DockerPanel, ObjectSummary}; use crate::history::HistoryPanel; -use crate::host::{HostId, HostOs, HostStore}; +use crate::host::{HostId, HostOs, HostStore, SnippetCategoryId}; use crate::monitor::{MonitorDetail, MonitorPanel}; use crate::netstat::NetstatPanel; use crate::processes::{Process, ProcessDetails, ProcessPanel, ProcessSort}; use crate::services::{Service, ServicePanel}; +use crate::snippets::SnippetPanel; use crate::terminal::{ExecTarget, RemoteTerminalId, TerminalView}; impl ToolKind { @@ -71,20 +72,6 @@ impl ToolKind { ToolKind::Monitor => "tool-monitor", } } - - /// What a tool still to come is going to show for the terminal in - /// front of `host`, said in its place. - fn about(self, host: &str) -> String { - match self { - ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(), - ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"), - ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"), - ToolKind::Services => format!("{host} 上的 systemd 服务:启动、停止、日志。"), - ToolKind::Processes => format!("{host} 上的进程:内存、CPU,结束进程。"), - ToolKind::Connections => format!("{host} 上的 TCP、UDP 连接和监听端口。"), - ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"), - } - } } /// The SSH terminal the right sidebar works on. @@ -107,7 +94,7 @@ pub struct ToolTerminal { /// /// Each tool is a feature module's entity held here, alive while hidden so /// it keeps its state, told which terminal is in front and whether it is on -/// screen; the tool still to come is a placeholder. +/// screen. pub struct ToolSidebar { tool: ToolKind, /// The terminal in front; `None` while the sidebar is hidden for @@ -116,14 +103,13 @@ pub struct ToolSidebar { /// Whether the right dock is open. shown: bool, monitor: Entity, + snippets: Entity, history: Entity, docker: Entity, services: Entity, processes: Entity, netstat: Entity, - store: Entity, focus_handle: FocusHandle, - _subscription: Subscription, } impl ToolSidebar { @@ -135,21 +121,18 @@ impl ToolSidebar { window: &mut Window, cx: &mut Context, ) -> Self { - // A host renamed is named anew. - let subscription = cx.observe(&store, |_, _, cx| cx.notify()); Self { tool: ToolKind::default(), terminal: None, shown: false, monitor: cx.new(|_| MonitorPanel::new(dispatch.clone())), + snippets: cx.new(|cx| SnippetPanel::new(store, dispatch.clone(), window, cx)), history: cx.new(|cx| HistoryPanel::new(dispatch.clone(), window, cx)), docker: cx.new(|_| DockerPanel::new(dispatch.clone())), services: cx.new(|cx| ServicePanel::new(dispatch.clone(), window, cx)), processes: cx.new(|cx| ProcessPanel::new(dispatch.clone(), window, cx)), netstat: cx.new(|cx| NetstatPanel::new(dispatch, window, cx)), - store, focus_handle: cx.focus_handle(), - _subscription: subscription, } } @@ -183,6 +166,16 @@ impl ToolSidebar { self.netstat.update(cx, |netstat, cx| netstat.refresh(cx)); } + /// Fold a category of 命令片段 away, or unfold it. + pub fn toggle_snippet_category( + &mut self, + id: Option, + cx: &mut Context, + ) { + self.snippets + .update(cx, |snippets, cx| snippets.toggle_category(id, cx)); + } + /// 历史命令's 刷新. pub fn refresh_history(&mut self, cx: &mut Context) { self.history.update(cx, |history, cx| history.refresh(cx)); @@ -301,24 +294,6 @@ impl ToolSidebar { self.sync_tools(cx); cx.notify(); } - - fn render_placeholder(&self, host: HostId, cx: &App) -> impl IntoElement { - let store = self.store.read(cx); - let host = store.host(host).map(|host| host.name.clone()); - let about = self.tool.about(host.as_deref().unwrap_or_default()); - v_flex() - .id("tool-placeholder") - .test_support() - .aria_label(about.clone()) - .items_center() - .gap_2() - .px_4() - .py_8() - .text_color(cx.theme().muted_foreground) - .child(self.tool.icon().large()) - .child(div().text_sm().text_center().child(about)) - .child(div().text_xs().child("即将推出")) - } } impl EventEmitter for ToolSidebar {} @@ -372,28 +347,17 @@ impl Render for ToolSidebar { .size_full() .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) - .when_some(self.terminal.as_ref(), |sidebar, terminal| { - match self.tool { - ToolKind::Monitor => { - sidebar.child(div().flex_1().min_h_0().child(self.monitor.clone())) - } - ToolKind::Connections => { - sidebar.child(div().flex_1().min_h_0().child(self.netstat.clone())) - } - ToolKind::Processes => { - sidebar.child(div().flex_1().min_h_0().child(self.processes.clone())) - } - ToolKind::Services => { - sidebar.child(div().flex_1().min_h_0().child(self.services.clone())) - } - ToolKind::Docker => { - sidebar.child(div().flex_1().min_h_0().child(self.docker.clone())) - } - ToolKind::History => { - sidebar.child(div().flex_1().min_h_0().child(self.history.clone())) - } - _ => sidebar.child(self.render_placeholder(terminal.host, cx)), - } + .when(self.terminal.is_some(), |sidebar| { + let panel = match self.tool { + ToolKind::Snippets => self.snippets.clone().into_any_element(), + ToolKind::History => self.history.clone().into_any_element(), + ToolKind::Docker => self.docker.clone().into_any_element(), + ToolKind::Services => self.services.clone().into_any_element(), + ToolKind::Processes => self.processes.clone().into_any_element(), + ToolKind::Connections => self.netstat.clone().into_any_element(), + ToolKind::Monitor => self.monitor.clone().into_any_element(), + }; + sidebar.child(div().flex_1().min_h_0().child(panel)) }) } } diff --git a/src/workspace/tools.rs b/src/workspace/tools.rs index 3a444ee..467c8b1 100644 --- a/src/workspace/tools.rs +++ b/src/workspace/tools.rs @@ -26,6 +26,7 @@ use crate::app::{ use crate::docker::{ self, ContainerCommand, DockerObject, open_container_dialog, open_object_dialog, }; +use crate::host::HostOs; use crate::processes::{end_command, ended, open_process_dialog}; use crate::services::{ServiceCommand, control_command, controlled, open_service_dialog}; use crate::shared::confirm_danger; @@ -186,23 +187,34 @@ impl Workspace { self.tools.update(cx, |tools, cx| tools.refresh_history(cx)); } - /// A command of 历史命令 onto the input line of the SSH terminal in - /// front, run with `run`; the terminal takes the keyboard, to edit it - /// or carry on. Why not, when it cannot, is a notification. + /// A command of 历史命令 or 命令片段 onto the input line of the SSH + /// terminal in front, run with `run`; the terminal takes the keyboard, + /// to edit it or carry on. Why not, when it cannot, is a notification. + /// + /// It takes the place of what is typed there, but on Windows, whose + /// shells do not read Ctrl-E and Ctrl-U as line editing. pub(super) fn on_enter_command( &mut self, action: &EnterCommand, window: &mut Window, cx: &mut Context, ) { - let Some(view) = self - .tool_terminal(cx) - .and_then(|terminal| terminal.view.upgrade()) - else { + let Some(terminal) = self.tool_terminal(cx) else { return; }; + let Some(view) = terminal.view.upgrade() else { + return; + }; + let replace = self + .store + .read(cx) + .host(terminal.host) + .and_then(|host| host.os) + != Some(HostOs::Windows); let EnterCommand { command, run } = action; - match view.update(cx, |view, cx| view.enter_command(command, *run, cx)) { + match view.update(cx, |view, cx| { + view.enter_command(command, *run, replace, cx) + }) { Ok(()) => { let focus = view.read(cx).focus_handle(); window.focus(&focus, cx); diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index ad0f04b..74f2754 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -2246,6 +2246,14 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_refresh_history)) .on_action(cx.listener(Self::on_enter_command)) .on_action(cx.listener(Self::on_copy_command)) + .on_action(cx.listener(Self::on_new_snippet)) + .on_action(cx.listener(Self::on_new_snippet_in)) + .on_action(cx.listener(Self::on_edit_snippet)) + .on_action(cx.listener(Self::on_delete_snippet)) + .on_action(cx.listener(Self::on_new_snippet_category)) + .on_action(cx.listener(Self::on_rename_snippet_category)) + .on_action(cx.listener(Self::on_delete_snippet_category)) + .on_action(cx.listener(Self::on_toggle_snippet_category)) .on_action(cx.listener(Self::on_refresh_docker)) .on_action(cx.listener(Self::on_toggle_docker_project)) .on_action(cx.listener(Self::on_show_docker_object)) diff --git a/tests/ui/main.rs b/tests/ui/main.rs index 59beeb4..80b808c 100644 --- a/tests/ui/main.rs +++ b/tests/ui/main.rs @@ -20,6 +20,7 @@ mod processes; mod services; mod settings; mod sftp; +mod snippets; mod terminal; mod transfer; mod update; diff --git a/tests/ui/snippets.rs b/tests/ui/snippets.rs new file mode 100644 index 0000000..8cb94c9 --- /dev/null +++ b/tests/ui/snippets.rs @@ -0,0 +1,346 @@ +//! 命令片段: the right sidebar's tool that keeps commands for every host, +//! in categories, and puts them on the SSH terminal's line. + +use crate::support::*; +use shellrs::app::{ + DeleteSnippet, DeleteSnippetCategory, EditSnippet, NewSnippetCategory, NewSnippetIn, + RenameSnippetCategory, +}; +use shellrs::host::{SnippetCategoryId, SnippetDraft, SnippetId}; + +/// Two categories and a snippet of none, made in an order the list does +/// not keep: Docker's 「容器」 and 「镜像」, 日志's 「系统日志」, and 「磁盘」. +fn store_with_snippets() -> HostStore { + let mut store = HostStore::seed(); + let logs = store.insert_snippet_category_unnotified("日志"); + let docker = store.insert_snippet_category_unnotified("Docker"); + store.insert_snippet_unnotified(SnippetDraft::new("镜像", "docker images", Some(docker))); + store.insert_snippet_unnotified( + SnippetDraft::new("系统日志", "journalctl -f", Some(logs)).with_run_on_click(true), + ); + store.insert_snippet_unnotified(SnippetDraft::new("磁盘", "df -h", None)); + store.insert_snippet_unnotified(SnippetDraft::new("容器", "docker ps -a", Some(docker))); + store +} + +const IMAGES: u64 = 1; +const JOURNAL: u64 = 2; +const DISK: u64 = 3; +const CONTAINERS: u64 = 4; +const LOGS: u64 = 1; +const DOCKER: u64 = 2; + +/// 命令片段 shown beside web-01's terminal, once its shell runs. +async fn open_snippets( + cx: &mut TestAppContext, + store: HostStore, +) -> ( + WindowHandle, + Entity, + Arc, +) { + let factory = Arc::new(FakeTerminalFactory::default()); + let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); + in_frame(cx, handle, |window, cx| { + window.activate_window(); + window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx) + }); + in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx)); + let terminal = RemoteTerminalId(INITIAL_WEB_TERMINAL); + cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| { + remote_lifecycle(&workspace, terminal, cx).accepts_input() + }) + .await; + (handle, workspace, factory) +} + +/// A closed dialog leaves nothing focused, and an action dispatched from +/// nothing goes nowhere: a click in the panel first, as a user would. +fn dispatch_from_panel( + cx: &mut TestAppContext, + handle: WindowHandle, + action: impl gpui_kit::Action, +) { + in_frame(cx, handle, |window, cx| { + window.click("snippets-summary", cx) + }); + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(action), cx) + }); +} + +/// The pointer onto a snippet's card, which shows what a click on it does. +fn point_at(cx: &mut TestAppContext, handle: WindowHandle, snippet: u64) { + in_frame(cx, handle, |window, cx| { + let position = window.find(("snippet", snippet)).bounds().center(); + window.dispatch_event( + MouseMoveEvent { + position, + pressed_button: None, + modifiers: gpui_kit::Modifiers::default(), + } + .to_platform_input(), + cx, + ); + }); +} + +fn top(window: &mut gpui_kit::Window, id: impl Into) -> gpui_kit::Pixels { + window.find(id).bounds().top() +} + +fn names(workspace: &Entity, cx: &mut TestAppContext) -> Vec { + workspace.read_with(cx, |workspace, cx| { + workspace + .store() + .read(cx) + .snippets() + .iter() + .map(|snippet| snippet.name.to_string()) + .collect() + }) +} + +#[gpui_kit::test] +async fn snippets_are_listed_by_category_and_a_click_puts_one_on_the_line(cx: &mut TestAppContext) { + let (handle, _, factory) = open_snippets(cx, store_with_snippets()).await; + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find("snippets-summary").label(), + Some("共 4 个片段 · 2 个分类") + ); + // Docker, then 日志, each by name; the rest last, under 未分类. + let order = [ + top(window, "snippet-category:2"), + top(window, ("snippet", CONTAINERS)), + top(window, ("snippet", IMAGES)), + top(window, "snippet-category:1"), + top(window, ("snippet", JOURNAL)), + top(window, "snippet-category:none"), + top(window, ("snippet", DISK)), + ]; + assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{order:?}"); + assert_eq!(window.find("snippet-category:2").label(), Some("Docker 2")); + assert_eq!( + window.find(("snippet", CONTAINERS)).label(), + Some("容器 · docker ps -a") + ); + }); + + // A click puts it on the line in place of what is typed there, and the + // terminal takes the keyboard. + let before = factory.written_text().len(); + in_frame(cx, handle, |window, cx| { + window.click(("snippet", CONTAINERS), cx) + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find(("terminal", INITIAL_WEB_TERMINAL)).focused(), + Some(true) + ); + }); + assert_eq!(&factory.written_text()[before..], "\x05\x15docker ps -a"); + + // 执行 shows with the pointer on the card, and runs it, and only that. + in_frame(cx, handle, |window, _| { + assert!(!window.find(("snippet-run", DISK)).visible()); + }); + point_at(cx, handle, DISK); + let before = factory.written_text().len(); + in_frame(cx, handle, |window, cx| { + assert!(window.find(("snippet-run", DISK)).visible()); + assert!(!window.find(("snippet-run", JOURNAL)).visible()); + window.click(("snippet-run", DISK), cx) + }); + assert_eq!(&factory.written_text()[before..], "\x05\x15df -h\r"); + + // One set to run on a click says so, and runs. + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find(("snippet", JOURNAL)).label(), + Some("系统日志 · journalctl -f · 点击时自动执行") + ); + }); + let before = factory.written_text().len(); + in_frame(cx, handle, |window, cx| { + window.click(("snippet", JOURNAL), cx) + }); + assert_eq!(&factory.written_text()[before..], "\x05\x15journalctl -f\r"); +} + +#[gpui_kit::test] +async fn a_category_folds_away_and_the_search_finds_through_it(cx: &mut TestAppContext) { + let (handle, _, _) = open_snippets(cx, store_with_snippets()).await; + in_frame(cx, handle, |window, cx| { + window.click("snippet-category:2", cx) + }); + in_frame(cx, handle, |window, _| { + assert!(window.try_find(("snippet", CONTAINERS)).is_none()); + assert_eq!(window.find("snippet-category:2").label(), Some("Docker 2")); + assert!(window.try_find(("snippet", JOURNAL)).is_some()); + }); + + // The search looks in names and commands, folded or not, and leaves + // out the categories with nothing that matches. + in_frame(cx, handle, |window, cx| { + window.click("snippets-search", cx); + window.input("PS", cx); + }); + in_frame(cx, handle, |window, _| { + assert!(window.try_find(("snippet", CONTAINERS)).is_some()); + assert_eq!(window.find("snippet-category:2").label(), Some("Docker 1")); + assert!(window.try_find("snippet-category:1").is_none()); + assert!(window.try_find(("snippet", DISK)).is_none()); + }); + in_frame(cx, handle, |window, cx| window.input(" kubectl", cx)); + in_frame(cx, handle, |window, _| { + assert!(window.find("snippets-no-match").visible()); + }); +} + +#[gpui_kit::test] +async fn a_snippet_is_made_edited_and_deleted(cx: &mut TestAppContext) { + let (handle, workspace, _) = open_snippets(cx, HostStore::seed()).await; + in_frame(cx, handle, |window, cx| { + assert_eq!( + window.find("snippets-empty").label(), + Some("还没有命令片段") + ); + window.click("snippets-empty-new", cx); + }); + + // The name takes the keyboard at once; without a command it is refused. + in_frame(cx, handle, |window, cx| { + assert_eq!(window.find("snippet-name").focused(), Some(true)); + window.input("查看端口", cx); + window.click("commit", cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window + .try_find("form-error") + .is_some_and(|error| error.label() == Some("请输入命令")) + }) + .await; + in_frame(cx, handle, |window, cx| { + window.click("snippet-command", cx); + window.input("ss -tlnp", cx); + window.click("commit", cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window.try_find(("snippet", 1u64)).is_some() + }) + .await; + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find(("snippet", 1u64)).label(), + Some("查看端口 · ss -tlnp · 点击时自动执行") + ); + assert_eq!(window.find("snippets-summary").label(), Some("共 1 个片段")); + }); + + // Edited in the same dialog, where a click is told only to type it. + dispatch_from_panel(cx, handle, EditSnippet(SnippetId(1))); + in_frame(cx, handle, |window, cx| { + window.press("cmd-a", cx); + window.input("监听端口", cx); + window.click("snippet-run-on-click", cx); + window.click("commit", cx); + }); + cx.run_until_parked(); + assert_eq!(names(&workspace, cx), ["监听端口"]); + let runs = workspace.read_with(cx, |workspace, cx| { + workspace.store().read(cx).snippets()[0].run_on_click + }); + assert!(!runs); + + // Deleted once confirmed. + dispatch_from_panel(cx, handle, DeleteSnippet(SnippetId(1))); + in_frame(cx, handle, |window, cx| window.click("ok", cx)); + assert!(names(&workspace, cx).is_empty()); + in_frame(cx, handle, |window, _| { + assert!(window.find("snippets-empty").visible()); + }); +} + +#[gpui_kit::test] +async fn a_category_is_made_renamed_and_deleted_with_its_snippets(cx: &mut TestAppContext) { + let (handle, workspace, _) = open_snippets(cx, store_with_snippets()).await; + + // A name the list already has is refused. + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(NewSnippetCategory), cx) + }); + in_frame(cx, handle, |window, cx| { + window.input("Docker", cx); + window.click("commit", cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window + .try_find("form-error") + .is_some_and(|error| error.label() == Some("已有同名分类")) + }) + .await; + in_frame(cx, handle, |window, cx| { + window.click("snippet-category-name", cx); + window.press("cmd-a", cx); + window.input("部署", cx); + window.click("commit", cx); + }); + // Empty, it shows until something goes in it. + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window + .try_find("snippet-category:3") + .is_some_and(|heading| heading.label() == Some("部署 0")) + }) + .await; + + // A snippet made from its menu goes in it. + dispatch_from_panel(cx, handle, NewSnippetIn(SnippetCategoryId(3))); + in_frame(cx, handle, |window, cx| { + window.input("发布", cx); + window.click("snippet-command", cx); + window.input("./deploy.sh", cx); + window.click("commit", cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window + .try_find("snippet-category:3") + .is_some_and(|heading| heading.label() == Some("部署 1")) + }) + .await; + + dispatch_from_panel(cx, handle, RenameSnippetCategory(SnippetCategoryId(LOGS))); + in_frame(cx, handle, |window, cx| { + window.press("cmd-a", cx); + window.input("日志查看", cx); + window.click("commit", cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window + .try_find("snippet-category:1") + .is_some_and(|heading| heading.label() == Some("日志查看 1")) + }) + .await; + + // Deleting Docker takes its two snippets. + dispatch_from_panel(cx, handle, DeleteSnippetCategory(SnippetCategoryId(DOCKER))); + in_frame(cx, handle, |window, cx| window.click("ok", cx)); + assert_eq!(names(&workspace, cx), ["系统日志", "磁盘", "发布"]); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("snippet-category:2").is_none()); + }); +} + +#[gpui_kit::test] +async fn on_windows_a_snippet_is_typed_after_what_is_there(cx: &mut TestAppContext) { + let mut store = store_with_snippets(); + store.set_host_os_unnotified(HostId(WEB_01), Some(HostOs::Windows)); + let (handle, _, factory) = open_snippets(cx, store).await; + point_at(cx, handle, DISK); + let before = factory.written_text().len(); + in_frame(cx, handle, |window, cx| { + window.click(("snippet-run", DISK), cx) + }); + // No Ctrl-E, Ctrl-U: its shells do not edit the line with them. + assert_eq!(&factory.written_text()[before..], "df -h\r"); +} diff --git a/tests/ui/workspace.rs b/tests/ui/workspace.rs index 9e5c8e8..cb2f039 100644 --- a/tests/ui/workspace.rs +++ b/tests/ui/workspace.rs @@ -608,6 +608,14 @@ async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut .unwrap(); } +/// 命令片段 beside web-01's terminal, where its dialogs open from. +fn show_snippets(cx: &mut TestAppContext, handle: WindowHandle) { + in_frame(cx, handle, |window, cx| { + window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx) + }); + in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx)); +} + /// A dialog holds what was typed into it: a click beside it does not close /// it, Escape (like its buttons) does. #[gpui_kit::test] @@ -624,6 +632,8 @@ async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) { ("new-group", "group-name"), ("new-forward", "forward-name"), ("new-credential", "credential-name"), + ("snippets-new", "snippet-name"), + ("snippets-new-category", "snippet-category-name"), ] { if open == "new-forward" { show_forwards(cx, handle).await; @@ -631,6 +641,9 @@ async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) { if open == "new-credential" { show_credentials(cx, handle).await; } + if open == "snippets-new" { + show_snippets(cx, handle); + } in_frame(cx, handle, |window, cx| window.click(open, cx)); in_frame(cx, handle, |window, cx| { window.click(field, cx); @@ -672,6 +685,12 @@ async fn a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it(cx: &mu ("new-group", "group-name", "请输入分组名称"), ("new-forward", "forward-name", "请选择端口转发经由的主机"), ("new-credential", "credential-name", "请输入名称"), + ("snippets-new", "snippet-name", "请输入名称"), + ( + "snippets-new-category", + "snippet-category-name", + "请输入分类名称", + ), ] { if open == "new-forward" { show_forwards(cx, handle).await; @@ -679,6 +698,9 @@ async fn a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it(cx: &mu if open == "new-credential" { show_credentials(cx, handle).await; } + if open == "snippets-new" { + show_snippets(cx, handle); + } in_frame(cx, handle, |window, cx| window.click(open, cx)); in_frame(cx, handle, |window, cx| { window.click(field, cx); @@ -763,10 +785,7 @@ fn the_tool_switch_shows_a_tool_and_hides_it_again(cx: &mut TestAppContext) { in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx)); in_frame(cx, handle, |window, _| { assert_eq!(window.find(sidebar).label(), Some("命令片段")); - assert_eq!( - window.find("tool-placeholder").label(), - Some("常用的命令存在这里,点一下就发送到当前终端。") - ); + assert!(window.find("snippets").visible()); assert_eq!(window.find("tool-snippets").checked(), Some(true)); // Between the center and the switch. let bounds = window.find(sidebar).bounds(); @@ -828,13 +847,13 @@ fn a_tool_follows_the_terminal_in_front_and_leaves_it_the_keyboard(cx: &mut Test let sidebar = ("tool-sidebar", INITIAL_STAGING_TERMINAL); in_frame(cx, handle, |window, _| { assert!(window.try_find(sidebar).is_some()); - assert!(window.find("tool-placeholder").visible()); + assert!(window.find("snippets").visible()); }); // A click in the sidebar takes the focus there; hiding the sidebar // hands it back to the terminal, so the shortcuts keep working. in_frame(cx, handle, |window, cx| { - window.click("tool-placeholder", cx) + window.click("snippets-summary", cx) }); in_frame(cx, handle, |window, _| { assert_eq!(window.find(sidebar).focused(), Some(true));