30 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
项目是什么
shellr 是一个类似 Xshell / WinSCP 的 SSH 会话管理工具,基于 gpui-kit 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
会话和分组是真实持久化的,存在一个 SQLite 文件里(~/Library/Application Support/shellr/shellr.db,SHELLR_DATA_DIR 可覆盖目录)。本地终端(portable-pty + alacritty_terminal)和 SSH 远程连接(russh)也都是真的。密码与私钥口令存在系统钥匙串里(keyring),数据库里永远不出现秘密。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传;目录同步和传输队列尚未实现。详见 README.md。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页,操作逻辑对标 WinSCP Commander(两行工具栏、WinSCP 的列、列表右键菜单、高亮多选;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);暂不做传输队列、Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。
做 UI 之前先读 gpui-kit 与 gpui-kit-design-guides 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 ~/.cargo/registry/src/*/gpui-component-0.6.1/、gpui-base-0.6.1/ 的源码或 https://gpui-kit.com/component/<name>.md 为准。
常用命令
cargo run # 启动应用
cargo build # 首次构建会编译整个 GPUI 栈,很慢
cargo test # 单元测试(模块内)+ tests/ui.rs(无头窗口 UI 集成测试)
cargo test --lib # 只跑单元测试
cargo test --test ui # 只跑 UI 集成测试
cargo test --test ui new_session -- --nocapture # 按名称片段跑单个 UI 测试
cargo clippy --all-targets -- -D warnings # 必须无警告
cargo fmt --check
sqlite3 ~/Library/Application\ Support/shellr/shellr.db '.schema' # 查落盘结果
security find-generic-password -s shellr -a "password:<user>@<host>:<port>" # 查钥匙串条目
cargo test -- --ignored # 会读写真实钥匙串的测试,默认跳过
工具链:rust-toolchain.toml 固定 Rust 1.98.1,因为 gpui-pre 用到了 1.94 尚未稳定的 std API。Cargo.lock 把 gpui-pre* 系列固定在 0.3.2(gpui-kit 0.6.1 发布时配套的版本),不要随手 cargo update 它们,除非确认仍能编译。cargo test 需要 gpui-kit 的 test-support 特性,首次会再编译一遍(同样慢)。
本终端的 screencapture 被 macOS 权限拦截,视觉检查只能靠 UI 集成测试或让用户看窗口。
架构
单个二进制 crate,带 src/lib.rs,这样 tests/ui.rs 能驱动生产环境的 Workspace。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 workspace(窗口壳),也不得触碰彼此的内部实现。
app/—actions.rs定义全部用户命令(gpui_kit::actions!单元动作 + 携带SessionId/GroupId的session_action!/group_action!动作);paths.rs解析数据目录(SHELLR_DATA_DIR→dirs::data_dir()/shellr)与数据库路径;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())渲染。session/—model.rs(Session / SessionGroup / SessionDraft / GroupDraft /HostOs;Session.group与SessionGroup.parent都是Option<GroupId>,Session.os是Option<HostOs>)、database.rs(SessionDatabase:rusqlite 连接、PRAGMA user_version迁移、行与模型的映射)、store.rs(SessionStore实体,内存是唯一事实来源,写穿到数据库)、outline.rs(纯函数:store →TreeItem,递归铺开嵌套分组;节点 id 形如g:<id>/s:<id>;group_options给表单提供按全路径标注的分组列表)、session_panel.rs(左侧 Dock 面板:搜索 + 树 + 右键菜单)、session_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。远程会话的传输在ssh/,中间区的标签页是terminal_panel.rs。共享 prompt 协议位于connection.rs,transport.rs通过TerminalPrompt/TerminalPromptKind/TerminalSecret重导出保持兼容。ssh/—probe.rs(探测远端系统的命令与纯解析函数,外加驱动两步探测的HostOsProbe状态机)、transport.rs:真实的 russh 客户端。known_hosts 校验写<data_dir>/known_hosts(不碰~/.ssh/known_hosts),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查secrets,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时不删钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用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。- 会话对话框的「测试连接」是真登录。 会话模块不依赖
ssh:connection.rs定义LoginTest(表单当前值,秘密用Zeroizing,Debug打码)和ConnectionTester接口,工作区用和终端 / SFTP 共享的SshConnector造ssh::SshConnectionTester注入进去(Workspace::new_with_services的最后一个服务参数,UI 测试注入FakeConnectionTester)。测试用的秘密来源FormSecrets只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入。SshPrompts::non_interactive()让缺密码 / 口令 / 键盘交互直接以带类型的MissingCredential失败而不弹框,失败原因由tester.rs的describe_test_failure按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 secrets/— 系统钥匙串(keyring4:macOS Keychain / Windows 凭据管理器 / Secret Service)。SecretRef决定归属:密码按user@host:port(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。SecretStore的三个方法全是阻塞的,只能在后台执行器或传输层工作线程上调用。InMemorySecretStore给测试,NoSecretStore给没有钥匙串的机器(界面据此禁用密码字段)。explorer/— SFTP 标签页的界面。explorer_panel.rs(连接、事件轮询、execute执行全部ExplorerCommand、进度条)、file_pane.rs(一侧面板:两行工具栏、目录列表Select、路径栏、列表容器上的右键菜单和拖放)、file_listing.rs(DataTable委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、selection.rs(纯函数的多选模型)、history.rs(后退 / 前进)、pane_menu.rs(右键、书签、新建菜单)、pane_operations.rs(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、file_dialogs.rs(删除确认、重命名 / 新建的名称表单)、properties_dialog.rs(权限 3×3 与八进制,PermissionDraft是纯函数)、transfer_dialog.rs(上传 / 下载确认、冲突 / 续传 / 出错问答、关闭确认)、model.rs(FileEntry保存原始值,format_kilobytes/format_changed/format_rights负责显示)。ExplorerAction统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。sftp/— 可注入传输与本地目录接口(LocalDirectoryProvider也负责本地的废纸篓、重命名、新建和改权限)、RemotePath、russh-sftp 3.0.0 协议适配(RemoteFs:lstat / stat / read_dir / 读写 / rmdir / setstat;list解析longname得到所有者,并发stat解析链接目标)、operations.rs(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、upload.rs/download.rs(有界并发分块传输、.filepart、冲突与续传问答)、journal.rs(原子 JSON 续传记录:上传在upload-resume/,下载在download-resume/)、worker.rs(TransferBatch二选一、三次自动重连)。ssh/connection.rs的SshConnector与终端共享认证、钥匙串服务及 known_hosts 写锁。workspace/—workspace_view.rs持有SessionStore、DockArea、按会话登记的面板注册表,以及全部动作处理器;title_bar.rs、status_bar.rs、recent_sessions.rs(中间区没有标签页时显示的「最近连接」开始页,不是 Dock 面板)、dock_skin.rs(WorkspaceDockSkin:包一层DockSkin,中间区为空时用deferred把开始页画在空的中间区之上;工作区在DockEvent::LayoutChanged时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。shared/— 多个功能共用的展示片段(ClosableTabTitle、HostMark、close_tab_items)。
关键流程与不变量:
-
SFTP 独立于终端。 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除会话会停止传输并保留续传数据。每会话一个运行批次,上传和下载共用(
TransferBatch,类型统一叫Transfer*,方向在TransferProgress::direction),来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。 -
下载严格按顺序写盘。 读请求并发且可能乱序、短读返回,
download_file用重排缓冲区按偏移顺序写入.filepart,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。 -
文件操作从不跟随符号链接。 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走
trashcrate 的 NSFileManager 方式(不弹自动化权限、不放声音)。文件操作与传输批次可以并行;同一侧面板有操作在跑时(FilePane::is_busy)第二行工具栏和对应命令都不接受新操作,结果回来后面板重新读目录。 -
选择归面板,不归表格。
DataTable只支持单行选择,所以表格设成row_selectable(false),FilePane持有按名称保存的Selection(..永远不进选择集),通过ListingContext快照交给委托;render_tr自己画选中行的底色和光标框(绝对定位的子元素,不能用tr.bg,否则会被 hover 色盖掉)。行的on_click读ClickEvent的修饰键和点击次数,直接更新面板(这是事件时刻,不是渲染时刻)。键盘上的方向键 / Home / End / 翻页绑在"LocalFileList > DataTable"这一层,盖过表格自带的单行选择键;←、→ 绑NoAction,因为表格在行模式下也会把它们切成列选择。 -
文件列表快捷键按焦点路由。 一个动作
ExplorerShortcut(ExplorerCommand),侧别写进绑定本身;工作区找 focus handle 含焦点的那个 SFTP 面板(contains_focus),不用最近激活的标签,否则两个 SFTP 分栏并排时会发错。同一命令绑多个键时,最后注册的那个会显示在 tooltip 里(tooltip_with_action),所以 WinSCP 的键放最后。菜单项派发ExplorerAction,按动作相等查不到绑定,菜单里不显示快捷键。 -
列表右键菜单挂在列表容器
("file-list", side)上,和会话树一样:行的右键处理器先把没选中的行选中、把行名写进menu_hit,容器在捕获阶段清空它;委托的context_menu保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。 -
Select必须放进有尺寸的容器。 它的外层是size_full(),直接放进工具栏行会盖住整行,别的按钮全点不到。 -
书签按会话、按侧持久化。
bookmarks表(schema v6)对sessions有ON DELETE CASCADE;remove_unnotified/remove_group_unnotified必须同时丢掉内存里被删会话的书签。BookmarkSide放在 session 模块,因为 session 不能依赖 explorer。 -
SFTP UI 测试完全注入。 使用
Workspace::new_with_services注入目录与传输,不访问用户服务器和钥匙串;FakeLocalDirectory只记录本地操作,不真的改文件。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过ExplorerDispatch延后派发,避免重入实体。 -
一个命令一个处理器。 按钮、菜单、右键菜单、快捷键都只派发
app/actions.rs里的动作,由Workspace处理。新增命令加在那里,不要写临时闭包直接改状态。 -
持久化写穿,内存说了算。
SessionStore带Context的 mutator 负责「改内存 → 写库 →notify」,*_unnotified那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发SessionStoreEvent::PersistFailed,工作区订阅后弹一个错误通知——绝不静默吞掉。ConnectionState是运行时状态,不入库;last_connected_at入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的ON DELETE CASCADE,内存里的remove_group_unnotified必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。 -
主机系统每次连上都重探一遍。 认证过、shell 起来、
Started发出去之后,传输层另开一条 exec 通道跑uname -s; cat /etc/os-release,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次cmd /c ver,还是不认就放弃,会话保留原来的标记。结果沿TerminalTransportEvent::HostOsDetected→ 引擎 →TerminalView→TerminalPanel→ 工作区 →SessionStore::set_host_os这条链走,和 prompt 是同一套管线。os列由set_host_os单独写,update_session不碰它(同last_connected_at)。标记是shared::HostMark,会话树、开始页和远程终端标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回muted底色加会话名第一个字。徽章沿用Avatar的圆形画法,尺寸走Sizable——树里和远程终端标签上用small(20px),开始页用默认的medium(32px)。AlmaLinux和macOS没有单一品牌色,改用foreground/background,这也是 Apple 官方的画法。品牌色写在host_os!表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用plain_mark占同样宽度的槽位,否则标签对不齐。 -
秘密只进钥匙串,不进数据库。
database.rs的schema_never_contains_secret_columns守着这条线。写入的唯一入口是SessionStore::save_secret:它在后台线程上调钥匙串,失败同样走PersistFailed。会话换了端点或被删掉时,store 用纯函数password_in_use判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,*_loaded标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进SessionDraft,它派生了Debug。 -
id 由内存分配。
next_session_id/next_group_id两个独立计数器,SessionStore::load把它们置为库里最大 id + 1。SessionStore::seed()生产环境不再使用,是tests/ui.rs的夹具。 -
状态经由 store 流动。 面板和对话框拿到
Entity<SessionStore>的克隆,通过它的方法修改(方法内部会notify),消费方用cx.observe(&store, ..)。状态栏得知当前会话的路径是BasePanel::set_active→store.set_active;on_removed负责复位状态并发出TerminalPanelEvent::Closed/ExplorerPanelEvent::Closed,工作区据此清理注册表。 -
Dock 面板需实现
EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel,一律用panel_handle(...)包装;tab_name保持None,由title返回富元素——标签页的图标和「×」关闭按钮就是这样来的(ClosableTabTitle的「×」只派发面板的关闭动作,见下一条)。panel_name()是持久化键,一经选定不可更改。 -
标签页全部可关。
TabGroup::close_panel会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发CloseTerminal/CloseExplorer,⌘W 派发CloseActiveTab(关工作区记录的最近显示的中间标签,面板在set_active(true)时发出Activated事件告知),处理器一律用DockArea::remove_panel移除。中间区空了就显示最近连接列表:SessionStore::recent_sessions()按「最近一次变为已连接」的顺序给出,set_state变为 Connected 时把会话移到最前。 -
终端内容区的右键菜单与查找、清屏。
- 菜单:
TerminalView自己给出复制、粘贴、查找、清屏。面板通过set_menu_items在下面追加一段:远程终端追加打开 SFTP、重新连接、断开连接(DisconnectTerminal只断这一个标签);本地终端不追加。追加段的构造器在菜单打开时运行,拿到的是事先读好的TerminalLifecycle,因为这时TerminalView正在更新,读它会重入。 - 动作:
FindInTerminal/FindNextInTerminal/FindPreviousInTerminal/DismissTerminalFind/ClearTerminal和复制、粘贴一样,由工作区通过active_terminal转给当前标签的TerminalView。 - 快捷键:⌘K 在
Terminal上下文里是清屏,比全局的FocusSearch更深,所以优先生效。 - 查找是字面量加智能大小写(
RegexSearch本身就是智能大小写)。- 渲染时只扫视口,起止扩到所在逻辑行的首尾,每帧几十行。
- 全量扫描只在查询变化(去抖 80 ms)和上下切换时各做一次,而且是流式计数,不收集结果:一个字母可能匹配上百万次。release 构建里一万行乘 200 列约 15–55 ms。
- 焦点匹配按回滚历史的增长量跟着输出上移,再用
regex_search_right核对,对不上就丢掉。计数不随新输出刷新。
- 查找条必须是终端
div的兄弟元素,不能是子元素:否则输入框里敲的键会冒泡到终端的on_key_down,被发给 shell。它还要.occlude(),否则点它会被下面终端的鼠标处理器抢走焦点。 - 清屏只在本地模拟器里做:把光标所在的逻辑行(含折行)挪到第 0 行,清空回滚历史,远端不知情。
ALT_SCREEN下什么也不做,菜单项置灰。
- 菜单:
-
标签右键菜单挂在
ClosableTabTitle上。 dock 的Tab不归我们渲染,所以菜单只覆盖标题元素,Tab自己左右的内边距右键无反应。gpui 的on_click只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由shared::close_tab_items统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发CloseTabs { tab: CenterTab, scope: CloseScope };工作区按TabGroup::panels()的顺序用CloseScope::targets选出标签,再逐个走close_center_tab(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读TabGroup算置灰状态。标签栏「…」菜单(Panel::dropdown_menu)和右键菜单由同一个构造函数给出(远程终端是TabMenu,SFTP 和本地终端是各自文件里的tab_menu),两处不许分叉。三种中间区面板的BasePanel::closable都返回false:否则 Dock 会往「…」里再加一个自己的「关闭」,走TabGroup::close_panel,既重复又绕过上传确认;我们自己的关闭走DockArea::remove_panel,不受这个开关影响。远程终端标签的自定义名TerminalPanel::custom_title只活在内存里,和 Dock 布局一样不持久化;为空或等于会话名时回到跟随会话名。 -
焦点规则。
window.dispatch_action只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的FocusHandle::dispatch_action派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。中间区一次只渲染激活的那个标签页,所以每个面板都必须在set_active(true)里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见both_tabs_of_one_session_stay_closable)。 -
树的语义。 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠
ListItem上的ClickEvent::click_count() == 2。TreeState::set_items会清空选择,SessionPanel::rebuild_tree按 id 恢复选择,展开状态由它自己的expanded集合维护。对话框新建出来的节点靠SessionPanel比对known_groups/known_sessions快照来发现,然后展开祖先链并选中——对话框不需要回调面板。 -
树的右键菜单挂在容器上,不挂在行上。 gpui-component 的
ContextMenu在request_layout里给弹出菜单抢焦点,而树行是uniform_list在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(set_focus called more than once in a single frame,只在开了辅助功能的调试构建里出现)。所以SessionPanel把.context_menu(..)挂在#session-tree容器上,右键命中的节点由行上的右键处理器写进一个Rc<Cell<Option<SessionNode>>>(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的Table也是这么挂的。 -
对话框关闭后焦点会丢。 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后
window.dispatch_action没有落点,要先点一下面板里的东西。UI 测试里同理(见groups_and_sessions_are_read_back_from_the_database)。 -
通知层要
deferred并高过对话框。 gpui-base 的对话框(连同半透明背景)是用deferred(优先级 0)画的,开始页也是;通知层若作为普通子元素,就会画在对话框背景底下,只能隐约看到。所以工作区把Root::render_notification_layer包成deferred(..).with_priority(NOTIFICATION_PRIORITY)(= 1,低于菜单等弹出层的POPUP_PRIORITY= 100)。无头测试看不到绘制顺序(被盖住的元素照样能点中),这条只能靠肉眼确认。 -
要彩色就不能用
Icon。 GPUI 的svg()走Window::paint_svg,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进Icon会变成一坨纯色。真要彩色只能走img()(ImageSource),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。 -
渲染回调不得读取实体。 树行渲染闭包和
render_td运行在所属实体的 render 过程中,只能传入普通快照(例如已连接会话的Rc<HashSet<SessionId>>)。 -
px(..)只允许出现在 API 要求Pixels的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和cx.theme()token。
测试约定
UI 测试(tests/ui.rs)在无头窗口里渲染真实的 Workspace,通过 gpui_kit::test::TestWindowExt(click、double_click、input、press、find、within)驱动。Workspace::new 要求外部传入 Entity<SessionStore>:生产环境由 main.rs 从数据库加载,测试用 open_workspace_with_store 注入 SessionStore::seed()(或一个临时文件上的真库)。不要用右键菜单驱动测试:菜单项的 ElementId 只是序号,而且 PopupMenu 实体会让测试以「leaked handles」失败;直接 window.dispatch_action 派发菜单会派发的那个动作。测试模块里要显式导入类型,use gpui_kit::* 会遮蔽 #[test]。被测试查询的元素带稳定的领域 id,自定义 div 还要加 .test_support():("session-row", id)、("session-os", id) 与开始页的 ("recent-session-os", id)(aria_label 是系统名,未探测时是「未探测到系统」)、("group-row", id)、("terminal", id)、("explorer", id)、("local-pane"|"remote-pane", id)、("close-terminal", id)、("close-explorer", id)、标签标题 ("terminal-tab", id) / ("explorer-tab", session_id) / ("local-terminal-tab", id)(aria_label 是标签文字)、("terminal-tab-os", id)、test-connection、("terminal-latency", id)(aria_label 是「32 ms」或「超时」)、terminal-find(查找输入框)、terminal-find-count(aria_label 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、terminal-find-previous / -next / -close、tab-name(重命名标签对话框)、recent-sessions、("recent-session", id)、recent-new-session、session-search、new-session、new-group、theme-toggle、status-connection、commit、ok(确认对话框)、form-error、session-name / -host / -port / -user / -auth / -key-path / -password / -passphrase、group-name、remote-path。SFTP 相关的还有:各侧面板里的 path-select、bookmarks、up、root、home、refresh、back、forward、upload(本地)/ download(远程)、delete、rename、properties、new(同名按钮在两侧都有,要用 within((pane, id)) 查)、行 file:<名称> 与名称单元格 name:<名称>(aria_selected 表示已选中)、("file-list", 0|1)、entry-name(重命名 / 新建表单)、("perm", 0..9) 与 perm-octal / perm-recursive / perm-dir-x(属性对话框)、download-confirm / download-target / download-browse、进度条的 transfer-status / cancel-transfer / resume-transfer / discard-transfer / transfer-details、问答的 transfer-question-* 与 transfer-apply-all。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(focused().is_some());TestWindowExt::click 不带修饰键,⌘ / Shift 单击用 modified_click 助手直接发鼠标事件。SFTP 测试的夹具会 set_reduce_motion(true):对话框按真实时间滑入,小目标(复选框、「浏览…」)会在定位和点击之间移位。被遮蔽的输入框在测试里读不到值(ElementSnapshot::value() 返回 None),所以密码相关的断言一律查注入的 InMemorySecretStore,不查界面。动作派发和 set_active 都是延迟执行的:先退出 update_window,调用 cx.run_until_parked(),再 render_frame 后断言。种子会话 id 按插入顺序(web-01=1 … dev-box=6),种子分组同理(生产=1、测试=2、开发=3);web-01 与 staging-api 初始为已连接并已打开终端标签。