From e7fcc7ca40e04b7c6c67b21388f03d4f25507a46 Mon Sep 17 00:00:00 2001 From: Xuz Date: Sat, 3 Oct 2026 13:03:18 +0800 Subject: [PATCH] =?UTF-8?q?Docker=EF=BC=9A=E5=90=8C=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E6=9C=89=E5=A4=9A=E4=B8=AA=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E6=97=B6=E6=AF=8F=E4=B8=AA=E6=A0=87=E7=AD=BE=E4=B8=80=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E6=8C=89=E6=A0=87=E7=AD=BE=E6=9F=A5=E7=9C=8B=E5=92=8C?= =?UTF-8?q?=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker images 每个「仓库:标签」一行,同一镜像的几行镜像 ID 相同,卡片和删除 按钮的 id 因此重复:调试版构建在镜像页直接崩溃,正式版里详情和删除只认第一 行,按 ID 删多标签镜像还会被 Docker 拒绝。现在每行按 Image::key 认:有标签 用「仓库:标签」(删除只去掉这个标签),没有标签用镜像 ID。只有仓库名的镜像 显示为「仓库:」、按 ID 操作,不再被当成 仓库:latest;valid_id 允许 /, 带仓库地址的镜像可以删除和查看详情。 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +-- src/docker/docker_panel.rs | 2 +- src/docker/linux.rs | 70 +++++++++++++++++++++++++++++++++++--- src/docker/model.rs | 33 +++++++++++++----- tests/ui/docker.rs | 63 ++++++++++++++++++++++++++++++++-- 5 files changed, 153 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 44820ba..f2906d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **右侧栏是右侧 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 })`,系统服务的命令也用它。 +- **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`(字母数字开头,只许字母数字和 `_.:-/`,`/` 是给带仓库地址的镜像名的),加双引号。`docker images` 每个「仓库:标签」一行,同一个镜像打了几个标签就有几行、镜像 ID 相同,所以镜像按 `Image::key` 认:有标签的是 `仓库:标签`,没有标签的是镜像 ID(只有仓库名的 `nginx` 会被 Docker 当成 `nginx:latest`,显示成 `nginx:`,也只按 ID 算在用);卡片 id、详情(`image inspect`)和删除(`rmi`,有别的标签时只去掉这一个)都用它,`DockerTable::new` 按键去重。卡片 id 重复时调试版构建会因无障碍节点 id 重复直接 panic(用户在镜像页碰到过)。`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 键:到行尾、删掉前面所有的,替换已经输入的内容;`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` 按第一项的高度排)。 @@ -200,4 +200,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`(系统监控)、`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` / `workbuddy`,状态行的 `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-ready`(已下载好时的那句)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `open-changelog`(点了查 `cx.opened_url()`)/ `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` / `workbuddy`,状态行的 `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:<键>`(`仓库:标签`,没有标签的是镜像 ID)/ `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-ready`(已下载好时的那句)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `open-changelog`(点了查 `cx.opened_url()`)/ `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/src/docker/docker_panel.rs b/src/docker/docker_panel.rs index 1f449a2..cb8c0c4 100644 --- a/src/docker/docker_panel.rs +++ b/src/docker/docker_panel.rs @@ -369,7 +369,7 @@ impl DockerPanel { DockerRow::Image(index) => { let image = &table.images()[*index]; render_object( - format!("docker-image:{}", image.id).into(), + format!("docker-image:{}", image.key).into(), image.summary(), &dispatch, cx, diff --git a/src/docker/linux.rs b/src/docker/linux.rs index 2046aa0..5a4d609 100644 --- a/src/docker/linux.rs +++ b/src/docker/linux.rs @@ -41,14 +41,15 @@ pub fn command() -> String { } /// Whether `id` is an ID or a name Docker gave or allows, and nothing else, -/// so that it can go into a command. +/// so that it can go into a command. An image's `repository:tag` may name +/// a registry, so `/` is allowed too (`registry.example.com/app:1.0`). pub fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= 255 && id.starts_with(|character: char| character.is_ascii_alphanumeric()) && id .chars() - .all(|character| character.is_ascii_alphanumeric() || "_.:-".contains(character)) + .all(|character| character.is_ascii_alphanumeric() || "_.:-/".contains(character)) } /// `script` with `$d` for `docker`, saying how it went in an `@@status` @@ -274,10 +275,20 @@ fn volume(object: &Value) -> Volume { fn image(object: &Value) -> Image { let (repository, tag) = (text(object, "Repository"), text(object, "Tag")); + let id = text(object, "ID").trim_start_matches("sha256:").to_owned(); + let tagged = repository != "" && !matches!(tag, "" | ""); Image { - id: text(object, "ID").trim_start_matches("sha256:").to_owned(), + // 「nginx」 alone would be read as nginx:latest, another image. + key: if tagged { + format!("{repository}:{tag}") + } else { + id.clone() + }, + id, + // 「nginx:」 as `docker images` puts it: plain 「nginx」 would + // say nginx:latest, and the containers of that would count as its. reference: (repository != "").then(|| match tag { - "" | "" => repository.to_owned(), + "" | "" => format!("{repository}:"), tag => format!("{repository}:{tag}"), }), size: format_size(text(object, "Size")), @@ -714,6 +725,54 @@ v2.25.0 assert!(table.volumes()[0].used_by.is_empty()); } + #[test] + fn an_image_with_several_tags_is_a_line_for_each_named_by_its_tag() { + let table = table( + "@@version\n26.0.0\n@@containers\n@@volumes\n@@images\n\ + {\"ID\":\"aaa111\",\"Repository\":\"nginx\",\"Tag\":\"latest\",\"Size\":\"187MB\"}\n\ + {\"ID\":\"aaa111\",\"Repository\":\"nginx\",\"Tag\":\"1.27\",\"Size\":\"187MB\"}\n\ + {\"ID\":\"aaa111\",\"Repository\":\"registry.example.com/web/nginx\",\"Tag\":\"prod\",\"Size\":\"187MB\"}\n\ + {\"ID\":\"bbb222\",\"Repository\":\"nginx\",\"Tag\":\"\",\"Size\":\"180MB\"}\n\ + {\"ID\":\"bbb222\",\"Repository\":\"mirror/nginx\",\"Tag\":\"\",\"Size\":\"180MB\"}\n\ + @@networks\n", + ); + let keys: Vec<&str> = table + .images() + .iter() + .map(|image| image.key.as_str()) + .collect(); + // 「nginx」 with no tag is not nginx:latest: it goes by its ID, and + // once, though two repositories hold it. + assert_eq!( + keys, + [ + "nginx:1.27", + "nginx:latest", + "registry.example.com/web/nginx:prod", + "bbb222" + ] + ); + assert_eq!( + table.images()[3].reference.as_deref(), + Some("mirror/nginx:") + ); + let summary = table + .summary_of(DockerObject::Image, "nginx:1.27") + .expect("by its tag"); + assert_eq!(summary.name, "nginx:1.27"); + assert_eq!(summary.id, "nginx:1.27"); + // Removing a line removes that tag, wherever the registry is. + assert_eq!( + remove_command(DockerObject::Image, "registry.example.com/web/nginx:prod").as_deref(), + Some( + "sh -c 'export LC_ALL=C PATH=$PATH:/usr/local/bin:/opt/homebrew/bin; \ + if test \"$(id -u)\" = 0 || docker version >/dev/null 2>&1; then d=docker; \ + else d=\"sudo -n docker\"; fi; $d rmi \"registry.example.com/web/nginx:prod\" 2>&1; \ + echo @@status $?'" + ) + ); + } + #[test] fn a_docker_that_will_not_answer_says_why() { assert_eq!( @@ -853,7 +912,10 @@ v2.25.0 assert!(valid_id("f8664a4a9b8d")); assert!(valid_id("vw-data")); assert!(valid_id("sha256:4a3b5c")); + assert!(valid_id("registry.example.com:5000/team/app:1.0")); assert!(!valid_id("-rf")); + assert!(!valid_id("/etc")); + assert!(!valid_id("a\"b")); assert!(!valid_id("a b")); assert!(!valid_id("$(reboot)")); assert_eq!(remove_command(DockerObject::Volume, "a;b"), None); diff --git a/src/docker/model.rs b/src/docker/model.rs index 2f7caa6..eda6a50 100644 --- a/src/docker/model.rs +++ b/src/docker/model.rs @@ -88,8 +88,13 @@ pub struct Volume { #[derive(Clone, Debug, PartialEq)] pub struct Image { - /// 「4a3b5c6d7e8f」. + /// 「4a3b5c6d7e8f」. Not unique in the list: an image tagged twice is + /// two of its lines, one per tag. pub id: String, + /// What a command names this line by, unique in the list: 「nginx:1.27」, + /// or the ID for an image without a tag. Removing a tag removes the + /// image only with its last one, as `docker rmi nginx:1.27` does. + pub key: String, /// 「nginx:1.27」, `None` for a dangling image (「」). pub reference: Option, /// 「187.69 MB」. @@ -306,14 +311,23 @@ impl DockerTable { }) || (by_id && (id.starts_with(&image.id) || image.id.starts_with(id))) }); } - // Named ones first, by name; the dangling after. + // Tagged ones first, by name; then those without a tag, the ones + // without a repository either last. images.sort_by(|a, b| { - (a.reference.is_none(), &a.reference, &a.id).cmp(&( - b.reference.is_none(), - &b.reference, - &b.id, - )) + let order = |image: &Image| { + ( + image.key == image.id, + image.reference.is_none(), + image.reference.clone(), + image.id.clone(), + ) + }; + order(a).cmp(&order(b)) }); + // One card per key: an image without a tag in two repositories is + // one image. + let mut seen = std::collections::HashSet::new(); + images.retain(|image| seen.insert(image.key.clone())); for network in &mut networks { network.used_by = using(&|container| container.networks.contains(&network.name)); @@ -352,7 +366,7 @@ impl DockerTable { DockerObject::Image => self .images .iter() - .find(|image| image.id == id) + .find(|image| image.key == id) .map(Image::summary), DockerObject::Network => self .networks @@ -496,7 +510,7 @@ impl Image { let in_use = !self.used_by.is_empty(); ObjectSummary { object: DockerObject::Image, - id: self.id.clone(), + id: self.key.clone(), name: self .reference .clone() @@ -759,6 +773,7 @@ mod tests { }; let image = |id: &str, reference: Option<&str>| Image { id: id.into(), + key: reference.unwrap_or(id).into(), reference: reference.map(str::to_owned), size: String::new(), created: String::new(), diff --git a/tests/ui/docker.rs b/tests/ui/docker.rs index 5b9d2ff..4445dd0 100644 --- a/tests/ui/docker.rs +++ b/tests/ui/docker.rs @@ -193,7 +193,7 @@ async fn the_other_tabs_list_volumes_images_and_networks(cx: &mut TestAppContext }); in_frame(cx, handle, |window, _| { assert_eq!( - window.find("docker-image:4a3b5c6d7e8f").label(), + window.find("docker-image:hello-world:latest").label(), Some("hello-world:latest · 4a3b5c6d7e8f · 13.3 kB · 2026-09-07 · 使用中") ); }); @@ -326,6 +326,63 @@ const NETWORK: &str = r#"[{"Name":"bridge","Id":"n1","Driver":"bridge","Scope":" "IPAM":{"Config":[{"Subnet":"172.17.0.0/16","Gateway":"172.17.0.1"}]}, "Containers":{"ccc333":{"Name":"forex-web","IPv4Address":"172.17.0.2/16"}}}]"#; +/// One image under two tags, and one without a tag. +const TAGGED_TWICE: &str = r#"@@version +26.0.0 +@@compose +v2.25.0 +@@containers +@@volumes +@@images +{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"latest"} +{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"1.27"} +{"CreatedAt":"2026-08-01 08:00:00 +0800 CST","ID":"ccc333ddd444","Repository":"nginx","Size":"180MB","Tag":""} +@@networks +"#; + +#[gpui_kit::test] +async fn an_image_under_two_tags_is_two_cards_each_removed_by_its_tag(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[ + TAGGED_TWICE, + "Untagged: nginx:1.27\n@@status 0\n", + TAGGED_TWICE, + ])); + let handle = open_docker(cx, factory.clone()); + wait_for_reading(cx, handle).await; + in_frame(cx, handle, |window, cx| { + window.within("docker-tabs").click(2usize, cx) + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find("docker-image:nginx:1.27").label(), + Some("nginx:1.27 · aaa111bbb222 · 187 MB · 2026-09-07") + ); + assert_eq!( + window.find("docker-image:nginx:latest").label(), + Some("nginx:latest · aaa111bbb222 · 187 MB · 2026-09-07") + ); + // Without a tag, by its ID. + assert_eq!( + window.find("docker-image:ccc333ddd444").label(), + Some("nginx: · ccc333ddd444 · 180 MB · 2026-08-01") + ); + }); + + in_frame(cx, handle, |window, cx| { + window.click("docker-image:nginx:1.27-remove", cx) + }); + in_frame(cx, handle, |window, cx| window.click("ok", cx)); + cx.wait_for(handle.into(), Duration::from_secs(3), |_, _| { + factory.exec_count() >= 2 + }) + .await; + assert!( + factory.exec_commands()[1].contains("$d rmi \"nginx:1.27\""), + "{:?}", + factory.exec_commands() + ); +} + #[gpui_kit::test] async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut TestAppContext) { let factory = Arc::new(FakeTerminalFactory::answering(&[ @@ -360,7 +417,7 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te window.within("docker-tabs").click(2usize, cx) }); in_frame(cx, handle, |window, cx| { - window.click("docker-image:4a3b5c6d7e8f", cx) + window.click("docker-image:hello-world:latest", cx) }); wait_for_label(cx, handle, "image-basics:平台", "linux/amd64").await; in_frame(cx, handle, |window, _| { @@ -371,7 +428,7 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te Some("PATH=/usr/bin") ); }); - assert!(factory.exec_commands()[2].contains("$d image inspect \"4a3b5c6d7e8f\"")); + assert!(factory.exec_commands()[2].contains("$d image inspect \"hello-world:latest\"")); in_frame(cx, handle, |window, cx| window.press("escape", cx)); in_frame(cx, handle, |window, cx| {