表结构发布前重构,「会话」在代码和数据库里统一改名为 Host

- 数据库升到第 13 版:主机的登录、连接方式、代理和备注合进 hosts 一行,只有一对多的跳板、书签和端口转发单独成表;全部表改为 STRICT,并用 CHECK 写明约束;用凭据的主机不再冗余存用户名,凭据外键不再级联,在用的凭据删不掉
- 表名 sessions → hosts、session_jumps → host_jumps,session_id → host_id,地址列 host → address;schema 比对去掉排版,以后加字段可以直接 ADD COLUMN
- 删除开发期的历史迁移代码:版本 1–12 的库直接拒绝打开、不动文件
- 代码里 Session / SessionId / SessionStore 等改为 Host*,模块 session/ → host/,Host.host → Host.address,菜单命令与界面元素 id 同步改名
- 外部 CLI 的参数、JSON 字段和错误码改为 host(host_not_found),协议版本升到 2
- CLAUDE.md、AGENTS.md、README 与 Agent skill 同步更新
This commit is contained in:
Xuz committed 2026-10-01 14:04:30 +08:00
1 parent f0690fe6b2
commit ef83812f45
79 files changed
+3955 -4606

No files matched your search

+21 -21
View File
@@ -4,11 +4,11 @@
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
**界面上叫「主机」,代码里叫 `Session`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」;它以前叫「会话」,代码标识符(`Session`、`SessionStore`、`session/` …)没有跟着改,本文件里说的「会话」指的就是它。新写的界面文案一律用「主机」,不要再出现「会话」;填 IP / 域名的那个字段叫「地址」,不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。协议术语「SSH 会话通道」不算。
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个主机独立的「SFTP」Dock 标签页;暂不做传输队列和 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` 为准。
@@ -20,7 +20,7 @@ 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 test --test ui new_host -- --nocapture # 按名称片段跑单个 UI 测试
cargo clippy --all-targets -- -D warnings # 必须无警告
cargo fmt --check
@@ -37,37 +37,37 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui.rs` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 的 `session_action!` / `group_action!` 动作);`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())` 渲染。
- `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`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 的 `host_action!` / `group_action!` 动作);`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())` 渲染。
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`)、`database.rs`(`HostDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`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 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
- `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`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` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `update/` — 在线升级:`feed.rs` 从 `dl.shellrs.com` 取已签名的清单和安装包(reqwest,走系统代理),`verify.rs` 先验 minisign 签名(trusted comment 绑定通道和版本)再信清单、按大小和 SHA-256 信安装包,`install*.rs` 按安装方式装(macOS 整包交换、Windows 运行 Inno 安装程序、Linux 覆盖 AppImage),`updater.rs` 的 `Updater` 实体管检查、下载和重启,工作线程的事件由 UI 定时轮询。不依赖 `workspace` 和 `settings`;`new_with_services` 里的 `Updater` 没有服务、从不联网,生产路径才 `set_services` + `start`,UI 测试经 `workspace.updater()` 注入假的。详见 `CLAUDE.md` 和 `README.md` 的「下载与更新」「发布」。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。
关键流程与不变量:
- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止上传,断开 / 删除会话会停止上传并保留续传数据。每会话一个运行批次,来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **SFTP 独立于终端。** 主机状态综合所有终端与 SFTP 状态计算;关闭终端不停止上传,断开 / 删除主机会停止上传并保留续传数据。每主机一个运行批次,来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串。工作线程事件由 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`(按查好凭据之后的登录快照 `SessionLogin` 判断)看还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_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`,工作区据此清理注册表。
- **持久化写穿,内存说了算。** `HostStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `HostStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删主机的 id 返回给工作区去关标签页。
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,主机保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `HostStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_host` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,主机树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加主机名称第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用 `password_in_use`(按查好凭据之后的登录快照 `HostLogin` 判断)看还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`。
- **id 由内存分配。** `next_host_id` / `next_group_id` 两个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1。`HostStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<HostStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`SessionStore::recent_sessions()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把会话移到最前。
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`HostStore::recent_hosts()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把主机移到最前。
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦主机面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`HostPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `HostPanel` 比对 `known_groups` / `seen_hosts` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_hosts_are_read_back_from_the_database`)。
- **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如已连接会话的 `Rc<HashSet<SessionId>>`)。
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如已连接主机的 `Rc<HashSet<HostId>>`)。
- `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)`、`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`。被遮蔽的输入框在测试里读不到值(`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` 初始为已连接并已打开终端标签。
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`host-name` / `host-address` / `host-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`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` 初始为已连接并已打开终端标签。
+47 -47
View File
@@ -6,11 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
**界面上叫「主机」,代码里叫 `Session`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」;它以前叫「会话」,代码标识符(`Session`、`SessionStore`、`session/` …)没有跟着改,本文件里说的「会话」指的就是它。新写的界面文案一律用「主机」,不要再出现「会话」;填 IP / 域名的那个字段叫「地址」,不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。协议术语「SSH 会话通道」不算。
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和会话存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),会话可以「使用凭据」而不是自己填登录信息。会话还有「连接方式」:直接连接、经过别的会话做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。详见 `README.md`。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。详见 `README.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:会话「使用凭据」时不再填用户名,改凭据的用户名所有用它的会话一起变;会话对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);会话自己不再有私钥文件,要用私钥就建密钥凭据(版本 10 的迁移把原来手动选私钥的会话按「用户 + 私钥文件」各建一条密钥凭据,原来的「自动」归为「密码」);密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(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`),连接时直Line truncated
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
做 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` 为准。
@@ -22,7 +22,7 @@ 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 test --test ui new_host -- --nocapture # 按名称片段跑单个 UI 测试
cargo test --test cli_bin # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑)
cargo test --lib update:: # 在线升级;装了 minisign 和 jq 时还验证发布脚本签出的清单客户端认
cargo clippy --all-targets -- -D warnings # 必须无警告
@@ -41,24 +41,24 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui.rs` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs` / `PublicId`;`Session.group` 与 `SessionGroup.parent` 都是 `Option<GroupId>`,`Session.os` 是 `Option<HostOs>`,`Session.credential` 是 `Option<CredentialId>`,`Session.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<SessionId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Session.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 session 里的理由同 `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`(`SessionLogin` / `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` 都是纯函数;放在 session 里是因为规则和会话同库、随会话级联删除,而 session 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`SessionDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `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`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),终端、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` 先按 `SessionLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `SessionLogin` 构造;`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 秒用 `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` 打码;`LoginTest::typed` 是会话自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板会话的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),终端、SSH、SFTP 和工作区都直接用这些类型。
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `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` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `forward/` — 端口转发。规则的模型在 `session/forward.rs`,这里是运行和界面。
- `forward/` — 端口转发。规则的模型在 `host/forward.rs`,这里是运行和界面。
- `transport.rs`:可注入的接口 `ForwardTransport` / `ForwardTransportProvider`,命令 `ForwardCommand`(`PromptReply`、`Stop`)和事件 `ForwardEvent`(`Connecting`、`Prompt`、`Listening`、`Connections(n)`、`ConnectionFailed`、`Reconnecting`,以及两个终结事件 `Failed` / `Stopped`)。
- `worker.rs`:真实实现 `SshForwardTransportProvider`。每次启动一条规则就是一个线程(`shellrs-forward`)加一个 current-thread runtime,结构照 `sftp/worker.rs`;本地转发是 `channel_open_direct_tcpip` 加 `copy_bidirectional`,远程转发是 `tcpip_forward` 加处理器交出来的通道,动态转发先过 SOCKS 握手。`describe_*` 把错误映射成中文。
- `socks.rs`:SOCKS5(无认证、`CONNECT`)与 SOCKS4 / 4a 的纯函数解析(`Parsed::{Incomplete, Done, Invalid}`)加一个喂字节的 `negotiate`。没有引入 SOCKS 依赖。
- `manager.rs`:`ForwardManager` 实体,持有每条在跑的规则的工作线程和状态 `ForwardStatus`,每 50 ms 轮询事件(不从后台线程唤醒 GPUI);自己订阅 store:规则被删(含随会话级联)就停,`ForwardSettingsChanged` / `ConnectionSettingsChanged` 就重启在跑的。
- `manager.rs`:`ForwardManager` 实体,持有每条在跑的规则的工作线程和状态 `ForwardStatus`,每 50 ms 轮询事件(不从后台线程唤醒 GPUI);自己订阅 store:规则被删(含随主机级联)就停,`ForwardSettingsChanged` / `ConnectionSettingsChanged` 就重启在跑的。
- `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。
- `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。
- `credential/` — 凭据的界面,模型在 `session/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `session::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `SessionStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-sessions` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是会话树里会话的那个菜单,两处都调用 `session::session_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
- `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
- `protocol.rs`:套接字上的帧:1 字节类型(`J` JSON / `1` stdout / `2` stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;`ErrorCode` 是给 Agent 判断用的稳定英文码。`Request::Activate` 不属于 `shellrs` 命令,是 ShellRS 被再次打开时发的(见下面「一个数据目录只跑一个 ShellRS」)。
@@ -79,52 +79,52 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
关键流程与不变量:
- **传输队列在标签这一层,引擎一次只跑一批。** `ExplorerPanel` 持有 `TransferQueue`:确认框派发的 `ExplorerCommand::Enqueue(TransferJob)` 只是入队(请求在入队时就校验,失败直接报错不入队),`start_next_if_idle` 在引擎空闲(`engine_busy` 为假,它由发出 Upload / Download / Resume / Discard 置真、由引擎的 `Idle` 清掉)且已连接时把第一个排队中的批次交出去。引擎处理完每个命令都会发 `Idle`(完成、停止、丢弃、准备失败都一样),标签收到后才推进,所以前一批的事件不会串到下一批上。进度事件只归队首(运行中或已停止的那一批),没有队首时丢弃(例如丢弃续传进度后引擎回的空 `Completed`)。**已停止的批次挡在队首**:引擎还留着它等「继续」,开始新批次会把它换掉,所以要等用户继续、丢弃续传进度或「移出队列」(续传记录留在磁盘上)之后队列才往下走。确认框和询问框分开记录(`question_shown`):批次停止或结束时只关询问框,不碰用户正在填的下一批的确认框。`TransferProgress` 为队列补了当前文件(`current_source` / `current_bytes` / `current_total`)、只在传输阶段累计的用时 `elapsed`、最近 3 秒的速度(`sftp/speed.rs` 的 `TransferClock`,等待回答和重连时归零)、跳过和失败文件的字节 `settled_bytes`(`fraction()` 因此能到 100%,`remaining()` 按剩余字节除以速度)和重连说明 `note`(`current` 始终是路径)。队列面板和双栏在同一个 `v_resizable` 里,队列为空时那一块 `visible(false)`,双栏在元素树里的位置不变,分栏比例不丢。
- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除会话会停止传输并保留续传数据。每个 SFTP 标签的传输引擎一次跑一个批次,其余在标签的传输队列里等,上传和下载共用(`TransferBatch`,类型统一叫 `Transfer*`,方向在 `TransferProgress::direction`),来源、端点和目标在确认时固定。SFTP 标签按 `ExplorerId` 登记(工作区的 `explorers`,`explorers_of(session)` 列出一个会话的全部 SFTP 标签),`ExplorerAction`、`CloseExplorer`、`RenameExplorer`、`CenterTab::Explorer` 和 SFTP 认证问答的 `PromptOwner::Sftp` 都按标签 id 寻址;按会话做的事(连接状态汇总、断开、删除会话 / 分组、统计要停止的传输)要遍历 `explorers_of`。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **SFTP 独立于终端。** 主机状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除主机会停止传输并保留续传数据。每个 SFTP 标签的传输引擎一次跑一个批次,其余在标签的传输队列里等,上传和下载共用(`TransferBatch`,类型统一叫 `Transfer*`,方向在 `TransferProgress::direction`),来源、端点和目标在确认时固定。SFTP 标签按 `ExplorerId` 登记(工作区的 `explorers`,`explorers_of(host)` 列出一个主机的全部 SFTP 标签),`ExplorerAction`、`CloseExplorer`、`RenameExplorer`、`CenterTab::Explorer` 和 SFTP 认证问答的 `PromptOwner::Sftp` 都按标签 id 寻址;按主机做的事(连接状态汇总、断开、删除主机 / 分组、统计要停止的传输)要遍历 `explorers_of`。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **下载严格按顺序写盘。** 读请求并发且可能乱序、短读返回,`download_file` 用重排缓冲区按偏移顺序写入 `.filepart`,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。
- **文件操作从不跟随符号链接。** 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走 `trash` crate 的 NSFileManager 方式(不弹自动化权限、不放声音)。文件操作与传输批次可以并行;同一侧面板有操作在跑时(`FilePane::is_busy`)第二行工具栏和对应命令都不接受新操作,结果回来后面板重新读目录。
- **选择归面板,不归表格。** `DataTable` 只支持单行选择,所以表格设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`(`..` 永远不进选择集),通过 `ListingContext` 快照交给委托;同 WinSCP 关掉「整行选择」,**名称单元格才是项目**:`render_td` 给出外层 `name-cell:<名称>`(`size_full`,画悬停灰底、选中底色和光标框,接单击、双击打开和右键,`FileListing::name_events`)和里面只有内容宽的 `name:<名称>`(图标加文件名,只接文件拖动)。所以在名称单元格里任何地方单击都选中这一行,但只有拖图标和文件名才是拖文件,从名称右边的空白拖是框选。行上不画任何状态:`DataTable` 在 `render_tr` 的样式之后给整行加 hover 底色、我们改不掉,所以行的第一个子元素是一块铺满的 `tokens.table` 底色(表格自己的不透明背景)把它盖住,单元格画在它上面;右键时表格给整行画的边框也在容器的右键处理里立刻清掉(`press_empty` 每次都 `clear_menu_row`)。为了连单元格的内边距一起铺满,名称列用 `name_column` 把表格的单元格内边距设成 0,名称单元格和它的列标题各自 `px_1p5()`(等于 `.small()` 表格的 6 px,文字因此和标题对齐);`Size::table_cell_padding` 不是公开 API,拿不到表格自己的值。单击、双击打开、右键都挂在名称上(`FileListing::name_events`),行上只剩拖放目标和记几何的 canvas。选中的名称一律只有底色,光标框只画在未选中的光标项上(`..`、刚用 ⌘ 取消选择的项),否则多选的最后一项会多出一圈边框。名称单元格的 `on_click` 读 `ClickEvent` 的修饰键和点击次数,直接更新面板(这是事件时刻,不是渲染时刻)。名称单元格和列标题以外都是空白处:按下落在哪里记在 `ListGeometry::pressed`(`Pressed::Empty` / `Name` / `Title`,容器在捕获阶段复位成 `Empty`,名称和标题在冒泡时改写),容器的 `on_click` 和右键按下在 `Empty` 且没按 ⌘ / Shift 时清空选择(`FilePane::press_empty`,连光标一起清)。表格底部的横向滚动条会在按下时停止冒泡,点在它上面不算空白处。键盘上的方向键 / Home / End / 翻页绑在 `"LocalFileList > DataTable"` 这一层,盖过表格自带的单行选择键;←、→ 绑 `NoAction`,因为表格在行模式下也会把它们切成列选择。
- **文件列表快捷键按焦点路由。** 一个动作 `ExplorerShortcut(ExplorerCommand)`,侧别写进绑定本身;工作区找 focus handle 含焦点的那个 SFTP 面板(`contains_focus`),不用最近激活的标签,否则两个 SFTP 分栏并排时会发错。同一命令绑多个键时,最后注册的那个会显示在 tooltip 里(`tooltip_with_action`),所以 WinSCP 的键放最后。菜单项派发 `ExplorerAction`,按动作相等查不到绑定,菜单里不显示快捷键。
- **框选同 WinSCP 关掉「整行选择」。** 拖文件去另一侧的 `on_drag` 只挂在名称单元格(图标加文件名,`FileListing::drag_from`)上,不挂在行上;名称上不带 ⌘ / Shift 的左键按下会在按下时就把未选中的项单独选中(`FilePane::select_pressed`,右键按下也用它),所以拖走的就是高亮的:未选中的项单独走,已选中的项带着整个选择走(拖动的载荷在 render 时按选择算好,按下前后算出的都是这一项,不会不一致)。拖动中只有指针在接收它的另一侧列表上(且可以传输)时才显示「上传 / 下载 N 个项目」并用 `DragCopy` 光标,其余地方(包括自己的列表)不显示预览、光标是 `OperationNotAllowed`(WinSCP 同):载荷带一个 `DropSpot`,接收侧容器的 `on_drag_move` 在指针落在自己范围内时记下指针位置,预览在 render 时比较它和 `window.mouse_position()`。存位置而不是布尔值,是因为 `on_drag_move` 会通知所有监听这个类型的元素(并排的另一个 SFTP 标签也会收到),「不在我这儿就清掉」会被后调用的覆盖。光标由预览里一个 canvas 在 paint 时 `set_window_cursor_style`:拖动层最后绘制,所以它的光标请求最后一个入栈、生效;测试平台不暴露光标,这点只能肉眼确认,测试只查预览 `file-drag-preview`(`aria_label` 是显示的文字)。列表容器 `("file-list", side)` 自己挂一个 `MarqueeDrag`,它带着开启它的面板的 `EntityId`:GPUI 的 `on_drag_move` 会把同一类型的拖动通知给**所有**挂了监听的元素,不管是谁开启的,所以监听里要核对 `event.drag(cx).pane == cx.entity_id()`,否则另一侧面板(以及别的 SFTP 标签)会跟着画框、改选择。所以从名称以外按下拖动就是框选(GPUI 的拖动由最上层先达到阈值的元素开启并停止冒泡,名称的拖动因此优先;滚动条在按下时就停止冒泡,不会触发框选)。容器在按下时记下位置,从列标题(`Pressed::Title`)开始的拖动不画框,其他地方都画(名称的图标和文件名有自己的文件拖动,GPUI 由它先开启,所以轮不到框选)。行高固定,每行用绝对定位的 `canvas`(**必须** `top_0().left_0()`)在 prepaint 记下「第一行顶端的位置和行高」(`ListGeometry::rows`),选框的起点按「距第一行顶端」保存,所以列表滚动时起点跟着内容走;框住哪些行由纯函数 `selection::swept_rows` / `row_at` 算,`Selection::sweep` 合并(开始时按住 ⌘ 或 Shift 则加到原有选择上)。拖到边缘用 `gpui_kit::base::AutoScroll` 滚动表格的 `vertical_scroll_handle`。拖动中 GPUI 每画一帧都会清掉各元素挂起的按下(`div.rs` 里 `has_active_drag` 时清 `pending_mouse_down`),所以松开时起点那个名称单元格不会再收到单击;拖动开始和松开落在同一帧里(快速一甩)时这条不成立,名称单元格的单击因此还用 `ends_a_drag`(按下和松开相距超过 GPUI 的 2 px 拖动阈值)排除掉;松开(`on_mouse_up` / `on_mouse_up_out`)时清掉选框并停止滚动。选框是容器里的绝对定位 `selection-rectangle`,夹在列表范围内。
- **列表右键菜单挂在列表容器 `("file-list", side)` 上**,和会话树一样:名称的右键处理器先把没选中的项选中、把 `MenuHit::Item(名称)` 写进 `menu_hit`(右键空白处则清空选择,给空白处菜单),列标题(委托的 `render_th`)写 `MenuHit::Column(列键)`,容器在捕获阶段清空它;容器的菜单构造器按命中给出文件菜单、「大小」标题的「文件大小显示为」(其他标题给空菜单,不弹出)或空白处菜单。**不要在标题或行上另挂 `context_menu`**:菜单的右键监听不阻止事件冒泡,外层容器的菜单会一起弹出。大小格式 `explorer::FileSizeFormat` 存在 `AppSettings::file_size_format`(`settings.json`),由 `settings::apply` 设成全局量(explorer 不依赖 settings,同终端字体),菜单项派发 `SetFileSizeFormat`,工作区写设置;委托的 `context_menu` 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。
- **列表右键菜单挂在列表容器 `("file-list", side)` 上**,和主机树一样:名称的右键处理器先把没选中的项选中、把 `MenuHit::Item(名称)` 写进 `menu_hit`(右键空白处则清空选择,给空白处菜单),列标题(委托的 `render_th`)写 `MenuHit::Column(列键)`,容器在捕获阶段清空它;容器的菜单构造器按命中给出文件菜单、「大小」标题的「文件大小显示为」(其他标题给空菜单,不弹出)或空白处菜单。**不要在标题或行上另挂 `context_menu`**:菜单的右键监听不阻止事件冒泡,外层容器的菜单会一起弹出。大小格式 `explorer::FileSizeFormat` 存在 `AppSettings::file_size_format`(`settings.json`),由 `settings::apply` 设成全局量(explorer 不依赖 settings,同终端字体),菜单项派发 `SetFileSizeFormat`,工作区写设置;委托的 `context_menu` 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。
- **`Select` 必须放进有尺寸的容器。** 它的外层是 `size_full()`,直接放进工具栏行会盖住整行,别的按钮全点不到。
- **SFTP 面板的工具栏永远一行高,放不下的按钮整个藏起来。** 折行会让这一侧的列表比另一侧低一截,所以工具栏行是 `flex_wrap` 加固定一行高(`h_6`,和 small 按钮、下拉框一样高)加 `overflow_hidden`:放不下的按钮折到被裁掉的第二行,整个隐藏(不会只露半个),从最右边的开始;命令在菜单和快捷键里都还有。边框和内边距在外层 `frame` 上,行高才不含它们。`sftp_controls_fit_small_window_in_light_dark_and_zoom` 查小窗口大字号下每个按钮要么完整可见要么不可见、两侧列表顶端对齐。新标签的两侧对半分(两个 `resizable_panel` 都 `flex_basis(relative(0.5))`,只管第一帧,之后分栏状态记下实际宽度),默认窗口(1280 px)开着会话栏时每侧约 500 px,第一行(目录下拉框 `rems(10.)`)约 404 px,放得下(`sftp_panes_open_half_and_half` 查按钮都可见、两侧列表顶端对齐)。改工具栏按钮或下拉框宽度时要重新算这笔账。
- **断开时对远程侧的操作要有回应。** `ExplorerPanel::execute` 一进来先看 `needs_connection(command)`(远程侧的导航、打开、删改、属性,以及所有传输;选择、书签、复制路径、队列按钮不算):标签是「未连接」时不执行,弹出「与 会话名 的 SFTP 连接已断开」(说明是远程面板的 `problem`),确定按钮「重新连接」派发 `ExplorerCommand::Reconnect`;`can_reconnect` 为假(队列里有停止的批次,或传输正在自动重连)时只有「知道了」并说明该怎么办。为了让这些命令发得出来,远程面板在「未连接」时不禁用工具栏、菜单和路径标签(`FilePane::takes_commands`:只有「连接中」才禁用),传输按钮和拖放在「未连接」时也可用(`sync_available` 的 `transfer`)。
- **SFTP 面板的工具栏永远一行高,放不下的按钮整个藏起来。** 折行会让这一侧的列表比另一侧低一截,所以工具栏行是 `flex_wrap` 加固定一行高(`h_6`,和 small 按钮、下拉框一样高)加 `overflow_hidden`:放不下的按钮折到被裁掉的第二行,整个隐藏(不会只露半个),从最右边的开始;命令在菜单和快捷键里都还有。边框和内边距在外层 `frame` 上,行高才不含它们。`sftp_controls_fit_small_window_in_light_dark_and_zoom` 查小窗口大字号下每个按钮要么完整可见要么不可见、两侧列表顶端对齐。新标签的两侧对半分(两个 `resizable_panel` 都 `flex_basis(relative(0.5))`,只管第一帧,之后分栏状态记下实际宽度),默认窗口(1280 px)开着侧栏时每侧约 500 px,第一行(目录下拉框 `rems(10.)`)约 404 px,放得下(`sftp_panes_open_half_and_half` 查按钮都可见、两侧列表顶端对齐)。改工具栏按钮或下拉框宽度时要重新算这笔账。
- **断开时对远程侧的操作要有回应。** `ExplorerPanel::execute` 一进来先看 `needs_connection(command)`(远程侧的导航、打开、删改、属性,以及所有传输;选择、书签、复制路径、队列按钮不算):标签是「未连接」时不执行,弹出「与 主机名称 的 SFTP 连接已断开」(说明是远程面板的 `problem`),确定按钮「重新连接」派发 `ExplorerCommand::Reconnect`;`can_reconnect` 为假(队列里有停止的批次,或传输正在自动重连)时只有「知道了」并说明该怎么办。为了让这些命令发得出来,远程面板在「未连接」时不禁用工具栏、菜单和路径标签(`FilePane::takes_commands`:只有「连接中」才禁用),传输按钮和拖放在「未连接」时也可用(`sync_available` 的 `transfer`)。
- **SFTP 标签自己持有分栏状态。** 双栏之间和双栏与队列之间的分隔线位置是 `ExplorerPanel` 的 `panes_split` / `queue_split`(`ResizableState` 实体,`with_state` 交给 `h_resizable` / `v_resizable`)。不给 `with_state` 时分栏用 keyed element state,标签一不渲染(切到别的标签)就丢,切回来回到初始宽度(`sftp_panes_keep_their_split_across_tab_switches`)。和 Dock 布局一样只活在内存里,不持久化。
- **路径标签按上一帧的宽度折叠。** render 时拿不到布局宽度,所以 `path-parts` 里放一个 `canvas`,在 prepaint 记下宽度,变了就用 `on_next_frame` 通知面板重画(绘制阶段里 `notify` 会丢)。各段宽度在 render 里用 `text_system().shape_line` 按 `text_sm` 量,和实际布局一致(各段不加内边距,否则 `/ home/ tester/` 看起来像多了空格)。折叠规则是纯函数 `folded_parts`:留住根,中段换成 `…`,末尾尽量多留几级;`…` 点了去它藏起的最后一级(WinSCP 同)。「当前面板」是最近获得焦点的一侧(`on_focus_in`,必须 `defer_in`,见下条),标签重新显示时焦点回到它;界面上不标出来(路径标签两侧一样是 `list_active` 底色加普通下边框):以前给当前面板加一条 `list_active_border` 下划线,用户看它像「这一侧处于激活状态」,而它和命令实际作用的一侧对不上。
- **焦点监听器里改渲染状态要 `defer_in`。** `on_focus_in` / `on_focus` / `on_blur` 在 `draw` 的焦点阶段同步执行,这时 `cx.notify()` 只记下脏视图、不置窗口脏也不排新帧,改动要等下一次别的原因重绘才看得见。点列表空白处(GPUI 对 `track_focus` 元素在按下时自动聚焦,没有别的处理器触发重绘)因此要点两下才切换当前面板;改为 `cx.defer_in` 在绘制结束后执行就好。测试 `sftp_clicking_empty_list_space_makes_that_pane_current_at_once` 不自己 `render_frame`,只看点击本身触发的帧。
- **SFTP 空闲时也要发现断网,和终端同时。** 终端靠主循环一直 `channel.wait()`,russh 的 keepalive(`SshConnector` 里 30 s × 3,所有 SSH 连接共用)一关掉连接它就返回。SFTP 连接同样会被关掉,但 russh-sftp 只在下一次请求时才报 `session closed`,而且不公开关闭状态。所以 `SftpClient` 把通道流包一层 `WatchedStream`:读到结尾或出错就置位一个 `watch`,`SftpClient::closed()` 等它;工作线程空闲等命令时同时等它,一触发就 `forget` 这个连接并发 `Disconnected`。等待时只持有 `Weak` 和那个 `watch` 接收端,**不能持有 `Arc<SftpClient>`**:主动「断开」靠丢掉最后一个引用来关连接,监听攥着强引用会让 SSH 连接一直开着(`an_idle_connection_that_drops_is_reported_without_a_request` 用服务器端的 `ended` 计数守着)。`is_network_error` 也认 russh-sftp 的 `session closed`。
- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status`(`FilePane::render_status`)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。列表没有行时表格中央的占位文字(委托的 `render_empty`,id `list-placeholder`,文字来自 `FilePane::placeholder`,经 `ListingContext::placeholder` 传进去)也按阶段说:连接中「正在连接 SFTP…」,还没读到过目录(`listed` 为假)时「正在读取目录…」,读到过之后才是「空目录」(重读一个空目录时同样等 `SLOW_LOAD` 才改口);没读成功过时是「未连接」或「无法读取目录」。非根目录总有 `..` 一行,所以「空目录」实际只出现在空的根目录。改加载状态的地方都要 `sync_listing`,否则委托里的占位文字不更新。读不了目录、远程侧断开的原因是面板的 `problem`,显示在窗口状态栏最左边、平时写「已连接 会话名」的 `status-connection`:SFTP 标签在前时,工作区用 `ExplorerPanel::status`(这个标签自己的连接状态,不是会话汇总的,加上 `problem`,远程侧优先)生成 `WorkspaceStatus::Explorer`,未连接时写「未连接 会话名:原因」,已连接但读不了目录时只写原因,两种都是 `danger` 红字;会话(终端标签)断开时同样是红字。`problem` 变化时面板发 `FilePaneEvent::ProblemChanged`,标签转成 `ExplorerPanelEvent::StatusChanged`,工作区只在那个标签在前时 `notify`(不观察整个标签,传输进度每 50 ms 一次)。
- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status`(`FilePane::render_status`)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。列表没有行时表格中央的占位文字(委托的 `render_empty`,id `list-placeholder`,文字来自 `FilePane::placeholder`,经 `ListingContext::placeholder` 传进去)也按阶段说:连接中「正在连接 SFTP…」,还没读到过目录(`listed` 为假)时「正在读取目录…」,读到过之后才是「空目录」(重读一个空目录时同样等 `SLOW_LOAD` 才改口);没读成功过时是「未连接」或「无法读取目录」。非根目录总有 `..` 一行,所以「空目录」实际只出现在空的根目录。改加载状态的地方都要 `sync_listing`,否则委托里的占位文字不更新。读不了目录、远程侧断开的原因是面板的 `problem`,显示在窗口状态栏最左边、平时写「已连接 主机名称」的 `status-connection`:SFTP 标签在前时,工作区用 `ExplorerPanel::status`(这个标签自己的连接状态,不是主机汇总的,加上 `problem`,远程侧优先)生成 `WorkspaceStatus::Explorer`,未连接时写「未连接 主机名称:原因」,已连接但读不了目录时只写原因,两种都是 `danger` 红字;主机(终端标签)断开时同样是红字。`problem` 变化时面板发 `FilePaneEvent::ProblemChanged`,标签转成 `ExplorerPanelEvent::StatusChanged`,工作区只在那个标签在前时 `notify`(不观察整个标签,传输进度每 50 ms 一次)。
- **「打开目录/书签」的选中项是推导出来的。** 列表里被选中的就是与目录框(按面板规则展开 `~`、相对路径并去掉末尾分隔符后)相同的书签,没有另存选中状态,和 WinSCP 的 `DirectoryEditChange` 一致:单击书签只是把它写进目录框。书签的增删和排序都派发 `ExplorerCommand`(`AddBookmark { path: Some(..) }`、`RemoveBookmark`、`MoveBookmark`),改动立即写库,取消对话框也不回滚(WinSCP 同)。对话框打开后在 `open_dialog` 同步返回之后立刻 `focus` 目录框并全选,这样能生效(`Root::open_dialog` 是同步聚焦对话框宿主的,之后同一更新里的聚焦会覆盖它);延迟到下一帧的聚焦才会失效。
- **书签按会话、按侧持久化。** `bookmarks` 表对 `sessions` 有 `ON DELETE CASCADE`;`remove_unnotified` / `remove_group_unnotified` 必须同时丢掉内存里被删会话的书签。`BookmarkSide` 放在 session 模块,因为 session 不能依赖 explorer。
- **书签按主机、按侧持久化。** `bookmarks` 表对 `hosts` 有 `ON DELETE CASCADE`;`remove_unnotified` / `remove_group_unnotified` 必须同时丢掉内存里被删主机的书签。`BookmarkSide` 放在 host 模块,因为 host 不能依赖 explorer。
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串;`FakeLocalDirectory` 只记录本地操作,不真的改文件。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。
- **端口转发独立于终端和 SFTP:规则在 store,运行状态在管理器。**
- `SessionStore` 持有规则(`forwards`,写穿到 `forwards` 表,对 `sessions` 有 `ON DELETE CASCADE`,`remove_unnotified` 同时丢掉内存里的规则,复制会话不复制规则);`ForwardManager` 只持有「在跑」这件事。规则没有运行状态字段,启动应用时全部是停止的,除了 `auto_start` 的(`start_automatic`,工作区构造末尾调用)。
- 转发不进 `refresh_session_connection_state`,`on_disconnect_session` 不碰它,`cancel_prompts_for_session` 也跳过 `PromptOwner::Forward`:会话的「断开连接」只管标签。删除会话 / 分组时工作区不用显式停转发,管理器看到规则从 store 消失就停。
- 认证问答走和终端、SFTP 同一套对话框,归属是 `PromptOwner::Forward(ForwardId, 代次)`,代次是管理器每次启动递增的编号;`StatusChanged` 时工作区丢掉过期代次的问答。转发可能是自动开启的,屏幕上没有任何东西说明谁在问,所以对话框开头多一行「端口转发“名称”正在通过 会话名 连接。」(`forward_prompt_origin`)。取消问答等于停止(`manager.stop`),不算失败、不弹通知。
- `HostStore` 持有规则(`forwards`,写穿到 `forwards` 表,对 `hosts` 有 `ON DELETE CASCADE`,`remove_unnotified` 同时丢掉内存里的规则,复制主机不复制规则);`ForwardManager` 只持有「在跑」这件事。规则没有运行状态字段,启动应用时全部是停止的,除了 `auto_start` 的(`start_automatic`,工作区构造末尾调用)。
- 转发不进 `refresh_host_connection_state`,`on_disconnect_host` 不碰它,`cancel_prompts_for_host` 也跳过 `PromptOwner::Forward`:主机的「断开连接」只管标签。删除主机 / 分组时工作区不用显式停转发,管理器看到规则从 store 消失就停。
- 认证问答走和终端、SFTP 同一套对话框,归属是 `PromptOwner::Forward(ForwardId, 代次)`,代次是管理器每次启动递增的编号;`StatusChanged` 时工作区丢掉过期代次的问答。转发可能是自动开启的,屏幕上没有任何东西说明谁在问,所以对话框开头多一行「端口转发“名称”正在通过 主机名称 连接。」(`forward_prompt_origin`)。取消问答等于停止(`manager.stop`),不算失败、不弹通知。
- 转发自己失败(`ForwardManagerEvent::Failed`)弹错误通知,因为转发列表可能没在显示。单个连接转发不了(`ForwardEvent::ConnectionFailed`,例如服务器连不上目标)只记成这一行的 `problem`,转发继续跑;连接数变化只 `notify`、不发 `StatusChanged`,繁忙的 SOCKS 代理上它一直在变。
- **转发发现断线靠处理器被丢弃,不靠 `Handler::disconnected`。** russh 0.63.3 在套接字 shutdown 失败时会跳过 `disconnected`(`client/mod.rs` 的 `Session::run`)。`SshClientHandler` 由会话任务持有,任何退出路径都会丢掉它,所以 `connect_forwarding` 把一个 `mpsc` 发送端放进处理器,工作线程 `forwarded.recv()` 读到 `None` 就是连接没了(SSH keepalive 30 s × 3,和终端同时发现)。同一个通道也承载远程转发:`server_channel_open_forwarded_tcpip` 把通道连同 `ChannelOpenHandle` 交给工作线程,连上本机目标之后才 `accept`,连不上就 `reject(ConnectFailed)`,对端看到的是拒绝而不是接受后挂断。别的连接(终端、SFTP、exec)没有这个发送端,服务器主动开来的 forwarded-tcpip 通道一律拒绝。
- **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。
- **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。
- **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。
- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `SessionPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `HostPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
- **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。
- **`ListItem` 里会增长的文字要零宽加裁剪。** `ListItem` 把内容包在两层 `w_full` 里、`suffix` 是它们的兄弟,整行靠内容的最小宽度收缩来给 `suffix` 让位。`flex_1().min_w_0()` 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 `suffix`(状态和启停按钮)挤出列表。转发行的文字列因此是 `w_0().flex_grow(1.).overflow_x_hidden()`(`the_forward_dialog_and_a_long_row_fit_the_smallest_window` 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:`RadioGroup::horizontal` 的那一行是 `flex_wrap`,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 `flex_1().min_w_0().self_stretch()`(那一行是 `items_center`,不拉伸的话高度不齐),卡片里那句用途同样是 `w_0().flex_grow(1.).overflow_x_hidden()`,放不下时在卡片内折行。`Radio` 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(`RADIO_DOT_COLUMN`:small 圆点 `size_3p5` 加 `gap_x_2`)拉回到圆点下面,占满卡片的整个宽度。对话框宽 `rems(42.)` 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 `Radio` 的圆点尺寸或间距时 `RADIO_DOT_COLUMN` 要跟着改,`the_three_kinds_of_forward_share_one_row` 查用途这一行的两端离卡片边缘的距离。
- **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。
- **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。
- **改 schema 要升版本并加一步迁移。** `SCHEMA_VERSION` 是 12。`migrate` 对版本 0 建完整的 `SCHEMA`,对旧版本按 `STEPS` 一步接一步升级(7→8 建 `forwards` 表,8→9 建 `credentials` 和 `session_credentials` 表,9→10 是只改数据的 `Step::Code`:`keys_into_credentials` 把会话自己的私钥文件变成密钥凭据、`auto` 变成 `password`,表定义不变,`sessions.key_path` 列留着、一律为空,因为删列要重建表;10→11 建连接方式的 `session_jumps`(`session_id`、`position`、`jump_id`,后者 `ON DELETE SET NULL`)和 `session_proxies` 表:有跳板行就是跳板(全是 NULL 也是),有代理行就是代理,都没有是直连;11→12 建会话备注的 `session_notes` 表,只有填了备注的会话有一行),一个库可以连升几级;一步改不了表就用 `Step::Sql`,要在 Rust 里算(随机 `keychain_id`、取文件名)就用 `Step::Code`,两种都在一个事务里,最后写 `user_version`;已经发出去的库只改 `SCHEMA` 是拿不到新表的。新表的 DDL 写成字面量宏(`forwards_table!`、`credential_tables!`、`route_tables!`、`notes_table!`),完整 schema 和迁移步骤共用同一段文字;`a_version_7_database_gains_the_forwards_table_and_keeps_its_rows`、`a_version_8_database_gains_the_credential_tables_and_keeps_its_rows`、`a_version_9_database_moves_host_keys_into_credentials` 、`a_version_10_database_gains_the_route_tables_and_keeps_its_rows` 与 `a_version_11_database_gains_the_notes_table_and_keeps_its_rows` 用冻结的旧 schema 验证升级后的表定义与新库逐字相同。**不要给已有的表 `ALTER TABLE ADD COLUMN`**:SQLite 会把新列拼在原来的右括号前面(`\n, 列定义)`),新库的 `SCHEMA` 只有照抄这种格式才能逐字相同;要加字段就像会话和凭据的关联那样另建一张表。
- **表结构(`SCHEMA_VERSION` 是 13)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件;`STEPS` 目前是空的,`Step` 枚举留着给下一次改表用。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path())`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate` 后直接退出。
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
- 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
- **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动检查并下载更新」存在 `AppSettings::update`,工作区在 `observe_in(&settings)` 里 `sync_updater`,`update` 模块不依赖 `settings`。动作是 `CheckForUpdates` / `DownloadUpdate` / `ShowUpdate` / `RestartToUpdate` / `OpenDownloadPage`,处理器在 `workspace/updates.rs`;对话框里「重启会中断什么」由工作区数(`restart_impact`:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 `cx.set_restart_path` + `cx.restart()`,它会照常跑 `on_app_quit`(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。**Windows 不在退出过程中起进程**:gpui 退出时在 App 仍被借用的状态下跑 `on_app_quit`,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 `update::start_handed_over()`,由 `main` 在 `run()` 返回之后启动(Windows 的事件循环结束后 `run` 会返回)。更新前写 `updates/applied.json`(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 `app::updates_dir()`(用户缓存目录;设了 `SHELLRS_DATA_DIR` 时在数据目录下)。
- **发布走 tag。** 推 `v<版本>`(和 `Cargo.toml` 一致,`CHANGELOG.md` 里要有 `## [版本]` 一节)触发 `.github/workflows/release.yml`:分平台构建打包(`packaging/macos/bundle.sh`、`packaging/windows/shellrs.iss`、`packaging/linux/appimage.sh`),在需要人工批准的 `release` 环境里上传 R2、建 GitHub Release,**最后**才用 `packaging/make-manifest.sh` 生成并上传清单、清 CDN 缓存。清单格式见 `packaging/manifest.example.json`(`the_example_manifest_parses` 守着它和解析器一致,`the_release_script_signs_what_the_client_accepts` 守着脚本和验签一致)。一次性准备和 secrets 见 `README.md` 的「发布」。
- **库只往前升,旧版本不碰新库。** `migrate` 遇到 `user_version > SCHEMA_VERSION` 直接报错、不动文件(`open_store` 退回内存 store 并提示);迁移已有的库之前先 `VACUUM INTO '<库>.v<旧版本>.bak'`(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。
- **库只往前升,旧版本不碰新库。** `migrate` 遇到 `user_version > SCHEMA_VERSION`(或早于第一步)直接报错、不动文件(`open_store` 退回内存 store 并提示);迁移已有的库之前先 `VACUUM INTO '<库>.v<旧版本>.bak'`(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。
- **窗口还在搭的时候不能直接弹通知。** 通知层属于窗口的 `Root`,而 `Root` 要等 `open_window` 的构造闭包返回才挂上;在那之前 `window.push_notification` 会 panic(「window first layer should be a gpui_component::Root」),应用启动即崩溃。`main` 里读库 / 读设置的问题、`Workspace::new` 里外部 CLI 启动失败,都用 `workspace::notify_once_open`(`window.defer`)延后到窗口建好。`cx.emit` 出去的事件本来就在构造结束后才送达,订阅里弹通知没有这个问题(自动开启的转发启动失败就是这样报的)。
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
- **设置写穿到 JSON,由工作区落到窗口上。** 设置是应用级的,不进会话数据库,存在 `settings.json`(`app::settings_path`),整文件先写临时文件再改名替换。`SettingsStore` 和 `SessionStore` 一样内存说了算:`update` 改内存 → 写文件 → `notify`,写失败发 `SettingsStoreEvent::PersistFailed`,工作区弹错误通知;`update_unnotified` 是给单元测试的纯内存一半。文件缺字段用默认值,读不出来就用默认设置并在启动时提示,文件留到下一次改动时覆盖。设置页的下拉只写 store(下拉的 setter 拿不到 `Window`);工作区 `observe_in` store 调 `settings::apply`,另外 `observe_window_appearance` 让「跟随系统」跟着系统切换,构造时在第一帧之前先应用一次。标题栏的主题开关(`ToggleTheme`)也只是把「应用外观」写成当前的反面,不再直接改主题,所以两处永远一致并且会保存。界面语言目前只影响 gpui-kit 自己的文案(`set_locale`),ShellRS 的文案还是中文,所以默认是「简体中文」而不是「跟随系统」;翻译完成后再改默认值。locale 是进程级全局量,UI 测试并行运行,所以 UI 测试不切换语言,只由单元测试覆盖 `locale` 的解析。
- **设置写穿到 JSON,由工作区落到窗口上。** 设置是应用级的,不进主机数据库,存在 `settings.json`(`app::settings_path`),整文件先写临时文件再改名替换。`SettingsStore` 和 `HostStore` 一样内存说了算:`update` 改内存 → 写文件 → `notify`,写失败发 `SettingsStoreEvent::PersistFailed`,工作区弹错误通知;`update_unnotified` 是给单元测试的纯内存一半。文件缺字段用默认值,读不出来就用默认设置并在启动时提示,文件留到下一次改动时覆盖。设置页的下拉只写 store(下拉的 setter 拿不到 `Window`);工作区 `observe_in` store 调 `settings::apply`,另外 `observe_window_appearance` 让「跟随系统」跟着系统切换,构造时在第一帧之前先应用一次。标题栏的主题开关(`ToggleTheme`)也只是把「应用外观」写成当前的反面,不再直接改主题,所以两处永远一致并且会保存。界面语言目前只影响 gpui-kit 自己的文案(`set_locale`),ShellRS 的文案还是中文,所以默认是「简体中文」而不是「跟随系统」;翻译完成后再改默认值。locale 是进程级全局量,UI 测试并行运行,所以 UI 测试不切换语言,只由单元测试覆盖 `locale` 的解析。
- **外部 CLI 经由正在运行的应用执行。** 命令本身不读钥匙串、不开数据库,只把请求交给应用:所以不会多弹钥匙串授权框,也不会和「内存说了算」、内存里分配 id 冲突。
- **服务端**:生产路径 `Workspace::new` 用终端同一个 `SshConnector`(共享 known_hosts 锁)和 SFTP 同一个 provider 启动 `CliServer`;`new_with_services`(UI 测试)不启服务,测试绝不监听真实套接字。工作区在 store / 设置变化时 `sync_cli_server`:会话快照(`CliTarget::all`,带分组全路径)和「启用外部 CLI」开关。
- **服务端**:生产路径 `Workspace::new` 用终端同一个 `SshConnector`(共享 known_hosts 锁)和 SFTP 同一个 provider 启动 `CliServer`;`new_with_services`(UI 测试)不启服务,测试绝不监听真实套接字。工作区在 store / 设置变化时 `sync_cli_server`:主机快照(`CliTarget::all`,带分组全路径)和「启用外部 CLI」开关。
- **一直监听,按开关拒绝**:开关关着时回 `not_enabled`,Agent 才分得清「没运行」和「没开」。套接字 `0600`,每个连接再用 `peer_cred` 核对 uid。残留的套接字文件:连得上说明另一个实例在跑(启动失败并弹通知),连不上就删掉接管。
- **Windows 用命名管道,两端互相确认。** 管道名是整机的命名空间,不在用户自己的目录里,所以:
- 服务端每个实例都带 DACL `D:P(A;;GA;;;<当前用户 SID>)` 和 `PIPE_REJECT_REMOTE_CLIENTS`,第一个实例带 `FILE_FLAG_FIRST_PIPE_INSTANCE`(名字已被占用就启动失败,报 `AddrInUse`)。DACL 在创建时原子生效,所以不像 Unix 那样再查对方的身份(查进程属主会误伤以管理员身份运行的 Agent)。
@@ -138,16 +138,16 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **传输无人值守**:`SshCliBackend::transfer` 先等 `Connected` 再发请求;连接前的 `Prompt` 一律回 `Cancel` 并按提示种类给出错误码;`Question` 按固定策略回答(冲突覆盖并勾选全部、续传一律重新开始、出错跳过并记为失败);以 `Idle` 为结束。请求用 `UploadRequest::scp` / `DownloadRequest::scp`:worker 连上后按 scp 规则解析目标(`scp_remote_target` / `scp_local_target`,目标目录必须存在,远程还要 `stat` 确认,因为有的服务器 realpath 不存在的路径也成功),并让新建文件保留源文件的权限位(上传整套 `0o777`,下载只补可执行位);SFTP 标签的请求不受影响。批次准备失败时 worker 也发 `Idle`。
- **安装集成的路径保护**:`CliIntegration` 没有路径(UI 测试默认)时页面显示「暂不支持」、没有安装按钮,测试不会写到真实的 `~`;需要时用 `workspace.cli_integration()` 注入临时目录。安装 / 移除是动作(`InstallCliCommand`、`RemoveCliCommand`、`InstallAgentSkill(AgentKind)`、`RemoveAgentSkill(AgentKind)`、`RefreshCliIntegration`、`CopyAgentSkill`),由工作区在后台线程执行后 `refresh`。
- **终端字体走全局量。** 终端模块不能依赖 settings,所以 `settings::apply` 把字体设成 `terminal::TerminalFont` 全局量并 `window.refresh()`,`TerminalView` 在 render(字体族、字号)和 prepaint(行高 `row_height`,按设备像素取整)里读它;格子变了,下一帧 prepaint 就按新尺寸 resize,已打开的终端立即生效。默认值就是加设置之前的样子:主题的等宽字体(macOS 上是 Menlo)、13 px、行高 1.54(13 × 1.54 取整后正好是原来的 20 px 行)。字体族存 `None` 表示跟随主题默认,下拉里选默认那一项也存 `None`。文件里的字体族只有装着才用(`is_font_installed`,进程内读一次字体列表;GPUI 找不到字体族时会换成别的字体),否则退回默认,文件里的值保留。数字在 `AppSettings::normalized` 里夹到范围并取整(字号整像素、行高两位小数),读文件和每次修改都过一遍;gpui-kit 的数字框用 `f64::to_string` 显示,步进 0.1 会显示 `1.7000000000000002`,store 取整后数字框下一次渲染会同步成取整后的值。字号用下拉不用数字框:数字框每敲一个字符就夹到最小值,输入 14 时敲下「1」就变成 8。字体下拉只列等宽字体:`monospace_font_families` 要加载每个字体族(macOS 上约 150 ms),在后台线程跑一次、进程内缓存,结果回来前下拉里只有默认字体;判定是「i」和「M」一样宽且窄于 0.8 em,后一条排除 Webdings 这种方块符号字体。预览 `TerminalFontPreview` 用和终端一样的排法(每格一个字形、宽字符后面补一个空格格子、终端的行高和配色),不是普通文字排版,否则中文的宽度和终端里对不上。
- **持久化写穿,内存说了算。** `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`,会话树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。会话树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。
- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `SessionLogin`(`SessionStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 `route: LoginRoute`——每台跳板会话的登录也查好了,`JumpLogin::Host { name, login }` 或已删除的 `JumpLogin::Deleted`;跳板自己的 `route` 一律是直连;会话表单测试连接时用 `SessionStore::route_login` 把表单上的 `Route` 解析成同样的东西),不是 `Session`。`SessionLogin` 只含影响登录的字段并派生 `PartialEq`(`JumpLogin` 手写 `PartialEq`、不比 `name`,跳板改名不让后面的会话重连),会话 `update` 和 `update_credential` 都在改之前取一遍**全部会话**的快照(`logins`),改完按「前后快照不相等」逐台发 `ConnectionSettingsChanged`(`emit_changed_logins`),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的会话也会重连;删除会话不发(同删除凭据,已打开的连接不断)。引用凭据的会话在 `Session.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`),store 在保存会话(`normalized`)、改凭据(同一个事务里改 `sessions.username`)和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `session.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。
- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `SessionStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或会话在用时原地覆盖:路径不变,用它的会话不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的会话不占着端点的密码条目,会话从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:会话对话框用第二个 `SecretFields` 预填和提交,没有会话再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。
- **id 由内存分配。** `next_session_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
- **对外只给 `PublicId`,不给 `SessionId`。** 会话右键「复制 ID」复制的是 `Session.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `sessions.public_id`(可为空,唯一性由索引 `sessions_public_id` 保证)。新建和复制会话时由 `SessionStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建会话时必须带上原来的 `public_id`,`update_session` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
- **持久化写穿,内存说了算。** `HostStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `HostStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删主机的 id 返回给工作区去关标签页。
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,主机保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `HostStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_host` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,主机树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加主机名称第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。主机树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。
- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `HostLogin`(`HostStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 `route: LoginRoute`——每台跳板主机的登录也查好了,`JumpLogin::Host { name, login }` 或已删除的 `JumpLogin::Deleted`;跳板自己的 `route` 一律是直连;主机表单测试连接时用 `HostStore::route_login` 把表单上的 `Route` 解析成同样的东西),不是 `Host`。`HostLogin` 只含影响登录的字段并派生 `PartialEq`(`JumpLogin` 手写 `PartialEq`、不比 `name`,跳板改名不让后面的主机重连),主机 `update` 和 `update_credential` 都在改之前取一遍**全部主机**的快照(`logins`),改完按「前后快照不相等」逐台发 `ConnectionSettingsChanged`(`emit_changed_logins`),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的主机也会重连;删除主机不发(同删除凭据,已打开的连接不断)。引用凭据的主机在内存的 `Host.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`;库里这一行的 `username` 是 NULL、`auth` 是 `credential`),store 在保存主机(`normalized`)、改凭据和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `host.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。
- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `HostStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或主机在用时原地覆盖:路径不变,用它的主机不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。
- **id 由内存分配。** `next_host_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`HostStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
- **对外只给 `PublicId`,不给 `HostId`。** 主机右键「复制 ID」复制的是 `Host.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `hosts.public_id`(可为空,`UNIQUE`)。新建和复制主机时由 `HostStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建主机时必须带上原来的 `public_id`,`update_host` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<HostStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer` / `CloseLocalTerminal` / `CloseSettings`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`SessionStore::recent_sessions()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把会话移到最前。
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer` / `CloseLocalTerminal` / `CloseSettings`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`HostStore::recent_hosts()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把主机移到最前。
- **终端内容区的右键菜单与查找、清屏。**
- **菜单**:`TerminalView` 自己给出复制、粘贴、查找、清屏。面板通过 `set_menu_items` 在下面追加一段:远程终端追加打开 SFTP、重新连接、断开连接(`DisconnectTerminal` 只断这一个标签);本地终端不追加。追加段的构造器在菜单打开时运行,拿到的是事先读好的 `TerminalLifecycle`,因为这时 `TerminalView` 正在更新,读它会重入。
- **动作**:`FindInTerminal` / `FindNextInTerminal` / `FindPreviousInTerminal` / `DismissTerminalFind` / `ClearTerminal` 和复制、粘贴一样,由工作区通过 `active_terminal` 转给当前标签的 `TerminalView`。
@@ -158,21 +158,21 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- 焦点匹配按回滚历史的增长量跟着输出上移,再用 `regex_search_right` 核对,对不上就丢掉。计数不随新输出刷新。
- **查找条**必须是终端 `div` 的兄弟元素,不能是子元素:否则输入框里敲的键会冒泡到终端的 `on_key_down`,被发给 shell。它还要 `.occlude()`,否则点它会被下面终端的鼠标处理器抢走焦点。
- **清屏**只在本地模拟器里做:把光标所在的逻辑行(含折行)挪到第 0 行,清空回滚历史,远端不知情。`ALT_SCREEN` 下什么也不做,菜单项置灰。
- **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。双击标题派发 `ToggleSessionPanel`(显示 / 隐藏左侧会话栏,同 ⌘B),也挂在标题上,所有中间区标签共用;第一下单击照常选中标签,关闭按钮的单击停止冒泡,双击它不会切换侧栏。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。中间区各面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 `custom_title`)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(`RenamableTab::default_title`:终端是会话名,SFTP 是「会话名 · SFTP」)时回到默认,默认名随会话改名。SFTP 标签的菜单(`explorer_panel.rs` 的 `TabMenu`)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑主机…」;标签栏按钮和远程终端一样是「SFTP」(`("open-sftp", id)`)和「重新连接」(`("reconnect-sftp", id)`)。SFTP 的「重新连接」是 `ExplorerCommand::Reconnect`:已连接时先 `disconnect` 再发 `Resume`(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效,要预先聚焦某个字段只能在 `open_dialog` 返回后的同一次更新里同步调用(见 `open_directory_dialog.rs`)。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_session_stay_closable`)。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
- **会话行的 tooltip 开在行的右侧。** 悬停会话行 500 ms 后,行右侧显示 `user@host:port`(`session_tooltip`,不显示系统),配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字)。只有这一个 tooltip 这样:gpui-kit 的 tooltip 和菜单、下拉共用 `popover` 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip(`Button::tooltip_placement` 那套)是 crate 私有的,普通元素的 `.tooltip()` 只会贴着鼠标弹、盖住下面的行,所以 `SessionPanel` 自己持有一个 `gpui_kit::base::TooltipOverlay`(`row_tooltip`,作为面板的子元素渲染,面板收起就一起消失;动画 `animate_row_tooltip` 仿 gpui-kit 自己的)。行在 `on_hover` 里带着行的边界 `request_show(...placement(Placement::Right))`,任意鼠标按下立即 `hide`,拖动中不显示。**只有 tooltip 的当前主人才能收起它**(`row_tooltip_owner`):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 `request_hide` 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(`the_row_tooltip_follows_the_pointer_down_the_list`)。按键会收起 tooltip 是 GPUI 的 `HoverListenerMode::InputModalityAware`:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 `canvas` 在 prepaint 记下,**必须**带 `top_0().left_0()`:`ListItem` 的内容盒不是 `relative`,不设偏移的 canvas(gpui-base 的 `ElementExt::on_prepaint` 就是这样)会落到内容下方,tooltip 随之偏下将近一行。
- **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `SessionPanel` 把 `.context_menu(..)` 挂在 `#session-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc<Cell<Option<SessionNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。开始页的列表 `recent-session-list` 照样挂在容器上(右键行同时选中该行,空白处得到空菜单、不弹出);开始页是 `deferred` 画的,但 `Deferred` 的子元素在主布局阶段就做 `request_layout`,菜单抢焦点仍早于任何 prepaint,不会触发上面的断言。
- **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。双击标题派发 `ToggleHostPanel`(显示 / 隐藏左侧栏,同 ⌘B),也挂在标题上,所有中间区标签共用;第一下单击照常选中标签,关闭按钮的单击停止冒泡,双击它不会切换侧栏。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。中间区各面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 `custom_title`)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(`RenamableTab::default_title`:终端是主机名称,SFTP 是「主机名称 · SFTP」)时回到默认,默认名随主机改名。SFTP 标签的菜单(`explorer_panel.rs` 的 `TabMenu`)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑主机…」;标签栏按钮和远程终端一样是「SFTP」(`("open-sftp", id)`)和「重新连接」(`("reconnect-sftp", id)`)。SFTP 的「重新连接」是 `ExplorerCommand::Reconnect`:已连接时先 `disconnect` 再发 `Resume`(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦主机面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效,要预先聚焦某个字段只能在 `open_dialog` 返回后的同一次更新里同步调用(见 `open_directory_dialog.rs`)。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_host_stay_closable`)。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`HostPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `HostPanel` 比对 `known_groups` / `seen_hosts` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
- **主机行的 tooltip 开在行的右侧。** 悬停主机行 500 ms 后,行右侧显示 `user@host:port`(`host_tooltip`,不显示系统),配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字)。只有这一个 tooltip 这样:gpui-kit 的 tooltip 和菜单、下拉共用 `popover` 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip(`Button::tooltip_placement` 那套)是 crate 私有的,普通元素的 `.tooltip()` 只会贴着鼠标弹、盖住下面的行,所以 `HostPanel` 自己持有一个 `gpui_kit::base::TooltipOverlay`(`row_tooltip`,作为面板的子元素渲染,面板收起就一起消失;动画 `animate_row_tooltip` 仿 gpui-kit 自己的)。行在 `on_hover` 里带着行的边界 `request_show(...placement(Placement::Right))`,任意鼠标按下立即 `hide`,拖动中不显示。**只有 tooltip 的当前主人才能收起它**(`row_tooltip_owner`):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 `request_hide` 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(`the_row_tooltip_follows_the_pointer_down_the_list`)。按键会收起 tooltip 是 GPUI 的 `HoverListenerMode::InputModalityAware`:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 `canvas` 在 prepaint 记下,**必须**带 `top_0().left_0()`:`ListItem` 的内容盒不是 `relative`,不设偏移的 canvas(gpui-base 的 `ElementExt::on_prepaint` 就是这样)会落到内容下方,tooltip 随之偏下将近一行。
- **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `HostPanel` 把 `.context_menu(..)` 挂在 `#host-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc<Cell<Option<HostNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。开始页的列表 `recent-host-list` 照样挂在容器上(右键行同时选中该行,空白处得到空菜单、不弹出);开始页是 `deferred` 画的,但 `Deferred` 的子元素在主布局阶段就做 `request_layout`,菜单抢焦点仍早于任何 prepaint,不会触发上面的断言。
- **表单里的几选一用 `shared::SegmentedControl`。** 用户给的样式:背景色的轨道加 `border` 细边框,选中的一段是 `accent` 底色、比轨道深一级的细边框(`border` 混入 8% 的 `foreground`,浅色主题约 neutral-300,深色主题反过来变亮)和轻阴影的块(截图里的灰太深,`secondary_active` 是 neutral-300,用户要浅一些;`accent` 浅色主题是 neutral-100,深色是 neutral-800),没选中的是 `muted_foreground` 字、悬停时半透明的 `accent` 底,比选中的淡;各段等宽铺满轨道(`flex_1`),高度是中号控件的 `h_8`,选中块的圆角按 2 px 内缩与轨道同心。行为全来自 gpui-base 的 `Radio` / `RadioGroup`(点击、回车 / 空格、每段一个 Tab 停靠点、无障碍角色),这里只管外观;每段的元素 id 是它的序号,和 gpui-component 的 `RadioGroup` 一样,所以测试照旧 `within(组).click(1usize)`。gpui-base 的 `RadioGroup` 是 `RenderOnce`,不能 `test_support()`,测试里找不到轨道本身,只能量各段。主机对话框的认证方式和连接方式、凭据对话框的类型 / 私钥来源 / 算法都用它;端口转发的三张类型卡片带说明和示意图,仍是 gpui-component 的 `RadioGroup`。
- **会话对话框和转发对话框一样从窗口高度的 5% 开始、最高占 90%**,选了跳板或代理、框里的内容多起来时正文滚动、按钮不动。
- **主机对话框和转发对话框一样从窗口高度的 5% 开始、最高占 90%**,选了跳板或代理、框里的内容多起来时正文滚动、按钮不动。
- **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `window.open_dialog` 都要 `.overlay_closable(false)`(默认是 `true`,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(`a_click_beside_a_dialog_does_not_close_it` 查主机、分组、端口转发和凭据四个)。`open_alert_dialog` 开的确认框本来就不会被点外面关掉。
- **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`DialogAction`)和回车都是 `window.dispatch_action(Confirm)`,先到焦点元素;gpui-base 的 `Select` 在收起状态下收到 `Confirm` 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」、主机对话框的「凭据」「分组」都没有绕开(`ForwardForm::focus_bind_port` 只在打开新建对话框时聚焦一次,代码里没有订阅 `SelectEvent`;「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这些下拉之后要先点一下别的字段再点 `commit`,否则后面第一次点击会被吃掉。
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_hosts_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<HashMap<SessionId, HostOs>>`)。
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如主机面板里各主机系统的 `Rc<HashMap<HostId, HostOs>>`)。
- `px(..)` 只允许出现在 API 要求 `Pixels` 的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和 `cx.theme()` token。
## 测试约定
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `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", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(会话面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-sessions` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-password`、「无密码」下面的说明 `session-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 Line truncated
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、主机行的 tooltip `host-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`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 个文件到 …」)Line truncated
+2 -2
View File
@@ -16,7 +16,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。旧版本里「自动」的主机升级后归为「密码」,自己选了私钥文件的主机升级后改用按「用户 + 私钥文件」自动建好的密钥凭据。
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。
主机对话框「分组」下面的「连接方式」决定怎么连到这台主机,终端、SFTP、端口转发、外部 CLI 和「测试连接」都照它走:
@@ -117,7 +117,7 @@ shellrs download <ID> <远程路径> <本地路径>
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`session_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里,多行或带引号的命令用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
## 标签页
+32 -32
View File
@@ -6,15 +6,15 @@ use gpui_kit::*;
use crate::{
cli::AgentKind,
explorer::{ExplorerId, FileSizeFormat},
session::{CredentialId, ForwardId, GroupId, NodeDrop, SessionId, SessionNode},
host::{CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop},
terminal::{LocalTerminalId, RemoteTerminalId},
};
gpui_kit::actions!(
shellrs,
[
/// Open the new-session dialog for a session at the root of the tree.
NewSession,
/// Open the new-host dialog for a host at the root of the tree.
NewHost,
/// Open the new-group dialog for a top-level group.
NewGroup,
/// Open a new local login-shell terminal.
@@ -35,9 +35,9 @@ gpui_kit::actions!(
/// prompt line.
ClearTerminal,
/// Show or hide the left dock, whichever list it is showing.
ToggleSessionPanel,
/// Show the session list in the left dock.
ShowSessions,
ToggleHostPanel,
/// Show the host list in the left dock.
ShowHosts,
/// Show the port-forwarding list in the left dock.
ShowForwards,
/// Open the new-forward dialog.
@@ -67,11 +67,11 @@ gpui_kit::actions!(
/// Move keyboard focus to the search field of the list the left dock
/// is showing.
FocusSearch,
/// Connect the session selected in the focused session list.
/// Connect the host selected in the focused host list.
ConnectSelected,
/// Expand every group in the session tree.
/// Expand every group in the host tree.
ExpandAllGroups,
/// Collapse every group in the session tree.
/// Collapse every group in the host tree.
CollapseAllGroups,
/// Close the center tab displayed most recently (the one ⌘W acts on).
CloseActiveTab,
@@ -121,29 +121,29 @@ macro_rules! id_actions {
}
id_actions! {
/// Open a new terminal connection for a session.
ConnectSession(SessionId);
/// Mark a session disconnected.
DisconnectSession(SessionId);
/// Open a new SFTP tab for a session, like a new terminal connection.
OpenExplorer(SessionId);
/// Open the edit-session dialog for a session.
EditSession(SessionId);
/// Duplicate a session in the same group.
DuplicateSession(SessionId);
/// Ask for confirmation, then delete a session.
DeleteSession(SessionId);
/// Copy a session's host field (an IP address or a host name) to the
/// Open a new terminal connection for a host.
ConnectHost(HostId);
/// Mark a host disconnected.
DisconnectHost(HostId);
/// Open a new SFTP tab for a host, like a new terminal connection.
OpenExplorer(HostId);
/// Open the edit-host dialog for a host.
EditHost(HostId);
/// Duplicate a host in the same group.
DuplicateHost(HostId);
/// Ask for confirmation, then delete a host.
DeleteHost(HostId);
/// Copy a host's address (an IP address or a host name) to the
/// clipboard.
CopySessionHost(SessionId);
/// Copy a session's public id to the clipboard, for another tool to
CopyHostAddress(HostId);
/// Copy a host's public id to the clipboard, for another tool to
/// name the machine by.
CopySessionId(SessionId);
CopyHostId(HostId);
/// Open a terminal connection for every session in this group's subtree.
/// Open a terminal connection for every host in this group's subtree.
ConnectGroup(GroupId);
/// Open the new-session dialog with this group pre-selected.
NewSessionInGroup(GroupId);
/// Open the new-host dialog with this group pre-selected.
NewHostInGroup(GroupId);
/// Open the new-group dialog for a group nested inside this one.
NewChildGroup(GroupId);
/// Open the rename-group dialog.
@@ -153,7 +153,7 @@ id_actions! {
/// Close one remote terminal connection and its tab.
CloseTerminal(RemoteTerminalId);
/// Reconnect one remote terminal using the session's latest settings.
/// Reconnect one remote terminal using the host's latest settings.
ReconnectTerminal(RemoteTerminalId);
/// Disconnect one remote terminal, keeping its tab to reconnect from.
DisconnectTerminal(RemoteTerminalId);
@@ -197,11 +197,11 @@ id_actions! {
DeleteCredential(CredentialId);
}
/// Move a session-tree row by dropping it beside a peer or into a group.
/// Move a host-tree row by dropping it beside a peer or into a group.
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct MoveSessionNode {
pub source: SessionNode,
pub struct MoveHostNode {
pub source: HostNode,
pub destination: NodeDrop,
}
+3 -3
View File
@@ -54,8 +54,8 @@ icon_assets!(
/// Names of the icons in `ExtraIcons`, usable anywhere an `Icon` is built.
pub use gpui_kit::assets::IconName as CatalogIcon;
/// Operating-system marks for the session tree, one file per
/// [`crate::session::HostOs`] variant.
/// Operating-system marks for the host tree, one file per
/// [`crate::host::HostOs`] variant.
///
/// These are brand logos, not Lucide glyphs, because an approximation cannot
/// say "this is a Debian box". The artwork comes from Simple Icons (CC0); the
@@ -127,7 +127,7 @@ impl AssetSource for AppAssets {
#[cfg(test)]
mod tests {
use super::AppAssets;
use crate::session::HostOs;
use crate::host::HostOs;
use gpui_kit::AssetSource as _;
#[test]
+12 -12
View File
@@ -17,9 +17,9 @@ use gpui_kit::*;
use crate::terminal::{TERMINAL_FIND_KEY_CONTEXT, TERMINAL_KEY_CONTEXT, terminal_key_bindings};
/// Key context of the session panel, for bindings that only apply there.
pub const SESSION_PANEL_CONTEXT: &str = "SessionPanel";
pub const RECENT_SESSIONS_CONTEXT: &str = "RecentSessions";
/// Key context of the host panel, for bindings that only apply there.
pub const HOST_PANEL_CONTEXT: &str = "HostPanel";
pub const RECENT_HOSTS_CONTEXT: &str = "RecentHosts";
/// Key context of the port-forwarding list.
pub const FORWARD_PANEL_CONTEXT: &str = "ForwardPanel";
/// Key context of the credential list.
@@ -48,9 +48,9 @@ pub(crate) fn deepen_list_hover(cx: &mut App) {
Theme::sync_base(cx);
}
/// Give the session tree three times the list hover's contrast against its sidebar.
/// The recent-session list keeps the shared hover color unchanged.
pub(crate) fn session_tree_selection_color(theme: &Theme) -> Hsla {
/// Give the host tree three times the list hover's contrast against its sidebar.
/// The recent-host list keeps the shared hover color unchanged.
pub(crate) fn host_tree_selection_color(theme: &Theme) -> Hsla {
let sidebar = theme.sidebar;
let hover = sidebar.blend(theme.list_hover);
let mut selected = hover.alpha(1.0);
@@ -66,9 +66,9 @@ fn key_bindings() -> Vec<KeyBinding> {
let primary = |key: &str| format!("{PRIMARY}-{key}");
let mut bindings = vec![
KeyBinding::new(&primary("n"), NewSession, None),
KeyBinding::new(&primary("n"), NewHost, None),
KeyBinding::new(&primary("shift-n"), NewGroup, None),
KeyBinding::new(&primary("b"), ToggleSessionPanel, None),
KeyBinding::new(&primary("b"), ToggleHostPanel, None),
KeyBinding::new(&primary("k"), FocusSearch, None),
KeyBinding::new(&primary("t"), NewLocalTerminal, None),
KeyBinding::new(&primary("w"), CloseActiveTab, None),
@@ -78,8 +78,8 @@ fn key_bindings() -> Vec<KeyBinding> {
KeyBinding::new(&primary("0"), ZoomReset, None),
KeyBinding::new(&primary("q"), Quit, None),
KeyBinding::new("shift-escape", ToggleZoom, None),
KeyBinding::new("enter", ConnectSelected, Some(SESSION_PANEL_CONTEXT)),
KeyBinding::new("enter", ConnectSelected, Some(RECENT_SESSIONS_CONTEXT)),
KeyBinding::new("enter", ConnectSelected, Some(HOST_PANEL_CONTEXT)),
KeyBinding::new("enter", ConnectSelected, Some(RECENT_HOSTS_CONTEXT)),
KeyBinding::new("enter", ToggleSelectedForward, Some(FORWARD_PANEL_CONTEXT)),
KeyBinding::new("up", SelectPreviousForward, Some(FORWARD_PANEL_CONTEXT)),
KeyBinding::new("down", SelectNextForward, Some(FORWARD_PANEL_CONTEXT)),
@@ -103,7 +103,7 @@ fn key_bindings() -> Vec<KeyBinding> {
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-shift-v", PasteTerminal, Some(TERMINAL_KEY_CONTEXT)),
// In a terminal ⌘K clears, as in other macOS terminals; everywhere
// else it still focuses the session search. Inside a terminal, Ctrl
// else it still focuses the host search. Inside a terminal, Ctrl
// with a letter belongs to the shell, so other platforms add Shift, as
// they do for copy and paste.
#[cfg(target_os = "macos")]
@@ -186,7 +186,7 @@ fn file_list_key_bindings() -> Vec<KeyBinding> {
),
bind("alt-left", ExplorerCommand::Back { remote }, context),
bind("alt-right", ExplorerCommand::Forward { remote }, context),
// WinSCP's Ctrl+B already toggles the session panel here.
// WinSCP's Ctrl+B already toggles the host panel here.
bind(
&format!("{PRIMARY}-d"),
ExplorerCommand::AddBookmark { remote, path: None },
+1 -1
View File
@@ -17,7 +17,7 @@ pub fn data_dir() -> PathBuf {
.unwrap_or_else(|| PathBuf::from("."))
}
/// The SQLite file holding sessions and groups. The parent directory is
/// The SQLite file holding hosts and groups. The parent directory is
/// created if it does not exist yet.
pub fn database_path() -> std::io::Result<PathBuf> {
let dir = data_dir();
+16 -16
View File
@@ -5,7 +5,7 @@ description: This skill should be used when the user asks to "connect to a serve
# ShellRS CLI
Use the `shellrs` CLI to list the user's saved SSH sessions, run one-off commands, and transfer files through the user's running ShellRS desktop app. ShellRS holds the credentials and connects with the saved configuration.
Use the `shellrs` CLI to list the user's saved SSH hosts, run one-off commands, and transfer files through the user's running ShellRS desktop app. ShellRS holds the credentials and connects with the saved configuration.
Prefer `shellrs` over running `ssh`, `scp`, or `sftp` directly.
Run `shellrs --help` or `shellrs <command> --help` for details. `list` prints JSON when piped or given `--json`.
@@ -14,18 +14,18 @@ Run `shellrs --help` or `shellrs <command> --help` for details. `list` prints JS
```bash
shellrs list --query <keyword> --json
shellrs exec <session-id> "<command>"
shellrs upload <session-id> <local-path> <remote-path>
shellrs download <session-id> <remote-path> <local-path>
shellrs exec <host-id> "<command>"
shellrs upload <host-id> <local-path> <remote-path>
shellrs download <host-id> <remote-path> <local-path>
```
`<session-id>` is the 16-character `id` from `shellrs list` (the same ID that ShellRS copies with 复制 ID).
`<host-id>` is the 16-character `id` from `shellrs list` (the same ID that ShellRS copies with 复制 ID).
For a simple command, pass one complete remote shell command string as `<command>`.
Pass a command through stdin when it contains quotes, pipes, redirects, JSON, `$`, backticks, or nested shell code, and quote the heredoc delimiter so the local shell leaves it alone:
```bash
shellrs exec <session-id> --stdin <<'EOF'
shellrs exec <host-id> --stdin <<'EOF'
<command>
EOF
```
@@ -33,10 +33,10 @@ EOF
## File Transfer
```bash
shellrs upload <session-id> ./dist /tmp/dist
shellrs upload <session-id> ./app.tar.gz /tmp/release.tar.gz
shellrs download <session-id> /var/log/nginx ./logs/nginx
shellrs download <session-id> /tmp/app.tar.gz ./latest.tar.gz
shellrs upload <host-id> ./dist /tmp/dist
shellrs upload <host-id> ./app.tar.gz /tmp/release.tar.gz
shellrs download <host-id> /var/log/nginx ./logs/nginx
shellrs download <host-id> /tmp/app.tar.gz ./latest.tar.gz
```
## Windows
@@ -48,10 +48,10 @@ In PowerShell, pass multi-line commands and commands containing quotes through s
```powershell
@'
<command>
'@ | shellrs exec <session-id> --stdin
'@ | shellrs exec <host-id> --stdin
```
Before running `shellrs` in PowerShell, make it read and write UTF-8, or non-ASCII text (such as Chinese session names and remote output) turns into `?` or mojibake:
Before running `shellrs` in PowerShell, make it read and write UTF-8, or non-ASCII text (such as the Chinese names of saved hosts and remote output) turns into `?` or mojibake:
```powershell
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
@@ -62,10 +62,10 @@ The exit code is in `$LASTEXITCODE`.
## Rules
- Do not ask the user for SSH passwords, private keys, or passphrases. ShellRS holds those credentials.
- `shellrs exec` takes a session id and one complete remote shell command string, opens a temporary SSH connection, runs the command, and closes the connection. Its stdout and stderr are the remote command's, and its exit code is the remote command's exit code.
- `shellrs exec` takes a host id and one complete remote shell command string, opens a temporary SSH connection, runs the command, and closes the connection. Its stdout and stderr are the remote command's, and its exit code is the remote command's exit code.
- The remote command gets no stdin, so interactive programs (editors, pagers, password prompts) will not work. Use non-interactive flags such as `--yes`, `-y`, or `--no-pager`.
- Long-running tasks should use tmux, nohup, or systemd on the remote host.
- `shellrs upload` takes a session id, a local file or folder path, and a remote destination path. `shellrs download` takes a session id, a remote file or folder path, and a local destination path. Folders are copied recursively, and existing files are overwritten.
- `shellrs upload` takes a host id, a local file or folder path, and a remote destination path. `shellrs download` takes a host id, a remote file or folder path, and a local destination path. Folders are copied recursively, and existing files are overwritten.
- Destination paths follow scp: if the destination exists and is a directory, the source keeps its name inside that directory; otherwise the destination path is the final file or folder path, and its parent directory must exist.
## Errors
@@ -74,7 +74,7 @@ Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. E
- `not_running`: ShellRS is not open. Ask the user to open ShellRS.
- `not_enabled`: ask the user to turn on 启用外部 CLI in ShellRS under 设置 → 外部 CLI.
- `session_not_found`: run `shellrs list` again and use an `id` from its output.
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the session once in ShellRS and save the password.
- `host_not_found`: run `shellrs list` again and use an `id` from its output.
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password.
- `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it.
- `connect_failed` saying there is no permission to connect to ShellRS: the agent's sandbox blocks local inter-process communication. Ask the user to allow it, or run the command outside the sandbox.
+15 -16
View File
@@ -11,10 +11,10 @@ use std::{
use unicode_width::UnicodeWidthStr as _;
use super::protocol::{
Envelope, ErrorCode, FrameKind, PROTOCOL_VERSION, Reply, Request, SessionInfo,
TransferCounters, TransferSummary, parse_json, read_frame, write_json,
Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request, TransferCounters,
TransferSummary, parse_json, read_frame, write_json,
};
use crate::session::HostOs;
use crate::host::HostOs;
/// Exit code when the command could not do what was asked at all, as ssh
/// uses it.
@@ -193,8 +193,8 @@ where
progress.show(console.stderr, &progress_line(counters));
}
}
Reply::Sessions { sessions } => {
print_sessions(&sessions, console)?;
Reply::Hosts { hosts } => {
print_hosts(&hosts, console)?;
return Ok(0);
}
Reply::TransferDone(summary) => return Ok(print_summary(&summary, console)?),
@@ -238,24 +238,23 @@ impl ProgressLine {
}
}
fn print_sessions(sessions: &[SessionInfo], console: &mut Console) -> io::Result<()> {
fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
if console.json {
serde_json::to_writer_pretty(&mut *console.stdout, sessions).map_err(io::Error::other)?;
serde_json::to_writer_pretty(&mut *console.stdout, hosts).map_err(io::Error::other)?;
return writeln!(console.stdout);
}
if sessions.is_empty() {
if hosts.is_empty() {
return writeln!(console.stderr, "没有匹配的主机");
}
let rows: Vec<[String; 5]> = sessions
let rows: Vec<[String; 5]> = hosts
.iter()
.map(|session| {
.map(|host| {
[
session.id.clone(),
session.name.clone(),
session.group.clone().unwrap_or_default(),
session.address(),
session
.os
host.id.clone(),
host.name.clone(),
host.group.clone().unwrap_or_default(),
host.address(),
host.os
.as_deref()
.and_then(HostOs::from_stored)
.map(|os| os.label().to_string())
+15 -18
View File
@@ -1,5 +1,5 @@
//! The external CLI: the `shellrs` command AI agents use to run commands
//! and move files on the user's saved sessions. The command holds no
//! and move files on the user's saved hosts. The command holds no
//! credentials and opens no database: it hands each request to the running
//! app over a local socket, and the app connects with what it has saved.
@@ -32,17 +32,17 @@ pub use install::{
update_outdated_binary, update_outdated_skills,
};
pub use integration::{CliIntegration, IntegrationStatus};
pub use protocol::{CliError, ErrorCode, Request, SessionInfo, TransferCounters, TransferSummary};
pub use protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
pub use server::{CliBackend, CliServer, CliTarget};
const AFTER_HELP: &str = "\
Sessions are named by the 16-character ID that `shellrs list` prints (the one ShellRS copies with 复制 ID).
Hosts are named by the 16-character ID that `shellrs list` prints (the one ShellRS copies with 复制 ID).
ShellRS must be running, with 设置 → 外部 CLI → 启用外部 CLI turned on.
Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;
255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).";
/// Run commands and move files on the SSH sessions saved in ShellRS.
/// Run commands and move files on the SSH hosts saved in ShellRS.
///
/// ShellRS holds the passwords and keys and makes the connections; this
/// command never sees them.
@@ -55,22 +55,22 @@ struct Cli {
#[derive(Debug, Subcommand)]
enum Command {
/// List the saved sessions.
/// List the saved hosts.
List {
/// Only sessions whose name, host, user, group or ID matches.
/// Only hosts whose name, host, user, group or ID matches.
#[arg(short, long)]
query: Option<String>,
/// Print JSON. Implied when stdout is not a terminal.
#[arg(long)]
json: bool,
},
/// Run one command on a session's host and print its output.
/// Run one command on a saved host and print its output.
///
/// Opens a temporary connection, runs the command in the login shell,
/// and closes it. The command gets no stdin. The exit code is the
/// remote command's.
Exec {
/// Session ID, from `shellrs list`.
/// Host ID, from `shellrs list`.
id: String,
/// One complete remote shell command, quoted as one argument.
#[arg(required_unless_present = "stdin")]
@@ -80,14 +80,14 @@ enum Command {
#[arg(long, conflicts_with = "command")]
stdin: bool,
},
/// Copy a local file or folder to a session's host.
/// Copy a local file or folder to a saved host.
///
/// Like scp: when the destination is an existing directory the source
/// goes inside it under its own name; otherwise the destination is the
/// copy's path. Folders are copied recursively; existing files are
/// overwritten.
Upload {
/// Session ID, from `shellrs list`.
/// Host ID, from `shellrs list`.
id: String,
/// Local file or folder.
local: PathBuf,
@@ -97,11 +97,11 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Copy a file or folder from a session's host to this machine.
/// Copy a file or folder from a saved host to this machine.
///
/// Destination rules are scp's, as for `upload`.
Download {
/// Session ID, from `shellrs list`.
/// Host ID, from `shellrs list`.
id: String,
/// Remote file or folder. `~` is the login directory.
remote: String,
@@ -167,10 +167,7 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
if command.trim().is_empty() {
return Err(console.error(ErrorCode::BadRequest, "命令不能为空"));
}
Request::Exec {
session: id,
command,
}
Request::Exec { host: id, command }
}
Command::Upload {
id,
@@ -180,7 +177,7 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
} => {
console.json |= json;
Request::Upload {
session: id,
host: id,
source: absolute(local, console)?,
destination: remote,
}
@@ -193,7 +190,7 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
} => {
console.json |= json;
Request::Download {
session: id,
host: id,
source: remote,
destination: absolute(local, console)?,
}
+14 -14
View File
@@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned};
/// Bumped when a request or reply changes shape. The command and the app
/// come from the same build, so they differ only while an older copy still
/// runs, or on Windows while the copy on the PATH has not been updated.
pub const PROTOCOL_VERSION: u32 = 1;
pub const PROTOCOL_VERSION: u32 = 2;
/// Frames larger than this are refused, so a confused peer cannot make the
/// other side allocate without bound.
@@ -109,18 +109,18 @@ pub enum Request {
query: Option<String>,
},
Exec {
session: String,
host: String,
command: String,
},
/// `source` is absolute: the app's working directory is not the
/// caller's.
Upload {
session: String,
host: String,
source: PathBuf,
destination: String,
},
Download {
session: String,
host: String,
source: String,
destination: PathBuf,
},
@@ -133,8 +133,8 @@ pub enum Request {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Reply {
Sessions {
sessions: Vec<SessionInfo>,
Hosts {
hosts: Vec<HostInfo>,
},
/// How far a transfer has come.
Progress(TransferCounters),
@@ -151,10 +151,10 @@ pub enum Reply {
},
}
/// A saved session as the CLI lists it.
/// A saved host as the CLI lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionInfo {
/// The session's public ID, the one 复制 ID copies.
pub struct HostInfo {
/// The host's public ID, the one 复制 ID copies.
pub id: String,
pub name: String,
/// The full group path, such as `生产/数据库`; `None` at the root.
@@ -166,7 +166,7 @@ pub struct SessionInfo {
pub os: Option<String>,
}
impl SessionInfo {
impl HostInfo {
pub fn address(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
}
@@ -200,7 +200,7 @@ pub enum ErrorCode {
/// 启用外部 CLI is off.
NotEnabled,
VersionMismatch,
SessionNotFound,
HostNotFound,
HostKeyUnknown,
HostKeyChanged,
MissingCredential,
@@ -215,7 +215,7 @@ impl ErrorCode {
ErrorCode::NotRunning => "not_running",
ErrorCode::NotEnabled => "not_enabled",
ErrorCode::VersionMismatch => "version_mismatch",
ErrorCode::SessionNotFound => "session_not_found",
ErrorCode::HostNotFound => "host_not_found",
ErrorCode::HostKeyUnknown => "host_key_unknown",
ErrorCode::HostKeyChanged => "host_key_changed",
ErrorCode::MissingCredential => "missing_credential",
@@ -254,7 +254,7 @@ mod tests {
&Envelope {
version: PROTOCOL_VERSION,
request: Request::Exec {
session: "VmLkf1snMOuPKJ07".into(),
host: "VmLkf1snMOuPKJ07".into(),
command: "uname -a".into(),
},
},
@@ -269,7 +269,7 @@ mod tests {
assert_eq!(
envelope.request,
Request::Exec {
session: "VmLkf1snMOuPKJ07".into(),
host: "VmLkf1snMOuPKJ07".into(),
command: "uname -a".into(),
}
);
+39 -39
View File
@@ -16,16 +16,16 @@ use std::{
use tokio::sync::watch;
use super::protocol::{
CliError, Envelope, ErrorCode, FrameKind, PROTOCOL_VERSION, Reply, Request, SessionInfo,
CliError, Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request,
TransferCounters, TransferSummary, parse_json, read_frame, write_frame, write_json,
};
use crate::session::{Session, SessionLogin, SessionStore, matches_query};
use crate::host::{Host, HostLogin, HostStore, matches_query};
use crate::ssh::ExecStream;
/// Does what a CLI request asks. Every method blocks: each request has a
/// thread of its own.
pub trait CliBackend: Send + Sync + 'static {
/// Run `command` on the session's host, passing its output on as it
/// Run `command` on the host, passing its output on as it
/// comes; the remote exit code. When `output` fails, the caller is gone
/// and the command should be abandoned.
fn exec(
@@ -57,65 +57,65 @@ pub trait CliBackend: Send + Sync + 'static {
) -> Result<TransferSummary, CliError>;
}
/// A saved session as the CLI sees it, with the login it connects with
/// A saved host as the CLI sees it, with the login it connects with
/// resolved when the target list was built.
#[derive(Clone, Debug)]
pub struct CliTarget {
info: SessionInfo,
session: Session,
login: SessionLogin,
info: HostInfo,
host: Host,
login: HostLogin,
}
impl CliTarget {
/// `group` is the session's full group path, if it has one.
pub fn new(session: &Session, login: SessionLogin, group: Option<String>) -> Self {
/// `group` is the host's full group path, if it has one.
pub fn new(host: &Host, login: HostLogin, group: Option<String>) -> Self {
Self {
info: SessionInfo {
id: session.public_id.to_string(),
name: session.name.to_string(),
info: HostInfo {
id: host.public_id.to_string(),
name: host.name.to_string(),
group,
user: session.user.to_string(),
host: session.host.to_string(),
port: session.port,
os: session.os.map(|os| os.as_str().to_string()),
user: host.user.to_string(),
host: host.address.to_string(),
port: host.port,
os: host.os.map(|os| os.as_str().to_string()),
},
session: session.clone(),
host: host.clone(),
login,
}
}
pub fn session(&self) -> &Session {
&self.session
pub fn host(&self) -> &Host {
&self.host
}
/// How a request logs in to the session's host.
pub fn login(&self) -> &SessionLogin {
/// How a request logs in to the host.
pub fn login(&self) -> &HostLogin {
&self.login
}
/// Every session in `store`, with its group path.
pub fn all(store: &SessionStore) -> Vec<Self> {
/// Every host in `store`, with its group path.
pub fn all(store: &HostStore) -> Vec<Self> {
store
.sessions()
.hosts()
.iter()
.map(|session| {
let names = session
.map(|host| {
let names = host
.group
.map(|id| store.group_names(id))
.unwrap_or_default();
Self::new(
session,
store.login_of(session),
host,
store.login_of(host),
(!names.is_empty()).then(|| names.join("/")),
)
})
.collect()
}
/// The session tree's search, plus the group path and the ID.
/// The host tree's search, plus the group path and the ID.
fn matches(&self, query: &str) -> bool {
let needle = query.trim().to_lowercase();
matches_query(&self.session, query)
matches_query(&self.host, query)
|| self
.info
.group
@@ -445,15 +445,15 @@ fn respond(
.targets
.read()
.unwrap_or_else(|error| error.into_inner());
let sessions = targets
let hosts = targets
.iter()
.filter(|target| query.as_deref().is_none_or(|query| target.matches(query)))
.map(|target| target.info.clone())
.collect();
Ok(Reply::Sessions { sessions })
Ok(Reply::Hosts { hosts })
}
Request::Exec { session, command } => {
let target = find(shared, &session)?;
Request::Exec { host, command } => {
let target = find(shared, &host)?;
let code = shared
.backend
.exec(&target, &command, &mut |stream, bytes| {
@@ -466,11 +466,11 @@ fn respond(
Ok(Reply::Exit { code })
}
Request::Upload {
session,
host,
source,
destination,
} => {
let target = find(shared, &session)?;
let target = find(shared, &host)?;
if !source.is_absolute() {
return Err(not_absolute(&source));
}
@@ -482,11 +482,11 @@ fn respond(
.map(Reply::TransferDone)
}
Request::Download {
session,
host,
source,
destination,
} => {
let target = find(shared, &session)?;
let target = find(shared, &host)?;
if !destination.is_absolute() {
return Err(not_absolute(&destination));
}
@@ -512,7 +512,7 @@ fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
.cloned()
.ok_or_else(|| {
CliError::new(
ErrorCode::SessionNotFound,
ErrorCode::HostNotFound,
format!("没有 ID 为 {id} 的主机:请用 shellrs list 查看"),
)
})
+29 -34
View File
@@ -10,14 +10,12 @@ use std::{
use clap::Parser as _;
use super::client::{self, Console};
use super::protocol::{
CliError, ErrorCode, Request, SessionInfo, TransferCounters, TransferSummary,
};
use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
use super::server::{CliBackend, CliServer, CliTarget};
use super::{Cli, Command};
use super::{ConsoleText, normalize_command};
use crate::app::cli_endpoint;
use crate::session::{AuthKind, GroupId, Session, SessionDraft, SessionId, SessionLogin};
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin};
use crate::ssh::ExecStream;
/// Records what it was asked and answers from a script.
@@ -36,7 +34,7 @@ impl CliBackend for FakeBackend {
self.calls
.lock()
.unwrap()
.push(format!("exec {} {command}", target.session().name));
.push(format!("exec {} {command}", target.host().name));
if command == "unreachable" {
return Err(CliError::new(ErrorCode::HostKeyUnknown, "尚未信任"));
}
@@ -54,7 +52,7 @@ impl CliBackend for FakeBackend {
) -> Result<TransferSummary, CliError> {
self.calls.lock().unwrap().push(format!(
"upload {} {} {destination}",
target.session().name,
target.host().name,
source.display()
));
progress(TransferCounters {
@@ -84,10 +82,10 @@ impl CliBackend for FakeBackend {
}
}
fn session(id: u64, name: &str, host: &str) -> Session {
Session::new(
SessionId(id),
SessionDraft::new(name, host, 22, "root", AuthKind::Password, Some(GroupId(1))),
fn host(id: u64, name: &str, host: &str) -> Host {
Host::new(
HostId(id),
HostDraft::new(name, host, 22, "root", AuthKind::Password, Some(GroupId(1))),
)
}
@@ -96,7 +94,7 @@ struct Fixture {
socket: PathBuf,
server: CliServer,
backend: Arc<FakeBackend>,
web: Session,
web: Host,
}
fn fixture() -> Fixture {
@@ -104,11 +102,11 @@ fn fixture() -> Fixture {
let socket = cli_endpoint(dir.path());
let backend = Arc::new(FakeBackend::default());
let server = CliServer::start(socket.clone(), backend.clone()).unwrap();
let web = session(1, "web-01", "10.0.1.12");
let db = session(2, "db-01", "10.0.2.5");
let web = host(1, "web-01", "10.0.1.12");
let db = host(2, "db-01", "10.0.2.5");
server.set_targets(vec![
CliTarget::new(&web, SessionLogin::of(&web, None), Some("生产".into())),
CliTarget::new(&db, SessionLogin::of(&db, None), Some("生产/数据库".into())),
CliTarget::new(&web, HostLogin::of(&web, None), Some("生产".into())),
CliTarget::new(&db, HostLogin::of(&db, None), Some("生产/数据库".into())),
]);
server.set_enabled(true);
Fixture {
@@ -151,8 +149,8 @@ fn local(name: &str) -> PathBuf {
std::env::temp_dir().join(name)
}
fn id(session: &Session) -> String {
session.public_id.to_string()
fn id(host: &Host) -> String {
host.public_id.to_string()
}
#[test]
@@ -167,11 +165,8 @@ fn listing_filters_by_name_host_group_or_id() {
true,
);
assert_eq!(code, 0);
let sessions: Vec<SessionInfo> = serde_json::from_str(&stdout).unwrap();
sessions
.into_iter()
.map(|session| session.name)
.collect::<Vec<_>>()
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
hosts.into_iter().map(|host| host.name).collect::<Vec<_>>()
};
assert_eq!(list(None), ["web-01", "db-01"]);
assert_eq!(list(Some("WEB")), ["web-01"]);
@@ -181,10 +176,10 @@ fn listing_filters_by_name_host_group_or_id() {
assert!(list(Some("nothing")).is_empty());
let (_, stdout, _) = run(&fixture.socket, Request::List { query: None }, true);
let sessions: Vec<SessionInfo> = serde_json::from_str(&stdout).unwrap();
assert_eq!(sessions[0].id, id(&fixture.web));
assert_eq!(sessions[0].group.as_deref(), Some("生产"));
assert_eq!(sessions[0].address(), "root@10.0.1.12:22");
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
assert_eq!(hosts[0].id, id(&fixture.web));
assert_eq!(hosts[0].group.as_deref(), Some("生产"));
assert_eq!(hosts[0].address(), "root@10.0.1.12:22");
}
#[test]
@@ -210,7 +205,7 @@ fn exec_passes_output_through_as_bytes_and_exits_with_the_remote_code() {
let (code, stdout, stderr) = run(
&fixture.socket,
Request::Exec {
session: id(&fixture.web),
host: id(&fixture.web),
command: "uname -a".into(),
},
false,
@@ -230,7 +225,7 @@ fn failures_carry_their_code_and_exit_255() {
let (code, _, stderr) = run(
&fixture.socket,
Request::Exec {
session: id(&fixture.web),
host: id(&fixture.web),
command: "unreachable".into(),
},
false,
@@ -245,7 +240,7 @@ fn failures_carry_their_code_and_exit_255() {
let (code, _, stderr) = run(
&fixture.socket,
Request::Exec {
session: "nobody".into(),
host: "nobody".into(),
command: "true".into(),
},
false,
@@ -254,14 +249,14 @@ fn failures_carry_their_code_and_exit_255() {
assert!(
String::from_utf8(stderr)
.unwrap()
.contains("[session_not_found]")
.contains("[host_not_found]")
);
// A path relative to the command is meaningless to the app.
let (code, _, stderr) = run(
&fixture.socket,
Request::Upload {
session: id(&fixture.web),
host: id(&fixture.web),
source: "relative/file".into(),
destination: "/tmp".into(),
},
@@ -283,7 +278,7 @@ fn with_the_switch_off_nothing_reaches_the_backend() {
let (code, stdout, stderr) = run(
&fixture.socket,
Request::Exec {
session: id(&fixture.web),
host: id(&fixture.web),
command: "true".into(),
},
false,
@@ -370,7 +365,7 @@ fn a_transfer_reports_its_summary_and_exits_1_when_items_failed() {
let (code, stdout, _) = run(
&fixture.socket,
Request::Upload {
session: id(&fixture.web),
host: id(&fixture.web),
source: local("dist"),
destination: "~/dist".into(),
},
@@ -392,7 +387,7 @@ fn progress_is_rewritten_in_place_without_escape_sequences() {
let (code, _, stderr) = run_on(
&fixture.socket,
Request::Upload {
session: id(&fixture.web),
host: id(&fixture.web),
source: local("dist"),
destination: "~/dist".into(),
},
+6 -6
View File
@@ -252,10 +252,10 @@ impl std::fmt::Debug for ConnectionPromptReply {
}
}
/// What a connection test logs in with: the session form's current values,
/// What a connection test logs in with: the host form's current values,
/// saved or not. Secrets stay zeroized and never print.
pub struct LoginTest {
login: crate::session::SessionLogin,
login: crate::host::HostLogin,
/// The secrets typed into the form, for a login typed there. `None` for
/// a login through a credential, which uses what the credential saved.
typed: Option<TypedSecrets>,
@@ -273,7 +273,7 @@ struct TypedSecrets {
impl LoginTest {
/// A login typed into the form. The form decides its password and its
/// key's passphrase: an empty field means none, whatever is saved.
pub fn typed(login: crate::session::SessionLogin) -> Self {
pub fn typed(login: crate::host::HostLogin) -> Self {
Self {
login,
typed: Some(TypedSecrets::default()),
@@ -282,7 +282,7 @@ impl LoginTest {
}
/// A login through a saved credential, with the secrets saved for it.
pub fn saved(login: crate::session::SessionLogin) -> Self {
pub fn saved(login: crate::host::HostLogin) -> Self {
Self {
login,
typed: None,
@@ -313,7 +313,7 @@ impl LoginTest {
self
}
pub fn login(&self) -> &crate::session::SessionLogin {
pub fn login(&self) -> &crate::host::HostLogin {
&self.login
}
pub fn host(&self) -> &str {
@@ -365,7 +365,7 @@ impl std::fmt::Debug for LoginTest {
/// until a person decides; `false` also covers "nobody is there to ask".
pub type TrustCallback = Box<dyn Fn(UnknownHostPrompt) -> bool + Send + Sync>;
/// Logs in once with a [`LoginTest`] and hangs up, so the session form can
/// Logs in once with a [`LoginTest`] and hangs up, so the host form can
/// check its values before saving them.
pub trait ConnectionTester: Send + Sync + 'static {
/// Blocks: call it on a thread of its own. `Err` holds the reason, worded
+9 -9
View File
@@ -13,11 +13,11 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use zeroize::Zeroizing;
use crate::secrets::SecretRef;
use crate::session::{
CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, GeneratedKey, KeyAlgorithm,
PastedKey, PastedKeyError, SecretFields, SessionStore, read_public_key,
use crate::host::{
CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, GeneratedKey, HostStore,
KeyAlgorithm, PastedKey, PastedKeyError, SecretFields, read_public_key,
};
use crate::secrets::SecretRef;
use crate::shared::{Segment, SegmentedControl, commit_footer, form_error};
/// Where the SSH agent is found, as the form explains it.
@@ -81,7 +81,7 @@ enum PublicKeyView {
/// The body of the new/edit credential dialog.
pub struct CredentialForm {
store: Entity<SessionStore>,
store: Entity<HostStore>,
editing: Option<CredentialId>,
kind: CredentialKind,
name: Entity<InputState>,
@@ -119,7 +119,7 @@ pub struct CredentialForm {
impl CredentialForm {
pub fn new(
dialog: CredentialDialog,
store: Entity<SessionStore>,
store: Entity<HostStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -131,11 +131,11 @@ impl CredentialForm {
let read = store.read(cx);
let credential = editing.and_then(|id| read.credential(id)).cloned();
let using: Vec<_> = editing
.map(|id| read.sessions_using(id).collect())
.map(|id| read.hosts_using(id).collect())
.unwrap_or_default();
let connected = using
.iter()
.filter(|session| session.state.is_connected())
.filter(|host| host.state.is_connected())
.count();
let kept_key = credential
.as_ref()
@@ -849,7 +849,7 @@ impl Render for CredentialForm {
/// the edit dialog of an existing one.
pub fn open_credential_dialog(
dialog: CredentialDialog,
store: Entity<SessionStore>,
store: Entity<HostStore>,
window: &mut Window,
cx: &mut App,
) {
+8 -8
View File
@@ -19,15 +19,15 @@ use crate::app::{
EditCredential, EditSelectedCredential, GenerateCredentialKey, NewCredential,
SelectNextCredential, SelectPreviousCredential,
};
use crate::session::{CredentialId, CredentialKind, SessionStore, matches_credential_query};
use crate::host::{CredentialId, CredentialKind, HostStore, matches_credential_query};
/// The credential list the left dock shows in place of the sessions: every
/// The credential list the left dock shows in place of the hosts: every
/// saved login and how many hosts use it.
///
/// The credentials live in the shared `SessionStore`; this panel observes it
/// The credentials live in the shared `HostStore`; this panel observes it
/// and owns only its selection and search.
pub struct CredentialPanel {
store: Entity<SessionStore>,
store: Entity<HostStore>,
/// The workspace's focus handle: actions dispatched on it reach the
/// workspace handlers whatever is focused, which after a dialog closes
/// may be nothing at all.
@@ -60,7 +60,7 @@ struct CredentialRow {
impl CredentialPanel {
pub fn new(
store: Entity<SessionStore>,
store: Entity<HostStore>,
target: FocusHandle,
window: &mut Window,
cx: &mut Context<Self>,
@@ -156,7 +156,7 @@ impl CredentialPanel {
.iter()
.filter(|credential| matches_credential_query(credential, &self.query))
.map(|credential| {
let hosts = store.sessions_using(credential.id).count();
let hosts = store.hosts_using(credential.id).count();
let summary = credential.summary(store.key_dir());
let detail = match hosts {
0 => summary,
@@ -293,7 +293,7 @@ impl CredentialPanel {
.confirmed(selected)
.aria_selected(selected)
.when(selected, |item| {
item.bg(crate::app::session_tree_selection_color(theme))
item.bg(crate::app::host_tree_selection_color(theme))
})
.child(
h_flex()
@@ -435,7 +435,7 @@ impl Render for CredentialPanel {
)
.child(
// The menu hangs off the list, not off the rows, like the
// session tree's and the forward list's.
// host tree's and the forward list's.
div()
.id("credential-list")
.test_support()
+1 -1
View File
@@ -1,7 +1,7 @@
//! 凭据: saved logins hosts share. The list the left dock shows and the
//! dialog that edits one, which also takes pasted keys and generates new
//! ones; the credentials themselves, the keys ShellRS keeps, and how a host
//! logs in with one live in `session`.
//! logs in with one live in `host`.
mod credential_dialog;
mod credential_panel;
+43 -43
View File
@@ -5,11 +5,11 @@ use super::{
use crate::app::ExplorerDispatch as _;
use crate::{
app::{
CatalogIcon, CenterTab, CloseExplorer, CopySessionHost, ExplorerAction, ExplorerCommand,
CatalogIcon, CenterTab, CloseExplorer, CopyHostAddress, ExplorerAction, ExplorerCommand,
OpenExplorer, RenameExplorer,
},
connection::{ConnectionPrompt, ConnectionPromptReply},
session::{BookmarkSide, ConnectionState, SessionId, SessionStore},
host::{BookmarkSide, ConnectionState, HostId, HostStore},
sftp::{
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider,
SharedSftpTransportProvider, TransferDirection, TransferPhase, TransferQuestion,
@@ -37,19 +37,19 @@ static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug)]
pub enum ExplorerPanelEvent {
Activated(ExplorerId),
Closed(ExplorerId, SessionId),
StateChanged(ExplorerId, SessionId),
Closed(ExplorerId, HostId),
StateChanged(ExplorerId, HostId),
/// What the window's status line says for this tab changed without the
/// connection changing: a directory could not be read, or can again.
StatusChanged(ExplorerId),
PromptRequested(ExplorerId, SessionId, u64, ConnectionPrompt),
PromptRequested(ExplorerId, HostId, u64, ConnectionPrompt),
}
pub struct ExplorerPanel {
id: ExplorerId,
session_id: SessionId,
host_id: HostId,
generation: u64,
endpoint: String,
pub(super) store: Entity<SessionStore>,
pub(super) store: Entity<HostStore>,
local: Entity<FilePane>,
pub(super) remote: Entity<FilePane>,
pub(super) local_provider: SharedLocalDirectoryProvider,
@@ -89,8 +89,8 @@ impl ExplorerPanel {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: ExplorerId,
session_id: SessionId,
store: Entity<SessionStore>,
host_id: HostId,
store: Entity<HostStore>,
provider: SharedSftpTransportProvider,
local_provider: SharedLocalDirectoryProvider,
dispatch: FocusHandle,
@@ -99,8 +99,8 @@ impl ExplorerPanel {
) -> Self {
let login = store
.read(cx)
.login(session_id)
.expect("workspace checked session");
.login(host_id)
.expect("workspace checked host");
let endpoint = login.endpoint();
let home = local_provider.home().to_string_lossy().into_owned();
let places = local_provider
@@ -112,7 +112,7 @@ impl ExplorerPanel {
FilePane::new(
PaneSide::Local,
id,
session_id,
host_id,
home.clone(),
places,
store.clone(),
@@ -125,7 +125,7 @@ impl ExplorerPanel {
FilePane::new(
PaneSide::Remote,
id,
session_id,
host_id,
String::new(),
Vec::new(),
store.clone(),
@@ -200,7 +200,7 @@ impl ExplorerPanel {
];
Self {
id,
session_id,
host_id,
generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed),
endpoint,
store,
@@ -230,8 +230,8 @@ impl ExplorerPanel {
pub fn id(&self) -> ExplorerId {
self.id
}
pub fn session_id(&self) -> SessionId {
self.session_id
pub fn host_id(&self) -> HostId {
self.host_id
}
pub fn generation(&self) -> u64 {
self.generation
@@ -283,8 +283,8 @@ impl ExplorerPanel {
let name = self
.store
.read(cx)
.session(self.session_id)
.map_or_else(|| "服务器".into(), |session| session.name.clone());
.host(self.host_id)
.map_or_else(|| "服务器".into(), |host| host.name.clone());
let reason = self
.remote
.read(cx)
@@ -405,7 +405,7 @@ impl ExplorerPanel {
.update(cx, |pane, cx| pane.disconnected("SFTP 已断开".into(), cx));
self.close_transfer_dialog(window, cx);
self.sync_available(cx);
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.session_id));
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id));
cx.notify();
}
/// Close this tab's transfer confirmation or question, if one is open.
@@ -445,25 +445,25 @@ impl ExplorerPanel {
SftpEvent::Connecting => {
self.generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
self.state = ConnectionState::Connecting;
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.session_id));
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id));
}
SftpEvent::Connected { home } => {
self.state = ConnectionState::Connected;
self.remote
.update(cx, |pane, _| pane.set_home(home.to_string()));
self.reload(true, window, cx);
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.session_id));
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id));
}
SftpEvent::Disconnected(message) => {
self.state = ConnectionState::Disconnected;
self.abandon_remote_operations(cx);
self.remote
.update(cx, |pane, cx| pane.disconnected(message, cx));
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.session_id));
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id));
}
SftpEvent::Prompt(prompt) => cx.emit(ExplorerPanelEvent::PromptRequested(
self.id,
self.session_id,
self.host_id,
self.generation,
prompt,
)),
@@ -613,17 +613,17 @@ impl ExplorerPanel {
let path = path
.clone()
.unwrap_or_else(|| self.pane(*remote).read(cx).path());
let (id, side) = (self.session_id, BookmarkSide::from_remote(*remote));
let (id, side) = (self.host_id, BookmarkSide::from_remote(*remote));
self.store
.update(cx, |store, cx| store.add_bookmark(id, side, &path, cx));
}
ExplorerCommand::RemoveBookmark { remote, path } => {
let (id, side) = (self.session_id, BookmarkSide::from_remote(*remote));
let (id, side) = (self.host_id, BookmarkSide::from_remote(*remote));
self.store
.update(cx, |store, cx| store.remove_bookmark(id, side, path, cx));
}
ExplorerCommand::MoveBookmark { remote, path, to } => {
let (id, side) = (self.session_id, BookmarkSide::from_remote(*remote));
let (id, side) = (self.host_id, BookmarkSide::from_remote(*remote));
self.store
.update(cx, |store, cx| store.move_bookmark(id, side, path, *to, cx));
}
@@ -806,9 +806,9 @@ impl BasePanel for ExplorerPanel {
}
fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
if active {
let session_id = self.session_id;
let host_id = self.host_id;
self.store
.update(cx, |store, cx| store.set_active(Some(session_id), cx));
.update(cx, |store, cx| store.set_active(Some(host_id), cx));
// Focus a file list, not the panel root, so list shortcuts work
// without a click first. Both stay inside this panel.
let focus = self.pane(self.last_remote).read(cx).focus_handle(cx);
@@ -823,19 +823,19 @@ impl BasePanel for ExplorerPanel {
self.disconnect(window, cx);
self.send(SftpCommand::Shutdown);
self.tab_group = None;
cx.emit(ExplorerPanelEvent::Closed(self.id, self.session_id));
cx.emit(ExplorerPanelEvent::Closed(self.id, self.host_id));
}
}
impl Panel for ExplorerPanel {
fn title(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let session = self.store.read(cx).session(self.session_id);
let name = session
let host = self.store.read(cx).host(self.host_id);
let name = host
.map(|s| s.name.clone())
.unwrap_or_else(|| "SFTP".into());
let os = session.and_then(|s| s.os);
let os = host.and_then(|s| s.os);
let id = self.id;
let menu = self.tab_menu(cx);
// The host's mark, as on the session's terminal tabs.
// The host's mark, as on the host's terminal tabs.
let mark = HostMark::new(("explorer-tab-os", id.0), name, os).small();
ClosableTabTitle::new(("explorer-tab", id.0), mark, self.tab_title(cx))
.closable(("close-explorer", id.0), Box::new(CloseExplorer(id)))
@@ -851,14 +851,14 @@ impl Panel for ExplorerPanel {
}
/// A terminal tab's buttons that apply here too.
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
let (id, session_id) = (self.id, self.session_id);
let (id, host_id) = (self.id, self.host_id);
Some(vec![
Button::new(("open-sftp", id.0))
.icon(Icon::new(CatalogIcon::FolderTree))
.label("SFTP")
.tooltip("打开 SFTP 文件浏览")
.on_click(move |_, window, cx| {
window.dispatch_action(Box::new(OpenExplorer(session_id)), cx)
window.dispatch_action(Box::new(OpenExplorer(host_id)), cx)
}),
Button::new(("reconnect-sftp", id.0))
.icon(Icon::new(CatalogIcon::RefreshCw))
@@ -881,7 +881,7 @@ impl RenamableTab for ExplorerPanel {
let name = self
.store
.read(cx)
.session(self.session_id)
.host(self.host_id)
.map(|s| s.name.clone())
.unwrap_or_else(|| "SFTP".into());
format!("{name} · SFTP").into()
@@ -958,7 +958,7 @@ pub struct ExplorerStatus {
#[derive(Clone)]
struct TabMenu {
id: ExplorerId,
session_id: SessionId,
host_id: HostId,
host_is_ip: bool,
can_reconnect: bool,
group: Option<WeakEntity<TabGroup>>,
@@ -969,12 +969,12 @@ impl ExplorerPanel {
fn tab_menu(&self, cx: &Context<Self>) -> TabMenu {
TabMenu {
id: self.id,
session_id: self.session_id,
host_id: self.host_id,
host_is_ip: self
.store
.read(cx)
.session(self.session_id)
.is_some_and(|session| session.host_is_ip()),
.host(self.host_id)
.is_some_and(|host| host.address_is_ip()),
can_reconnect: self.can_reconnect(),
group: self.tab_group.clone(),
panel: cx.entity_id(),
@@ -985,7 +985,7 @@ impl ExplorerPanel {
impl TabMenu {
/// A terminal tab's commands that apply to an SFTP tab, then closing.
fn build(&self, menu: PopupMenu, cx: &App) -> PopupMenu {
let (id, session_id) = (self.id, self.session_id);
let (id, host_id) = (self.id, self.host_id);
let copy_host = if self.host_is_ip {
"复制 IP 地址"
} else {
@@ -1000,12 +1000,12 @@ impl TabMenu {
.menu_with_icon(
"打开 SFTP",
Icon::new(CatalogIcon::FolderTree),
Box::new(OpenExplorer(session_id)),
Box::new(OpenExplorer(host_id)),
)
.menu_with_icon(
copy_host,
Icon::new(IconName::Copy),
Box::new(CopySessionHost(session_id)),
Box::new(CopyHostAddress(host_id)),
)
.menu_with_icon_and_disabled(
"重新连接",
+9 -9
View File
@@ -14,7 +14,7 @@ use crate::{
CatalogIcon, ExplorerAction, ExplorerCommand, ExplorerShortcut, LOCAL_FILE_LIST_CONTEXT,
REMOTE_FILE_LIST_CONTEXT,
},
session::{BookmarkSide, ConnectionState, SessionId, SessionStore},
host::{BookmarkSide, ConnectionState, HostId, HostStore},
sftp::{DirectoryListing, SharedLocalDirectoryProvider},
};
use gpui_kit::component::{
@@ -163,8 +163,8 @@ pub struct FilePane {
pub(super) side: PaneSide,
/// The SFTP tab this pane belongs to, which its commands address.
explorer: ExplorerId,
/// The session whose bookmarks it shows.
session_id: SessionId,
/// The host whose bookmarks it shows.
host_id: HostId,
pub(super) path: String,
home: String,
/// Well-known local places for the 目录列表 select; empty for remote.
@@ -205,7 +205,7 @@ pub struct FilePane {
transfer_enabled: bool,
/// A file operation on this pane is running.
busy: bool,
store: Entity<SessionStore>,
store: Entity<HostStore>,
dispatch: FocusHandle,
_subscriptions: Vec<Subscription>,
load_task: Option<Task<()>>,
@@ -215,10 +215,10 @@ impl FilePane {
pub fn new(
side: PaneSide,
explorer: ExplorerId,
session_id: SessionId,
host_id: HostId,
home: String,
places: Vec<(SharedString, String)>,
store: Entity<SessionStore>,
store: Entity<HostStore>,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut Context<Self>,
@@ -257,7 +257,7 @@ impl FilePane {
let mut pane = Self {
side,
explorer,
session_id,
host_id,
path: home.clone(),
home,
places,
@@ -365,7 +365,7 @@ impl FilePane {
pub fn bookmarks(&self, cx: &App) -> Vec<String> {
self.store
.read(cx)
.bookmarks(self.session_id, self.side.bookmark_side())
.bookmarks(self.host_id, self.side.bookmark_side())
.to_vec()
}
pub fn back_target(&self) -> Option<String> {
@@ -1226,7 +1226,7 @@ impl Render for FilePane {
)
})
// One menu for rows, column titles and empty space, on the
// container: see `SessionPanel` for why menus stay off
// container: see `HostPanel` for why menus stay off
// virtual rows. Two menus would also both open, since a
// menu does not stop the click reaching the one around it.
.context_menu(move |menu, window, cx| {
+1 -1
View File
@@ -4,7 +4,7 @@ use crate::sftp::{DirectoryEntry, EntryKind};
use gpui_kit::{Global, SharedString};
use serde::{Deserialize, Serialize};
/// Stable identity for one SFTP tab. A session can have several, each with
/// Stable identity for one SFTP tab. A host can have several, each with
/// its own connection and transfer queue.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct ExplorerId(pub u64);
+7 -7
View File
@@ -1,5 +1,5 @@
//! 打开目录/书签, after WinSCP's Open directory dialog: a directory to go to,
//! and this session's bookmarks for the pane. Picking a bookmark fills in the
//! and this host's bookmarks for the pane. Picking a bookmark fills in the
//! directory; the bookmark matching the directory is the one selected. Changes
//! to the bookmarks are saved as they are made, even if the dialog is then
//! cancelled, as in WinSCP.
@@ -8,7 +8,7 @@ use super::{ExplorerId, ExplorerPanel, expand_path};
use crate::app::ExplorerDispatch as _;
use crate::{
app::{CatalogIcon, ExplorerAction, ExplorerCommand},
session::{BookmarkSide, SessionId, SessionStore},
host::{BookmarkSide, HostId, HostStore},
shared::{commit_footer, form_error},
};
use gpui_kit::component::{
@@ -24,11 +24,11 @@ use gpui_kit::*;
struct OpenDirectoryForm {
remote: bool,
/// The tab the commands address, and the session whose bookmarks these are.
/// The tab the commands address, and the host whose bookmarks these are.
explorer: ExplorerId,
session: SessionId,
host: HostId,
dispatch: FocusHandle,
store: Entity<SessionStore>,
store: Entity<HostStore>,
input: Entity<InputState>,
list_focus: FocusHandle,
/// The pane's directory and home, which `~` and relative paths use.
@@ -42,7 +42,7 @@ impl OpenDirectoryForm {
fn bookmarks(&self, cx: &App) -> Vec<String> {
self.store
.read(cx)
.bookmarks(self.session, BookmarkSide::from_remote(self.remote))
.bookmarks(self.host, BookmarkSide::from_remote(self.remote))
.to_vec()
}
/// The typed directory, resolved the way the pane resolves it; empty
@@ -349,7 +349,7 @@ impl ExplorerPanel {
let form = cx.new(|cx| OpenDirectoryForm {
remote,
explorer: self.id(),
session: self.session_id(),
host: self.host_id(),
dispatch: self.dispatch.clone(),
store: self.store.clone(),
input: input.clone(),
+1 -1
View File
@@ -3,7 +3,7 @@
//! stays busy until the result arrives, then re-reads its directory.
use super::{ExplorerPanel, NewEntryKind};
use crate::session::ConnectionState;
use crate::host::ConnectionState;
use crate::sftp::{
LocalDirectoryProvider, PermissionEdit, RemoteOperation, RemotePath, SftpCommand,
};
+1 -1
View File
@@ -2,7 +2,7 @@ use super::{ExplorerId, ExplorerPanel, TransferJob};
use crate::app::ExplorerDispatch as _;
use crate::{
app::{ExplorerAction, ExplorerCommand},
session::ConnectionState,
host::ConnectionState,
sftp::{TransferAnswer, TransferChoice, TransferDirection, TransferQuestionKind},
shared::commit_footer,
};
+1 -1
View File
@@ -15,7 +15,7 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::CatalogIcon;
use crate::session::ForwardKind;
use crate::host::ForwardKind;
/// One round of the dot: across the tunnel, on to the target, then a rest
/// before it sets out again. The rounds go on for as long as the picture is
+27 -34
View File
@@ -15,9 +15,8 @@ use super::{
diagram::{ForwardDiagram, explain, purpose, ssh_flag, typical_use},
forward_panel::kind_icon,
};
use crate::session::{
DEFAULT_BIND_HOST, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, SessionId,
SessionStore,
use crate::host::{
DEFAULT_BIND_HOST, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, HostId, HostStore,
};
use crate::shared::{commit_footer, form_error, parse_port};
@@ -42,7 +41,7 @@ const RADIO_DOT_COLUMN: f32 = 1.375;
/// The kind cards, the diagram and the sentence under it are all driven by
/// the same fields, so what the picture shows is what would be saved.
pub struct ForwardForm {
store: Entity<SessionStore>,
store: Entity<HostStore>,
editing: Option<ForwardId>,
/// Whether the rule being edited is running, in which case saving a
/// change to what it does restarts it.
@@ -52,9 +51,9 @@ pub struct ForwardForm {
/// the new entrance.
flow: u64,
name: Entity<InputState>,
session: Entity<SelectState<Vec<SharedString>>>,
/// Parallel to the session select's rows.
session_ids: Vec<SessionId>,
host: Entity<SelectState<Vec<SharedString>>>,
/// Parallel to the host select's rows.
host_ids: Vec<HostId>,
bind_host: Entity<InputState>,
bind_port: Entity<InputState>,
target_host: Entity<InputState>,
@@ -68,36 +67,31 @@ impl ForwardForm {
pub fn new(
editing: Option<ForwardId>,
editing_active: bool,
store: Entity<SessionStore>,
store: Entity<HostStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let (draft, session_ids, session_labels) = {
let (draft, host_ids, host_labels) = {
let read = store.read(cx);
let draft = editing
.and_then(|id| read.forward(id))
.map(|rule| rule.draft());
let sessions = read.sessions();
let hosts = read.hosts();
(
draft,
sessions
hosts.iter().map(|host| host.id).collect::<Vec<_>>(),
hosts
.iter()
.map(|session| session.id)
.collect::<Vec<_>>(),
sessions
.iter()
.map(|session| {
SharedString::from(format!("{}({})", session.name, session.address()))
})
.map(|host| SharedString::from(format!("{}({})", host.name, host.endpoint())))
.collect::<Vec<_>>(),
)
};
let kind = draft.as_ref().map(|draft| draft.kind).unwrap_or_default();
// A new rule names no host: which server a port is opened through is
// for the user to pick, not for a default to pick for them.
let session_ix = draft
let host_ix = draft
.as_ref()
.and_then(|draft| session_ids.iter().position(|id| *id == draft.session));
.and_then(|draft| host_ids.iter().position(|id| *id == draft.host));
let bind = draft.as_ref().map(|draft| draft.bind.clone());
let target = draft.as_ref().and_then(|draft| draft.target.clone());
let port_text = |port: Option<u16>| port.map(|port| port.to_string()).unwrap_or_default();
@@ -107,9 +101,8 @@ impl ForwardForm {
.placeholder("可选,留空显示转发摘要")
.default_value(draft.as_ref().map(|d| d.name.clone()).unwrap_or_default())
});
let session = cx.new(|cx| {
SelectState::new(session_labels, session_ix.map(IndexPath::new), window, cx)
.searchable(true)
let host = cx.new(|cx| {
SelectState::new(host_labels, host_ix.map(IndexPath::new), window, cx).searchable(true)
});
let bind_host = cx.new(|cx| {
InputState::new(window, cx)
@@ -162,8 +155,8 @@ impl ForwardForm {
kind,
flow: 0,
name,
session,
session_ids,
host,
host_ids,
bind_host,
bind_port,
target_host,
@@ -199,18 +192,18 @@ impl ForwardForm {
cx.notify();
}
fn selected_session(&self, cx: &App) -> Option<usize> {
self.session
fn selected_host(&self, cx: &App) -> Option<usize> {
self.host
.read(cx)
.selected_index(cx)
.map(|ix| ix.row)
.filter(|ix| *ix < self.session_ids.len())
.filter(|ix| *ix < self.host_ids.len())
}
/// What the form holds right now, not yet checked. A port that is not a
/// number reads as 0, which no rule may have.
fn draft(&self, cx: &App) -> Option<ForwardDraft> {
let session = *self.session_ids.get(self.selected_session(cx)?)?;
let host = *self.host_ids.get(self.selected_host(cx)?)?;
let endpoint = |host: &Entity<InputState>, port: &Entity<InputState>| {
ForwardEndpoint::new(
host.read(cx).value().trim().to_string(),
@@ -224,7 +217,7 @@ impl ForwardForm {
Some(
ForwardDraft::new(
self.kind,
session,
host,
endpoint(&self.bind_host, &self.bind_port),
target,
)
@@ -253,7 +246,7 @@ impl ForwardForm {
None => store.insert_forward(draft, cx).is_some(),
});
if !saved {
// The session, or the rule itself, was deleted meanwhile.
// The host, or the rule itself, was deleted meanwhile.
self.error = Some("所选主机已不存在,请重新选择".into());
cx.notify();
return false;
@@ -387,8 +380,8 @@ impl ForwardForm {
.required(true)
.col_span(4)
.child(
Select::new(&self.session)
.id("forward-session")
Select::new(&self.host)
.id("forward-host")
.placeholder("请选择主机")
.small(),
),
@@ -528,7 +521,7 @@ impl Render for ForwardForm {
pub fn open_forward_dialog(
editing: Option<ForwardId>,
editing_active: bool,
store: Entity<SessionStore>,
store: Entity<HostStore>,
window: &mut Window,
cx: &mut App,
) {
+17 -17
View File
@@ -17,19 +17,19 @@ use gpui_kit::*;
use super::{ForwardManager, ForwardStatus};
use crate::app::{
CatalogIcon, DeleteForward, EditForward, FORWARD_PANEL_CONTEXT, NewForward, NewSession,
CatalogIcon, DeleteForward, EditForward, FORWARD_PANEL_CONTEXT, NewForward, NewHost,
SelectNextForward, SelectPreviousForward, StartForward, StopForward, ToggleSelectedForward,
};
use crate::session::{ForwardId, ForwardKind, SessionStore, matches_forward_query};
use crate::host::{ForwardId, ForwardKind, HostStore, matches_forward_query};
/// The port-forwarding list the left dock shows in place of the sessions:
/// The port-forwarding list the left dock shows in place of the hosts:
/// every rule, whether it is running, and the switch that starts or stops it.
///
/// The rules live in the shared `SessionStore` and what each is doing in the
/// The rules live in the shared `HostStore` and what each is doing in the
/// `ForwardManager`; this panel observes both and owns only its selection
/// and search.
pub struct ForwardPanel {
store: Entity<SessionStore>,
store: Entity<HostStore>,
manager: Entity<ForwardManager>,
/// The workspace's focus handle: actions dispatched on it reach the
/// workspace handlers whatever is focused, which after a dialog closes
@@ -63,7 +63,7 @@ struct ForwardRow {
impl ForwardPanel {
pub fn new(
store: Entity<SessionStore>,
store: Entity<HostStore>,
manager: Entity<ForwardManager>,
target: FocusHandle,
window: &mut Window,
@@ -162,18 +162,18 @@ impl ForwardPanel {
.forwards()
.iter()
.filter_map(|rule| {
let session = store
.session(rule.session)
.map(|session| session.name.clone())
let host = store
.host(rule.host)
.map(|host| host.name.clone())
.unwrap_or_default();
matches_forward_query(rule, &session, &self.query).then(|| ForwardRow {
matches_forward_query(rule, &host, &self.query).then(|| ForwardRow {
id: rule.id,
kind: rule.kind,
title: rule.title(),
detail: if rule.name.is_empty() {
format!("{} · {session}", rule.kind.label())
format!("{} · {host}", rule.kind.label())
} else {
format!("{} · {} · {session}", rule.kind.label(), rule.summary())
format!("{} · {} · {host}", rule.kind.label(), rule.summary())
}
.into(),
status: manager.status(rule.id),
@@ -236,17 +236,17 @@ impl ForwardPanel {
let target = self.target.clone();
let (title, hint, button) = if !store.forwards().is_empty() {
("没有匹配的端口转发", None, None)
} else if store.sessions().is_empty() {
} else if store.hosts().is_empty() {
(
"还没有主机",
Some("端口转发经由一台主机的 SSH 连接,先新建主机。"),
Some(
Button::new("forward-empty-new-session")
Button::new("forward-empty-new-host")
.small()
.icon(IconName::Plus)
.label("新建主机…")
.on_click(move |_, window, cx| {
target.dispatch_action(&NewSession, window, cx)
target.dispatch_action(&NewHost, window, cx)
}),
),
)
@@ -313,7 +313,7 @@ impl ForwardPanel {
.confirmed(selected)
.aria_selected(selected)
.when(selected, |item| {
item.bg(crate::app::session_tree_selection_color(theme))
item.bg(crate::app::host_tree_selection_color(theme))
})
.child(
h_flex()
@@ -539,7 +539,7 @@ impl Render for ForwardPanel {
)
.child(
// The menu hangs off the list, not off the rows, like the
// session tree's: capture clears the hit before a
// host tree's: capture clears the hit before a
// right-clicked row writes itself back, and the menu is
// built after both.
div()
+28 -31
View File
@@ -8,7 +8,7 @@ use gpui_kit::{Context, Entity, EventEmitter, SharedString, Subscription};
use super::{ForwardCommand, ForwardEvent, SharedForwardTransportProvider};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptReply},
session::{ForwardId, SessionId, SessionStore, SessionStoreEvent},
host::{ForwardId, HostId, HostStore, HostStoreEvent},
};
/// How often the workers' events are collected. Worker threads never wake a
@@ -66,7 +66,7 @@ pub enum ForwardManagerEvent {
StatusChanged(ForwardId),
/// Logging in needs an answer. The number is the run the question
/// belongs to: an answer for an earlier run of the rule is stale.
PromptRequested(ForwardId, SessionId, u64, ConnectionPrompt),
PromptRequested(ForwardId, HostId, u64, ConnectionPrompt),
/// A forward ended on its own, which the user may not be looking at.
Failed(ForwardId, SharedString),
}
@@ -75,7 +75,7 @@ pub enum ForwardManagerEvent {
struct Link {
/// Tells this run's questions from those of an earlier one.
generation: u64,
session: SessionId,
host: HostId,
commands: Sender<ForwardCommand>,
events: Receiver<ForwardEvent>,
status: ForwardStatus,
@@ -85,10 +85,10 @@ struct Link {
stopping: Option<bool>,
}
/// Owns every running forward. The rules themselves live in the session
/// Owns every running forward. The rules themselves live in the host
/// store; this holds only what a run adds: a worker and its status.
pub struct ForwardManager {
store: Entity<SessionStore>,
store: Entity<HostStore>,
provider: SharedForwardTransportProvider,
links: HashMap<ForwardId, Link>,
/// Why a rule's last run ended, until it starts again or is deleted.
@@ -104,32 +104,29 @@ impl EventEmitter<ForwardManagerEvent> for ForwardManager {}
impl ForwardManager {
pub fn new(
store: Entity<SessionStore>,
store: Entity<HostStore>,
provider: SharedForwardTransportProvider,
cx: &mut Context<Self>,
) -> Self {
let subscriptions = vec![
cx.observe(&store, |this, _, cx| this.forget_removed(cx)),
cx.subscribe(
&store,
|this, _, event: &SessionStoreEvent, cx| match event {
// The rule listens or connects somewhere else now.
SessionStoreEvent::ForwardSettingsChanged(id) => this.restart(*id, cx),
// The server moved, or logs in differently.
SessionStoreEvent::ConnectionSettingsChanged(session) => {
let running: Vec<_> = this
.links
.iter()
.filter(|(_, link)| link.session == *session)
.map(|(id, _)| *id)
.collect();
for id in running {
this.restart(id, cx);
}
cx.subscribe(&store, |this, _, event: &HostStoreEvent, cx| match event {
// The rule listens or connects somewhere else now.
HostStoreEvent::ForwardSettingsChanged(id) => this.restart(*id, cx),
// The server moved, or logs in differently.
HostStoreEvent::ConnectionSettingsChanged(host) => {
let running: Vec<_> = this
.links
.iter()
.filter(|(_, link)| link.host == *host)
.map(|(id, _)| *id)
.collect();
for id in running {
this.restart(id, cx);
}
SessionStoreEvent::PersistFailed(_) => {}
},
),
}
HostStoreEvent::PersistFailed(_) => {}
}),
];
Self {
store,
@@ -210,10 +207,10 @@ impl ForwardManager {
let Some(rule) = store.forward(id) else {
return;
};
let Some(login) = store.login(rule.session) else {
let Some(login) = store.login(rule.host) else {
return;
};
let session_id = rule.session;
let host_id = rule.host;
let transport = self.provider.create(rule, &login);
let (commands, command_receiver) = async_channel::unbounded();
let (event_sender, events) = async_channel::unbounded();
@@ -232,7 +229,7 @@ impl ForwardManager {
id,
Link {
generation,
session: session_id,
host: host_id,
commands,
events,
status: ForwardStatus::Connecting,
@@ -289,7 +286,7 @@ impl ForwardManager {
}
}
/// Rules deleted from the store, on their own or with their session,
/// Rules deleted from the store, on their own or with their host,
/// stop running and leave nothing behind.
fn forget_removed(&mut self, cx: &mut Context<Self>) {
let store = self.store.read(cx);
@@ -380,9 +377,9 @@ impl ForwardManager {
let status = match event {
ForwardEvent::Connecting => ForwardStatus::Connecting,
ForwardEvent::Prompt(prompt) => {
let (session, generation) = (link.session, link.generation);
let (host, generation) = (link.host, link.generation);
cx.emit(ForwardManagerEvent::PromptRequested(
id, session, generation, prompt,
id, host, generation, prompt,
));
return;
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! 端口转发: SSH port forwards (local, remote and dynamic), each over a
//! connection of its own, and the list and dialog that manage them. The
//! rules themselves are stored with the sessions (`session::ForwardRule`).
//! rules themselves are stored with the hosts (`host::ForwardRule`).
mod diagram;
mod forward_dialog;
+16 -16
View File
@@ -21,11 +21,11 @@ use tokio::{
use super::*;
use crate::{
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret},
secrets::{InMemorySecretStore, SecretRef, SecretStore as _},
session::{
AuthKind, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, Session,
SessionDraft, SessionId, SessionLogin,
host::{
AuthKind, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, Host,
HostDraft, HostId, HostLogin,
},
secrets::{InMemorySecretStore, SecretRef, SecretStore as _},
ssh::{SshConnectionConfig, SshConnector, SshPrompts},
};
@@ -42,7 +42,7 @@ struct Policy {
connections: AtomicUsize,
ended: AtomicUsize,
/// The logged-in connections, so a test can hang up on them.
sessions: Mutex<Vec<server::Handle>>,
logged_in: Mutex<Vec<server::Handle>>,
}
struct TestServer {
@@ -91,7 +91,7 @@ impl server::Handler for Handler {
}
async fn auth_succeeded(&mut self, session: &mut server::Session) -> Result<(), Self::Error> {
self.policy.sessions.lock().unwrap().push(session.handle());
self.policy.logged_in.lock().unwrap().push(session.handle());
Ok(())
}
@@ -191,9 +191,9 @@ impl Drop for Running {
impl Running {
/// End every logged-in connection from the server's side.
async fn hang_up(&self) {
let sessions: Vec<_> = self.policy.sessions.lock().unwrap().drain(..).collect();
for session in sessions {
let _ = session
let logged_in: Vec<_> = self.policy.logged_in.lock().unwrap().drain(..).collect();
for handle in logged_in {
let _ = handle
.disconnect(russh::Disconnect::ByApplication, "bye".into(), "en".into())
.await;
}
@@ -292,10 +292,10 @@ impl Fixture {
.unwrap();
}
fn session(&self) -> Session {
Session::new(
SessionId(1),
SessionDraft::new(
fn host(&self) -> Host {
Host::new(
HostId(1),
HostDraft::new(
"fixture",
"127.0.0.1",
self.ssh_port,
@@ -323,12 +323,12 @@ impl Fixture {
ForwardId(1),
ForwardDraft::new(
kind,
SessionId(1),
HostId(1),
ForwardEndpoint::new("127.0.0.1", bind),
target.map(|port| ForwardEndpoint::new("127.0.0.1", port)),
),
);
let transport = provider.create(&rule, &SessionLogin::of(&self.session(), None));
let transport = provider.create(&rule, &HostLogin::of(&self.host(), None));
let (commands, command_receiver) = async_channel::unbounded();
let (event_sender, events) = async_channel::unbounded();
let thread = std::thread::spawn(move || transport.run(command_receiver, event_sender));
@@ -848,7 +848,7 @@ fn a_connection_that_forwards_nothing_refuses_channels_from_the_server() {
// A terminal's or an SFTP tab's kind of connection.
let (_shutdown, shutdown) = watch::channel(false);
let prompts = Arc::new(SshPrompts::new(Arc::new(|_| false), shutdown).non_interactive());
let config = SshConnectionConfig::from(&SessionLogin::of(&fixture.session(), None));
let config = SshConnectionConfig::from(&HostLogin::of(&fixture.host(), None));
let (handle, _) = fixture.connector.connect(&config, prompts).await.unwrap();
let remote = free_port();
handle
+3 -3
View File
@@ -7,7 +7,7 @@ use async_channel::{Receiver, Sender};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptReply},
session::{ForwardRule, SessionLogin},
host::{ForwardRule, HostLogin},
};
/// What the list tells a running forward.
@@ -56,9 +56,9 @@ pub trait ForwardTransport: Send + 'static {
}
pub trait ForwardTransportProvider: Send + Sync + 'static {
/// A transport for `rule` as it stands now, logging in to its session
/// A transport for `rule` as it stands now, logging in to its host
/// with `login`.
fn create(&self, rule: &ForwardRule, login: &SessionLogin) -> Box<dyn ForwardTransport>;
fn create(&self, rule: &ForwardRule, login: &HostLogin) -> Box<dyn ForwardTransport>;
}
pub type SharedForwardTransportProvider = Arc<dyn ForwardTransportProvider>;
+3 -3
View File
@@ -2,7 +2,7 @@
//!
//! One run is one thread with its own current-thread runtime, like an SFTP
//! worker: SSH handles are bound to the runtime that made them. The run owns
//! its own connection, apart from any terminal or SFTP tab of the session.
//! its own connection, apart from any terminal or SFTP tab of the host.
use std::{
future::Future,
@@ -26,7 +26,7 @@ use super::{
};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptKind},
session::{ForwardEndpoint, ForwardKind, ForwardRule, SessionLogin},
host::{ForwardEndpoint, ForwardKind, ForwardRule, HostLogin},
ssh::{
ForwardedTcpip, MissingCredential, SshConnectionConfig, SshConnector, SshHandle,
SshPrompts, describe_login_error, is_network_error,
@@ -78,7 +78,7 @@ impl SshForwardTransportProvider {
}
impl ForwardTransportProvider for SshForwardTransportProvider {
fn create(&self, rule: &ForwardRule, login: &SessionLogin) -> Box<dyn ForwardTransport> {
fn create(&self, rule: &ForwardRule, login: &HostLogin) -> Box<dyn ForwardTransport> {
Box::new(SshForwardTransport {
connector: self.connector.clone(),
config: SshConnectionConfig::from(login),
@@ -1,5 +1,5 @@
//! Saved logins that hosts share: a user name with a password, a private key
//! file or the SSH agent. They live with the sessions because hosts refer to
//! file or the SSH agent. They live with the hosts because hosts refer to
//! them and they are stored in the same database; the `credential` module
//! that lists and edits them depends on this one, never the reverse.
+1654
View File
File diff suppressed because it is too large. Load diff
+19 -19
View File
@@ -1,12 +1,12 @@
//! Port-forwarding rules. They live with the sessions because they are stored
//! in the same database and go with their session when it is deleted; the
//! Port-forwarding rules. They live with the hosts because they are stored
//! in the same database and go with their host when it is deleted; the
//! `forward` module that runs them depends on this one, never the reverse.
use std::fmt;
use gpui_kit::SharedString;
use super::SessionId;
use super::HostId;
/// Stable identity of a forwarding rule. Never reused within a process.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -150,8 +150,8 @@ pub struct ForwardDraft {
/// Optional. An empty name shows the rule's summary instead.
pub name: SharedString,
pub kind: ForwardKind,
/// The session whose server the forward goes through.
pub session: SessionId,
/// The host whose server the forward goes through.
pub host: HostId,
/// Where the forward listens: on this machine for local and dynamic
/// forwards, on the server for a remote one.
pub bind: ForwardEndpoint,
@@ -164,14 +164,14 @@ pub struct ForwardDraft {
impl ForwardDraft {
pub fn new(
kind: ForwardKind,
session: SessionId,
host: HostId,
bind: ForwardEndpoint,
target: Option<ForwardEndpoint>,
) -> Self {
Self {
name: SharedString::default(),
kind,
session,
host,
bind,
target,
auto_start: false,
@@ -227,7 +227,7 @@ pub struct ForwardRule {
pub id: ForwardId,
pub name: SharedString,
pub kind: ForwardKind,
pub session: SessionId,
pub host: HostId,
pub bind: ForwardEndpoint,
pub target: Option<ForwardEndpoint>,
pub auto_start: bool,
@@ -241,7 +241,7 @@ impl ForwardRule {
id,
name: draft.name,
kind: draft.kind,
session: draft.session,
host: draft.host,
bind: draft.bind,
target: draft.target,
auto_start: draft.auto_start,
@@ -254,7 +254,7 @@ impl ForwardRule {
ForwardDraft {
name: self.name.clone(),
kind: self.kind,
session: self.session,
host: self.host,
bind: self.bind.clone(),
target: self.target.clone(),
auto_start: self.auto_start,
@@ -290,15 +290,15 @@ impl ForwardRule {
/// opposed to its name or whether it starts with the application.
pub fn needs_restart(&self, draft: &ForwardDraft) -> bool {
self.kind != draft.kind
|| self.session != draft.session
|| self.host != draft.host
|| self.bind != draft.bind
|| self.target != draft.target
}
}
/// Whether a rule matches what was typed into the forward list's search box:
/// its name, its addresses, or the name of the session it goes through.
pub fn matches_forward_query(rule: &ForwardRule, session_name: &str, query: &str) -> bool {
/// its name, its addresses, or the name of the host it goes through.
pub fn matches_forward_query(rule: &ForwardRule, host_name: &str, query: &str) -> bool {
let query = query.trim().to_lowercase();
if query.is_empty() {
return true;
@@ -307,7 +307,7 @@ pub fn matches_forward_query(rule: &ForwardRule, session_name: &str, query: &str
rule.name.to_string(),
rule.summary(),
rule.kind.label().to_string(),
session_name.to_string(),
host_name.to_string(),
]
.iter()
.any(|text| text.to_lowercase().contains(&query))
@@ -320,7 +320,7 @@ mod tests {
fn local() -> ForwardDraft {
ForwardDraft::new(
ForwardKind::Local,
SessionId(1),
HostId(1),
ForwardEndpoint::new(DEFAULT_BIND_HOST, 8080),
Some(ForwardEndpoint::new("db.internal", 3306)),
)
@@ -413,16 +413,16 @@ mod tests {
let mut other_port = local();
other_port.bind.port = 8081;
assert!(rule.needs_restart(&other_port));
let mut other_session = local();
other_session.session = SessionId(2);
assert!(rule.needs_restart(&other_session));
let mut through_another = local();
through_another.host = HostId(2);
assert!(rule.needs_restart(&through_another));
let mut other_target = local();
other_target.target = Some(ForwardEndpoint::new("db.internal", 5432));
assert!(rule.needs_restart(&other_target));
}
#[test]
fn the_search_looks_at_the_name_the_addresses_and_the_session() {
fn the_search_looks_at_the_name_the_addresses_and_the_host() {
let rule = ForwardRule::new(ForwardId(1), local().with_name("数据库"));
assert!(matches_forward_query(&rule, "web-01", ""));
assert!(matches_forward_query(&rule, "web-01", "数据"));
@@ -8,7 +8,7 @@ use gpui_kit::component::{
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::{DeleteHandler, Dependents, GroupDraft, GroupId, SessionStore, group_options};
use super::{DeleteHandler, Dependents, GroupDraft, GroupId, HostStore, group_options};
use crate::shared::{commit_footer, confirm_delete, form_error};
/// The label of the row that stands for "no parent" / "no group".
@@ -17,7 +17,7 @@ pub const ROOT_LABEL: &str = "(顶层)";
/// The body of the new/rename group dialog. Owns the field states and
/// validates on commit; the store is only touched when validation passes.
pub struct GroupForm {
store: Entity<SessionStore>,
store: Entity<HostStore>,
editing: Option<GroupId>,
name: Entity<InputState>,
parent: Entity<SelectState<Vec<SharedString>>>,
@@ -30,7 +30,7 @@ impl GroupForm {
pub fn new(
editing: Option<GroupId>,
default_parent: Option<GroupId>,
store: Entity<SessionStore>,
store: Entity<HostStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -151,7 +151,7 @@ impl Render for GroupForm {
pub fn open_group_dialog(
editing: Option<GroupId>,
default_parent: Option<GroupId>,
store: Entity<SessionStore>,
store: Entity<HostStore>,
window: &mut Window,
cx: &mut App,
) {
@@ -187,12 +187,12 @@ pub fn open_group_dialog(
}
/// Ask before deleting a group. Deleting one takes its subgroups and every
/// session inside them, and those sessions' port forwards, so the counts go
/// host inside them, and those hosts' port forwards, so the counts go
/// in the description, with the hosts elsewhere that jump through them.
#[allow(clippy::too_many_arguments)]
pub fn confirm_delete_group(
name: &str,
sessions: usize,
hosts: usize,
subgroups: usize,
affected: (bool, usize),
dependents: Dependents,
@@ -205,7 +205,7 @@ pub fn confirm_delete_group(
jump_users,
} = dependents;
let (closes_tabs, uploads) = affected;
let mut description = describe_contents(sessions, subgroups, closes_tabs);
let mut description = describe_contents(hosts, subgroups, closes_tabs);
if uploads > 0 {
description = Some(
format!(
@@ -238,13 +238,13 @@ pub fn confirm_delete_group(
/// What the delete dialog says about everything that goes with the group.
/// `None` for an empty group with nothing open.
fn describe_contents(sessions: usize, subgroups: usize, closes_tabs: bool) -> Option<SharedString> {
let contents = match (sessions, subgroups) {
fn describe_contents(hosts: usize, subgroups: usize, closes_tabs: bool) -> Option<SharedString> {
let contents = match (hosts, subgroups) {
(0, 0) => None,
(0, subgroups) => Some(format!("将同时删除其中的 {subgroups} 个子分组。")),
(sessions, 0) => Some(format!("将同时删除其中的 {sessions} 台主机。")),
(sessions, subgroups) => Some(format!(
"将同时删除其中的 {sessions} 台主机和 {subgroups} 个子分组。"
(hosts, 0) => Some(format!("将同时删除其中的 {hosts} 台主机。")),
(hosts, subgroups) => Some(format!(
"将同时删除其中的 {hosts} 台主机和 {subgroups} 个子分组。"
)),
};
let tabs = closes_tabs.then_some("已打开的终端和 SFTP 标签会一并关闭。");
@@ -22,14 +22,13 @@ use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, Unknow
use super::secret_fields::SecretFields;
use super::{
AuthKind, Credential, CredentialId, CredentialKind, DEFAULT_USER, GroupId, ProxyKind,
ProxySettings, Route, Session, SessionDraft, SessionId, SessionLogin, SessionStore,
group_options,
AuthKind, Credential, CredentialId, CredentialKind, DEFAULT_USER, GroupId, Host, HostDraft,
HostId, HostLogin, HostStore, ProxyKind, ProxySettings, Route, group_options,
};
pub use crate::shared::DeleteHandler;
use crate::shared::{Segment, SegmentedControl, confirm_delete, form_error, parse_port};
/// The label of the row that puts a session at the root of the tree.
/// The label of the row that puts a host at the root of the tree.
pub const NO_GROUP_LABEL: &str = "(无分组)";
/// Where the dialog's top sits and how tall it may grow, as fractions of
@@ -119,24 +118,24 @@ const PROXY_NOTE: &str = "目标地址由代理服务器解析;代理不需要
/// A host the form offers as a jump host, as it was when the form opened.
#[derive(Clone)]
struct JumpHost {
id: SessionId,
id: HostId,
name: SharedString,
/// `host:port`.
address: SharedString,
}
impl JumpHost {
fn of(session: &Session) -> Self {
fn of(host: &Host) -> Self {
Self {
id: session.id,
name: session.name.clone(),
address: format!("{}:{}", session.host, session.port).into(),
id: host.id,
name: host.name.clone(),
address: format!("{}:{}", host.address, host.port).into(),
}
}
}
impl SearchableListItem for JumpHost {
type Value = SessionId;
type Value = HostId;
/// The name and the address, both of which the search looks in.
fn title(&self) -> SharedString {
@@ -156,20 +155,20 @@ impl SearchableListItem for JumpHost {
)
}
fn value(&self) -> &SessionId {
fn value(&self) -> &HostId {
&self.id
}
}
type JumpPicker = ComboboxState<SearchableVec<JumpHost>>;
/// The body of the new/edit session dialog. Owns the field states and
/// The body of the new/edit host dialog. Owns the field states and
/// validates on commit; the store is only touched when validation passes.
pub struct SessionForm {
store: Entity<SessionStore>,
editing: Option<SessionId>,
pub struct HostForm {
store: Entity<HostStore>,
editing: Option<HostId>,
name: Entity<InputState>,
host: Entity<InputState>,
address: Entity<InputState>,
port: Entity<InputState>,
source: AuthSource,
user: Entity<InputState>,
@@ -188,7 +187,7 @@ pub struct SessionForm {
group_ids: Vec<Option<GroupId>>,
route: RouteChoice,
/// The jump hosts, in order; `None` is one that has been deleted.
hops: Vec<Option<SessionId>>,
hops: Vec<Option<HostId>>,
/// Every other host, as it was when the form opened: what can be a
/// jump host, and what the hops are called.
jump_hosts: Vec<JumpHost>,
@@ -208,11 +207,11 @@ pub struct SessionForm {
_subscriptions: Vec<Subscription>,
}
impl SessionForm {
impl HostForm {
pub fn new(
editing: Option<SessionId>,
editing: Option<HostId>,
preselect_group: Option<GroupId>,
store: Entity<SessionStore>,
store: Entity<HostStore>,
tester: SharedConnectionTester,
window: &mut Window,
cx: &mut Context<Self>,
@@ -221,22 +220,22 @@ impl SessionForm {
let (draft, options, credentials, key_dir, editing_connected, jump_hosts) = {
let read = store.read(cx);
(
editing.and_then(|id| read.session(id)).map(Session::draft),
editing.and_then(|id| read.host(id)).map(Host::draft),
group_options(read.groups(), &[]),
read.credentials().to_vec(),
read.key_dir().map(Path::to_path_buf),
editing
.and_then(|id| read.session(id))
.is_some_and(|session| session.state != super::ConnectionState::Disconnected),
read.sessions()
.and_then(|id| read.host(id))
.is_some_and(|host| host.state != super::ConnectionState::Disconnected),
read.hosts()
.iter()
.filter(|session| Some(session.id) != editing)
.filter(|host| Some(host.id) != editing)
.map(JumpHost::of)
.collect::<Vec<_>>(),
)
};
// A session with no group sits at the root of the tree, which is where
// every session starts when the database is still empty.
// A host with no group sits at the root of the tree, which is where
// every host starts when the database is still empty.
let mut group_ids: Vec<Option<GroupId>> = vec![None];
let mut group_names: Vec<SharedString> = vec![NO_GROUP_LABEL.into()];
for (id, path) in options {
@@ -244,7 +243,7 @@ impl SessionForm {
group_names.push(path);
}
let draft = draft.unwrap_or_else(|| {
SessionDraft::new(
HostDraft::new(
"",
"",
22,
@@ -259,10 +258,10 @@ impl SessionForm {
.placeholder("例如 web-01")
.default_value(draft.name.clone())
});
let host = cx.new(|cx| {
let address = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("主机名或 IP 地址")
.default_value(draft.host.clone())
.default_value(draft.address.clone())
});
let port = cx.new(|cx| {
InputState::new(window, cx)
@@ -401,7 +400,7 @@ impl SessionForm {
store,
editing,
name,
host,
address,
port,
source: match (credential_ix, draft.auth) {
(Some(_), _) => AuthSource::Credential,
@@ -451,7 +450,7 @@ impl SessionForm {
/// Put `id` at the end of the jump hosts, and take it off the hosts that
/// can still be added.
fn add_hop(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
fn add_hop(&mut self, id: HostId, window: &mut Window, cx: &mut Context<Self>) {
if !self.hops.contains(&Some(id)) {
self.hops.push(Some(id));
self.error = None;
@@ -522,12 +521,12 @@ impl SessionForm {
/// The address and port, or why they will not do.
fn endpoint(&self, cx: &App) -> Result<(String, u16), &'static str> {
let host = self.host.read(cx).value().trim().to_string();
let address = self.address.read(cx).value().trim().to_string();
let port = parse_port(self.port.read(cx).value().trim());
match (host.is_empty(), port) {
match (address.is_empty(), port) {
(true, _) => Err("请输入地址"),
(_, None) => Err("端口必须是 1 到 65535 之间的数字"),
(_, Some(port)) => Ok((host, port)),
(_, Some(port)) => Ok((address, port)),
}
}
@@ -541,7 +540,7 @@ impl SessionForm {
None => {
let credential = self.selected_credential(cx).ok_or("请选择凭据")?;
LoginTest::saved(
SessionLogin::with_credential(host, port, credential).with_route(route_login),
HostLogin::with_credential(host, port, credential).with_route(route_login),
)
}
Some(auth) => {
@@ -550,7 +549,7 @@ impl SessionForm {
return Err("请输入用户名");
}
let mut request = LoginTest::typed(
SessionLogin::manual(host, port, user, auth).with_route(route_login),
HostLogin::manual(host, port, user, auth).with_route(route_login),
);
// Only what the chosen way uses, which is also what the form
// shows.
@@ -692,7 +691,7 @@ impl SessionForm {
let mut password_change = None;
let draft = match login {
CommittedLogin::Saved { credential, user } => {
SessionDraft::new(name, host, port, user, AuthKind::default(), group)
HostDraft::new(name, host, port, user, AuthKind::default(), group)
.with_credential(credential)
}
CommittedLogin::Own(auth) => {
@@ -702,7 +701,7 @@ impl SessionForm {
} else {
user
};
let draft = SessionDraft::new(name, host, port, user, auth, group);
let draft = HostDraft::new(name, host, port, user, auth, group);
if auth == AuthKind::Password
&& let Some(change) = self.fields.read(cx).password_change(cx)
{
@@ -735,7 +734,7 @@ impl SessionForm {
}
}
// `update` above may have dropped the entry for the endpoint the
// session just left; this writes the one it moved to.
// host just left; this writes the one it moved to.
for (secret, change) in password_change.into_iter().chain(proxy_change) {
store.save_secret(secret, change, cx);
}
@@ -756,14 +755,14 @@ impl SessionForm {
Field::new()
.label("用户名")
.col_span(4)
.child(Input::new(&self.user).id("session-user").small()),
.child(Input::new(&self.user).id("host-user").small()),
)
.when(auth == AuthKind::Password, |form| {
form.child(
Field::new()
.label("密码")
.col_span(4)
.child(self.fields.read(cx).password_input("session-password")),
.child(self.fields.read(cx).password_input("host-password")),
)
})
}
@@ -783,7 +782,7 @@ impl SessionForm {
.col_span(4)
.child(
Select::new(&self.credential)
.id("session-credential")
.id("host-credential")
.placeholder("请选择凭据")
.search_placeholder("搜索凭据")
.empty(|_, cx| {
@@ -799,7 +798,7 @@ impl SessionForm {
.when_some(summary, |field, summary: SharedString| {
field.description_fn(move |_, _| {
div()
.id("session-credential-summary")
.id("host-credential-summary")
.test_support()
.aria_label(summary.clone())
.child(summary.clone())
@@ -808,11 +807,11 @@ impl SessionForm {
}
}
impl SessionForm {
impl HostForm {
/// 「连接方式」: the choice, and below it what the choice needs.
fn route_field(&self, cx: &mut Context<Self>) -> Field {
let route = self.route;
let choice = SegmentedControl::new("session-route")
let choice = SegmentedControl::new("host-route")
.selected_index(RouteChoice::ALL.iter().position(|each| *each == route))
.on_change(cx.listener(|this, ix: &usize, _, cx| {
if let Some(route) = RouteChoice::ALL.get(*ix) {
@@ -849,7 +848,7 @@ impl SessionForm {
fn jump_box(&self, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let (muted, danger) = (theme.muted_foreground, theme.danger);
let name_of = |hop: &Option<SessionId>| {
let name_of = |hop: &Option<HostId>| {
hop.and_then(|id| self.jump_hosts.iter().find(|host| host.id == id))
};
@@ -892,7 +891,7 @@ impl SessionForm {
.join(" → ");
let stop_count = stops.len();
let chain = h_flex()
.id("session-route-chain")
.id("host-route-chain")
.test_support()
.aria_label(chain_label)
.flex_wrap()
@@ -1008,7 +1007,7 @@ impl SessionForm {
.gap_2()
.child(
div()
.id("session-route-note")
.id("host-route-note")
.test_support()
.aria_label(JUMP_NOTE)
.text_sm()
@@ -1030,47 +1029,40 @@ impl SessionForm {
Form::new()
.columns(4)
.child(
Field::new().label("代理类型").col_span(4).child(
Select::new(&self.proxy_kind)
.id("session-proxy-kind")
.small(),
),
Field::new()
.label("代理类型")
.col_span(4)
.child(Select::new(&self.proxy_kind).id("host-proxy-kind").small()),
)
.child(
Field::new()
.label("代理地址")
.required(true)
.col_span(3)
.child(
Input::new(&self.proxy_host)
.id("session-proxy-host")
.small(),
),
.child(Input::new(&self.proxy_host).id("host-proxy-host").small()),
)
.child(
Field::new().label("端口").required(true).child(
Input::new(&self.proxy_port)
.id("session-proxy-port")
.small(),
),
Field::new()
.label("端口")
.required(true)
.child(Input::new(&self.proxy_port).id("host-proxy-port").small()),
)
.child(
Field::new().label("用户名").col_span(2).child(
Input::new(&self.proxy_user)
.id("session-proxy-user")
.small(),
),
Field::new()
.label("用户名")
.col_span(2)
.child(Input::new(&self.proxy_user).id("host-proxy-user").small()),
)
.child(
Field::new()
.label("密码")
.col_span(2)
.child(password.password_input("session-proxy-password")),
.child(password.password_input("host-proxy-password")),
),
)
.child(
div()
.id("session-route-note")
.id("host-route-note")
.test_support()
.aria_label(PROXY_NOTE)
.text_sm()
@@ -1084,7 +1076,7 @@ impl SessionForm {
const DELETED_HOST: &str = "已删除的主机";
/// The hosts that can still be added as a jump host: those not in `hops`.
fn available_jump_hosts(hosts: &[JumpHost], hops: &[Option<SessionId>]) -> Vec<JumpHost> {
fn available_jump_hosts(hosts: &[JumpHost], hops: &[Option<HostId>]) -> Vec<JumpHost> {
hosts
.iter()
.filter(|host| !hops.contains(&Some(host.id)))
@@ -1092,7 +1084,7 @@ fn available_jump_hosts(hosts: &[JumpHost], hops: &[Option<SessionId>]) -> Vec<J
.collect()
}
impl Render for SessionForm {
impl Render for HostForm {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let source = self.source;
let keychain = self.fields.read(cx).keychain_available();
@@ -1110,26 +1102,26 @@ impl Render for SessionForm {
.label("名称")
.required(true)
.col_span(4)
.child(Input::new(&self.name).id("session-name").small()),
.child(Input::new(&self.name).id("host-name").small()),
)
.child(
Field::new()
.label("地址")
.required(true)
.col_span(3)
.child(Input::new(&self.host).id("session-host").small()),
.child(Input::new(&self.address).id("host-address").small()),
)
.child(
Field::new()
.label("端口")
.child(Input::new(&self.port).id("session-port").small()),
.child(Input::new(&self.port).id("host-port").small()),
)
.child(
Field::new()
.label("认证方式")
.col_span(4)
.child(
SegmentedControl::new("session-auth-source")
SegmentedControl::new("host-auth-source")
.selected_index(AuthSource::ALL.iter().position(|each| *each == source))
.on_change(cx.listener(|this, ix: &usize, _, cx| {
if let Some(source) = AuthSource::ALL.get(*ix) {
@@ -1141,7 +1133,7 @@ impl Render for SessionForm {
.when(source == AuthSource::NoPassword, |field| {
field.description_fn(|_, _| {
div()
.id("session-no-password-note")
.id("host-no-password-note")
.test_support()
.aria_label(NO_PASSWORD_NOTE)
.child(NO_PASSWORD_NOTE)
@@ -1166,7 +1158,7 @@ impl Render for SessionForm {
.child(
Field::new().label("备注").col_span(4).child(
div()
.id("session-notes")
.id("host-notes")
.test_support()
.w_full()
.child(Textarea::new(&self.notes).text_sm()),
@@ -1229,7 +1221,7 @@ fn credential_summary(credential: &Credential, key_dir: Option<&Path>) -> Shared
/// A trust question from the test's worker, with where to send the answer.
type TrustQuestion = (UnknownHostPrompt, std::sync::mpsc::Sender<bool>);
/// Put a first-seen host key to the user, above the session dialog. Closing
/// Put a first-seen host key to the user, above the host dialog. Closing
/// the dialog any other way than trusting counts as declining.
fn ask_to_trust(
prompt: UnknownHostPrompt,
@@ -1278,19 +1270,19 @@ fn connection_test_notification(result: Result<(), String>) -> Notification {
/// Whether this authentication kind can end up asking for a password.
/// `Auto` walks agent, then keys, then password, so it can.
/// Open the new-session (`editing == None`) or edit-session dialog.
/// `preselect_group` fills in the group field of a new session, so creating
/// Open the new-host (`editing == None`) or edit-host dialog.
/// `preselect_group` fills in the group field of a new host, so creating
/// one from a group's context menu lands it in that group. `tester` backs the
/// dialog's 「测试连接」 button.
pub fn open_session_dialog(
editing: Option<SessionId>,
pub fn open_host_dialog(
editing: Option<HostId>,
preselect_group: Option<GroupId>,
store: Entity<SessionStore>,
store: Entity<HostStore>,
tester: SharedConnectionTester,
window: &mut Window,
cx: &mut App,
) {
let form = cx.new(|cx| SessionForm::new(editing, preselect_group, store, tester, window, cx));
let form = cx.new(|cx| HostForm::new(editing, preselect_group, store, tester, window, cx));
let title: SharedString = if editing.is_some() {
"编辑主机"
} else {
@@ -1377,11 +1369,11 @@ pub struct Dependents {
pub jump_users: usize,
}
/// Ask before deleting a session. `on_delete` runs when the user confirms.
/// `affected` is whether tabs of the session are open and how many of them
/// Ask before deleting a host. `on_delete` runs when the user confirms.
/// `affected` is whether tabs of the host are open and how many of them
/// are transferring.
pub fn confirm_delete_session(
session: &Session,
pub fn confirm_delete_host(
host: &Host,
affected: (bool, usize),
dependents: Dependents,
on_delete: DeleteHandler,
@@ -1390,17 +1382,17 @@ pub fn confirm_delete_session(
) {
let (closes_tabs, uploads) = affected;
confirm_delete(
&session.name,
describe_session_delete(closes_tabs, uploads, dependents),
&host.name,
describe_host_delete(closes_tabs, uploads, dependents),
on_delete,
window,
cx,
);
}
/// What the delete dialog says goes with the session. `None` for a session
/// What the delete dialog says goes with the host. `None` for a host
/// with nothing open and nothing depending on it.
fn describe_session_delete(
fn describe_host_delete(
closes_tabs: bool,
uploads: usize,
dependents: Dependents,
@@ -1429,7 +1421,7 @@ fn describe_session_delete(
#[cfg(test)]
mod tests {
use super::{Dependents, describe_session_delete};
use super::{Dependents, describe_host_delete};
fn forwards(forwards: usize) -> Dependents {
Dependents {
@@ -1439,22 +1431,22 @@ mod tests {
}
#[test]
fn deleting_a_session_says_what_goes_with_it() {
assert_eq!(describe_session_delete(false, 0, forwards(0)), None);
fn deleting_a_host_says_what_goes_with_it() {
assert_eq!(describe_host_delete(false, 0, forwards(0)), None);
assert_eq!(
describe_session_delete(true, 0, forwards(0)).as_deref(),
describe_host_delete(true, 0, forwards(0)).as_deref(),
Some("会一并关闭该主机已打开的终端和 SFTP 标签。")
);
assert_eq!(
describe_session_delete(true, 2, forwards(0)).as_deref(),
describe_host_delete(true, 2, forwards(0)).as_deref(),
Some("会一并关闭该主机已打开的终端和 SFTP 标签。将停止 2 个传输批次并保留续传进度。")
);
assert_eq!(
describe_session_delete(false, 0, forwards(3)).as_deref(),
describe_host_delete(false, 0, forwards(3)).as_deref(),
Some("将同时删除经由该主机的 3 条端口转发。")
);
assert_eq!(
describe_session_delete(true, 0, forwards(1)).as_deref(),
describe_host_delete(true, 0, forwards(1)).as_deref(),
Some("会一并关闭该主机已打开的终端和 SFTP 标签。将同时删除经由该主机的 1 条端口转发。")
);
}
@@ -1466,7 +1458,7 @@ mod tests {
jump_users: 2,
};
assert_eq!(
describe_session_delete(false, 0, dependents).as_deref(),
describe_host_delete(false, 0, dependents).as_deref(),
Some(
"将同时删除经由该主机的 1 条端口转发。有 2 台主机把它用作跳板主机,删除后要重新选择跳板主机才能连接。"
)
@@ -26,53 +26,51 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{
CatalogIcon, CollapseAllGroups, ConnectGroup, ConnectSelected, ConnectSession, CopySessionId,
DeleteGroup, DeleteSession, DuplicateSession, EditSession, ExpandAllGroups, MoveSessionNode,
NewChildGroup, NewGroup, NewSession, NewSessionInGroup, OpenExplorer, RenameGroup,
SESSION_PANEL_CONTEXT,
CatalogIcon, CollapseAllGroups, ConnectGroup, ConnectHost, ConnectSelected, CopyHostId,
DeleteGroup, DeleteHost, DuplicateHost, EditHost, ExpandAllGroups, HOST_PANEL_CONTEXT,
MoveHostNode, NewChildGroup, NewGroup, NewHost, NewHostInGroup, OpenExplorer, RenameGroup,
};
use crate::shared::HostMark;
use super::{
GroupId, HostOs, NodeDrop, SessionId, SessionNode, SessionStore, matches_query,
session_tree_items,
GroupId, HostId, HostNode, HostOs, HostStore, NodeDrop, host_tree_items, matches_query,
};
/// The session list of the left dock: a searchable, grouped tree of sessions.
/// The host list of the left dock: a searchable, grouped tree of hosts.
/// The workspace's sidebar shows it, and borrows its title and toolbar for
/// the dock's title bar while it does.
///
/// Owns the tree and search state; the session data lives in the shared
/// `SessionStore`, which this panel observes.
pub struct SessionPanel {
store: Entity<SessionStore>,
/// Owns the tree and search state; the host data lives in the shared
/// `HostStore`, which this panel observes.
pub struct HostPanel {
store: Entity<HostStore>,
tree_state: Entity<TreeState>,
search: Entity<InputState>,
query: String,
expanded: HashSet<GroupId>,
/// What the store held the last time the tree was rebuilt. Comparing
/// against these is how a group or session created in a dialog gets
/// against these is how a group or host created in a dialog gets
/// revealed without the dialog having to report back to the panel.
known_groups: HashSet<GroupId>,
known_sessions: HashSet<SessionId>,
seen_hosts: HashSet<HostId>,
/// Which node the last right-click landed on, `None` for the blank space
/// below the rows. Shared with the row renderer and the context menu
/// builder, both of which run outside this entity.
right_clicked: Rc<Cell<Option<SessionNode>>>,
drop_target: Rc<Cell<Option<(SessionNode, NodeDrop)>>>,
/// Session rows' tooltips, which open beside the row rather than under
right_clicked: Rc<Cell<Option<HostNode>>>,
drop_target: Rc<Cell<Option<(HostNode, NodeDrop)>>>,
/// Host rows' tooltips, which open beside the row rather than under
/// the pointer so they never cover the rows below.
row_tooltip: Entity<TooltipOverlay>,
/// The row the tooltip was last opened for. See `render_row`.
row_tooltip_owner: Rc<Cell<Option<SessionId>>>,
row_tooltip_owner: Rc<Cell<Option<HostId>>>,
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
}
impl SessionPanel {
pub fn new(store: Entity<SessionStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let (expanded, known_groups, known_sessions, items) = {
impl HostPanel {
pub fn new(store: Entity<HostStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let (expanded, known_groups, seen_hosts, items) = {
let read = store.read(cx);
let expanded: HashSet<GroupId> = read
.groups()
@@ -80,11 +78,11 @@ impl SessionPanel {
.filter(|group| group.expanded)
.map(|group| group.id)
.collect();
let items = session_tree_items(read.groups(), read.sessions(), "", &expanded);
let items = host_tree_items(read.groups(), read.hosts(), "", &expanded);
(
expanded,
read.groups().iter().map(|group| group.id).collect(),
read.sessions().iter().map(|s| s.id).collect(),
read.hosts().iter().map(|s| s.id).collect(),
items,
)
};
@@ -117,7 +115,7 @@ impl SessionPanel {
TreeEvent::Expanded(id) => (id, true),
TreeEvent::Collapsed(id) => (id, false),
};
if let Some(SessionNode::Group(group)) = SessionNode::parse(id) {
if let Some(HostNode::Group(group)) = HostNode::parse(id) {
if expanded {
this.expanded.insert(group);
} else {
@@ -137,7 +135,7 @@ impl SessionPanel {
query: String::new(),
expanded,
known_groups,
known_sessions,
seen_hosts,
right_clicked: Rc::new(Cell::new(None)),
drop_target: Rc::new(Cell::new(None)),
row_tooltip: cx.new(|_| TooltipOverlay::new().render_with(animate_row_tooltip)),
@@ -148,7 +146,7 @@ impl SessionPanel {
}
/// Rebuild the tree, then put the cursor on whatever the store just
/// gained so a freshly created group or session is visible and selected.
/// gained so a freshly created group or host is visible and selected.
fn on_store_changed(&mut self, cx: &mut Context<Self>) {
let created = self.take_created_node(cx);
self.rebuild_tree(cx);
@@ -159,22 +157,22 @@ impl SessionPanel {
/// The node the store gained since the last rebuild, if any, with the
/// folders above it opened so it can be scrolled to.
fn take_created_node(&mut self, cx: &mut Context<Self>) -> Option<SessionNode> {
let (created, ancestors, groups, sessions) = {
fn take_created_node(&mut self, cx: &mut Context<Self>) -> Option<HostNode> {
let (created, ancestors, groups, hosts) = {
let store = self.store.read(cx);
let created = store
.groups()
.iter()
.rev()
.find(|group| !self.known_groups.contains(&group.id))
.map(|group| SessionNode::Group(group.id))
.map(|group| HostNode::Group(group.id))
.or_else(|| {
store
.sessions()
.hosts()
.iter()
.rev()
.find(|session| !self.known_sessions.contains(&session.id))
.map(|session| SessionNode::Session(session.id))
.find(|host| !self.seen_hosts.contains(&host.id))
.map(|host| HostNode::Host(host.id))
});
let ancestors = created
.map(|node| groups_above(store, node))
@@ -183,13 +181,13 @@ impl SessionPanel {
created,
ancestors,
store.groups().iter().map(|g| g.id).collect(),
store.sessions().iter().map(|s| s.id).collect(),
store.hosts().iter().map(|s| s.id).collect(),
)
};
self.known_groups = groups;
self.known_sessions = sessions;
self.seen_hosts = hosts;
self.expanded.extend(ancestors);
if let Some(SessionNode::Group(id)) = created
if let Some(HostNode::Group(id)) = created
&& self
.store
.read(cx)
@@ -205,12 +203,7 @@ impl SessionPanel {
fn rebuild_tree(&mut self, cx: &mut Context<Self>) {
let items = {
let store = self.store.read(cx);
session_tree_items(
store.groups(),
store.sessions(),
&self.query,
&self.expanded,
)
host_tree_items(store.groups(), store.hosts(), &self.query, &self.expanded)
};
let selected_id = self
.tree_state
@@ -227,7 +220,7 @@ impl SessionPanel {
/// Select (and reveal) a row, for example a fresh duplicate or a group
/// just created.
pub fn select_node(&mut self, node: SessionNode, cx: &mut Context<Self>) {
pub fn select_node(&mut self, node: HostNode, cx: &mut Context<Self>) {
self.rebuild_tree(cx);
let row_id = node.id();
self.tree_state.update(cx, |state, cx| {
@@ -238,7 +231,7 @@ impl SessionPanel {
}
/// Keep the moved row visible even when it was dropped into a closed group.
pub fn reveal_node(&mut self, node: SessionNode, cx: &mut Context<Self>) {
pub fn reveal_node(&mut self, node: HostNode, cx: &mut Context<Self>) {
let ancestors = groups_above(self.store.read(cx), node);
self.expanded.extend(ancestors);
self.select_node(node, cx);
@@ -265,23 +258,23 @@ impl SessionPanel {
}
/// The node currently selected in the tree.
pub fn selected_node(&self, cx: &App) -> Option<SessionNode> {
pub fn selected_node(&self, cx: &App) -> Option<HostNode> {
self.tree_state
.read(cx)
.selected_item()
.and_then(|item| SessionNode::parse(&item.id))
.and_then(|item| HostNode::parse(&item.id))
}
fn connect_first_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let first = self
.store
.read(cx)
.sessions()
.hosts()
.iter()
.find(|session| matches_query(session, &self.query))
.map(|session| session.id);
.find(|host| matches_query(host, &self.query))
.map(|host| host.id);
if let Some(id) = first {
window.dispatch_action(Box::new(ConnectSession(id)), cx);
window.dispatch_action(Box::new(ConnectHost(id)), cx);
}
}
@@ -292,10 +285,10 @@ impl SessionPanel {
cx: &mut Context<Self>,
) {
match self.selected_node(cx) {
Some(SessionNode::Session(id)) => {
window.dispatch_action(Box::new(ConnectSession(id)), cx);
Some(HostNode::Host(id)) => {
window.dispatch_action(Box::new(ConnectHost(id)), cx);
}
Some(SessionNode::Group(group)) => {
Some(HostNode::Group(group)) => {
if !self.query.trim().is_empty() {
return;
}
@@ -317,10 +310,10 @@ impl SessionPanel {
/// Every group a node sits in, at any depth: the folders that have to be
/// open for its row to show.
fn groups_above(store: &SessionStore, node: SessionNode) -> Vec<GroupId> {
fn groups_above(store: &HostStore, node: HostNode) -> Vec<GroupId> {
let parent = match node {
SessionNode::Group(id) => store.group(id).and_then(|group| group.parent),
SessionNode::Session(id) => store.session(id).and_then(|session| session.group),
HostNode::Group(id) => store.group(id).and_then(|group| group.parent),
HostNode::Host(id) => store.host(id).and_then(|host| host.group),
};
parent
.map(|id| {
@@ -331,17 +324,17 @@ fn groups_above(store: &SessionStore, node: SessionNode) -> Vec<GroupId> {
.unwrap_or_default()
}
impl EventEmitter<PanelEvent> for SessionPanel {}
impl EventEmitter<PanelEvent> for HostPanel {}
impl Focusable for SessionPanel {
impl Focusable for HostPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl BasePanel for SessionPanel {
impl BasePanel for HostPanel {
fn panel_name(&self) -> &'static str {
"SessionPanel"
"HostPanel"
}
fn closable(&self, _: &App) -> bool {
@@ -353,7 +346,7 @@ impl BasePanel for SessionPanel {
}
}
impl Panel for SessionPanel {
impl Panel for HostPanel {
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_1()
@@ -364,10 +357,10 @@ impl Panel for SessionPanel {
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
// A new host first: it is what the list is mostly added to.
Some(vec![
Button::new("new-session-panel")
Button::new("new-host-panel")
.icon(IconName::Plus)
.tooltip("新建主机…")
.on_click(|_, window, cx| window.dispatch_action(Box::new(NewSession), cx)),
.on_click(|_, window, cx| window.dispatch_action(Box::new(NewHost), cx)),
Button::new("new-group")
.icon(Icon::new(CatalogIcon::FolderPlus))
.tooltip("新建分组…")
@@ -384,24 +377,24 @@ impl Panel for SessionPanel {
}
}
impl Render for SessionPanel {
impl Render for HostPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Plain snapshots for the row renderer: render callbacks must not
// read entities.
let host_os: Rc<HashMap<SessionId, HostOs>> = Rc::new(
let host_os: Rc<HashMap<HostId, HostOs>> = Rc::new(
self.store
.read(cx)
.sessions()
.hosts()
.iter()
.filter_map(|session| session.os.map(|os| (session.id, os)))
.filter_map(|host| host.os.map(|os| (host.id, os)))
.collect(),
);
let addresses: Rc<HashMap<SessionId, SharedString>> = Rc::new(
let addresses: Rc<HashMap<HostId, SharedString>> = Rc::new(
self.store
.read(cx)
.sessions()
.hosts()
.iter()
.map(|session| (session.id, session.address().into()))
.map(|host| (host.id, host.endpoint().into()))
.collect(),
);
let group_parents: Rc<HashMap<GroupId, Option<GroupId>>> = Rc::new(
@@ -413,7 +406,7 @@ impl Render for SessionPanel {
.collect(),
);
let group_counts = Rc::new(group_host_counts(
self.store.read(cx).sessions(),
self.store.read(cx).hosts(),
&group_parents,
));
let clicked_row = self.right_clicked.clone();
@@ -435,8 +428,8 @@ impl Render for SessionPanel {
let tree_scroll = self.tree_state.read(cx).scroll_handle().clone();
v_flex()
.id("session-panel")
.key_context(SESSION_PANEL_CONTEXT)
.id("host-panel")
.key_context(HOST_PANEL_CONTEXT)
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_connect_selected))
.size_full()
@@ -445,7 +438,7 @@ impl Render for SessionPanel {
.child(
div().p_2().child(
Input::new(&self.search)
.id("session-search")
.id("host-search")
.small()
.cleanable(true)
.prefix(Icon::new(IconName::Search).small()),
@@ -453,7 +446,7 @@ impl Render for SessionPanel {
)
.child(
div()
.id("session-tree")
.id("host-tree")
.test_support()
.flex_1()
.min_h_0()
@@ -464,7 +457,7 @@ impl Render for SessionPanel {
.child(
BaseTree::new(&self.tree_state)
.item(move |_, entry, state, _, cx| {
let node = SessionNode::parse(&entry.item().id);
let node = HostNode::parse(&entry.item().id);
let selected = match row_interactions.right_clicked.get() {
Some(right_clicked) => node == Some(right_clicked),
None => state.is_selected(),
@@ -502,14 +495,14 @@ impl Render for SessionPanel {
}
})
.when(can_reorder, |view| {
view.on_drag_move(move |_: &DragMoveEvent<DraggedSessionNode>, _, _| {
view.on_drag_move(move |_: &DragMoveEvent<DraggedHostNode>, _, _| {
drop_target_for_move.set(None);
})
.on_drop(
move |drag: &DraggedSessionNode, window, cx| {
move |drag: &DraggedHostNode, window, cx| {
if drop_target_for_root.get().is_none() {
window.dispatch_action(
Box::new(MoveSessionNode {
Box::new(MoveHostNode {
source: drag.node,
destination: NodeDrop::Root,
}),
@@ -526,8 +519,8 @@ impl Render for SessionPanel {
}
}
/// A row that is not a session still has to reserve the badge's width, or the
/// labels of groups and sessions would not line up.
/// A row that is not a host still has to reserve the badge's width, or the
/// labels of groups and hosts would not line up.
fn plain_mark(icon: Icon, cx: &App) -> AnyElement {
h_flex()
.flex_shrink_0()
@@ -540,27 +533,27 @@ fn plain_mark(icon: Icon, cx: &App) -> AnyElement {
struct RowInteractions {
group_parents: Rc<HashMap<GroupId, Option<GroupId>>>,
right_clicked: Rc<Cell<Option<SessionNode>>>,
drop_target: Rc<Cell<Option<(SessionNode, NodeDrop)>>>,
right_clicked: Rc<Cell<Option<HostNode>>>,
drop_target: Rc<Cell<Option<(HostNode, NodeDrop)>>>,
can_reorder: bool,
/// `user@host:port` of every session, for the row tooltips.
addresses: Rc<HashMap<SessionId, SharedString>>,
/// `user@host:port` of every host, for the row tooltips.
addresses: Rc<HashMap<HostId, SharedString>>,
tooltip: Entity<TooltipOverlay>,
tooltip_owner: Rc<Cell<Option<SessionId>>>,
tooltip_owner: Rc<Cell<Option<HostId>>>,
}
/// A session row's tooltip: where the session logs in, as `user@host:port`.
/// A host row's tooltip: where the host logs in, as `user@host:port`.
///
/// Drawn in the theme's inverse, dark on the light theme and light on the
/// dark one, so it stands out against the sidebar and the terminal in both.
/// Only this tooltip: gpui-kit's others share the popover colour with menus.
fn session_tooltip(address: SharedString) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static {
fn host_tooltip(address: SharedString) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static {
move |window, cx| {
let address = address.clone();
let (background, foreground) = (cx.theme().foreground, cx.theme().background);
Tooltip::element(move |_, _| {
div()
.id("session-tooltip")
.id("host-tooltip")
.test_support()
.aria_label(address.clone())
.child(address.clone())
@@ -588,7 +581,7 @@ fn animate_row_tooltip(
.fade(0., 1.)
.apply(
tooltip,
ElementId::NamedInteger("session-tooltip-enter".into(), epoch as u64),
ElementId::NamedInteger("host-tooltip-enter".into(), epoch as u64),
)
.into_any_element(),
TooltipTransition::Switch {
@@ -600,7 +593,7 @@ fn animate_row_tooltip(
.slide_y(previous.center().y - current.center().y, px(0.))
.apply(
tooltip,
ElementId::NamedInteger("session-tooltip-move".into(), epoch as u64),
ElementId::NamedInteger("host-tooltip-move".into(), epoch as u64),
)
.into_any_element(),
}
@@ -608,12 +601,12 @@ fn animate_row_tooltip(
/// Count hosts in each group, including hosts in nested child groups.
fn group_host_counts(
sessions: &[super::Session],
hosts: &[super::Host],
parents: &HashMap<GroupId, Option<GroupId>>,
) -> HashMap<GroupId, usize> {
let mut counts = HashMap::new();
for session in sessions {
let mut group = session.group;
for host in hosts {
let mut group = host.group;
for _ in 0..parents.len() {
let Some(id) = group else { break };
*counts.entry(id).or_insert(0) += 1;
@@ -627,7 +620,7 @@ fn render_row(
entry: &TreeEntry,
selected: bool,
group_counts: &HashMap<GroupId, usize>,
host_os: &HashMap<SessionId, HostOs>,
host_os: &HashMap<HostId, HostOs>,
interactions: &RowInteractions,
cx: &mut App,
) -> ListItem {
@@ -641,9 +634,9 @@ fn render_row(
tooltip_owner,
} = interactions;
let item = entry.item();
let node = SessionNode::parse(&item.id);
let node = HostNode::parse(&item.id);
let (mark, row_id): (AnyElement, ElementId) = match node {
Some(SessionNode::Group(id)) => (
Some(HostNode::Group(id)) => (
plain_mark(
Icon::new(if entry.is_expanded() {
IconName::FolderOpen
@@ -654,23 +647,23 @@ fn render_row(
),
("group-row", id.0).into(),
),
Some(SessionNode::Session(id)) => (
Some(HostNode::Host(id)) => (
HostMark::new(
("session-os", id.0),
("host-os", id.0),
item.label.clone(),
host_os.get(&id).copied(),
)
.small()
.without_tooltip()
.into_any_element(),
("session-row", id.0).into(),
("host-row", id.0).into(),
),
None => (
plain_mark(Icon::new(IconName::File), cx),
item.id.clone().into(),
),
};
let session_id = node.and_then(SessionNode::session_id);
let host_id = node.and_then(HostNode::host_id);
ListItem::new(row_id)
.w_full()
@@ -679,11 +672,11 @@ fn render_row(
// Make selection stronger than hover against the sidebar background.
.confirmed(selected)
.when(selected, |row| {
row.bg(crate::app::session_tree_selection_color(cx.theme()))
row.bg(crate::app::host_tree_selection_color(cx.theme()))
})
.pl(rems(0.75 + entry.depth() as f32))
.child(h_flex().gap_2().child(mark).child(item.label.clone()))
.when_some(node.and_then(SessionNode::group_id), |row, id| {
.when_some(node.and_then(HostNode::group_id), |row, id| {
let count = group_counts.get(&id).copied().unwrap_or(0);
row.suffix(move |_, cx| {
div()
@@ -696,15 +689,15 @@ fn render_row(
.child(count.to_string())
})
})
.when_some(session_id, |row, id| {
.when_some(host_id, |row, id| {
row.on_click(move |event: &ClickEvent, window, cx| {
if event.click_count() == 2 {
window.dispatch_action(Box::new(ConnectSession(id)), cx);
window.dispatch_action(Box::new(ConnectHost(id)), cx);
}
})
})
.when_some(session_id, |row, id| {
let content = Rc::new(session_tooltip(
.when_some(host_id, |row, id| {
let content = Rc::new(host_tooltip(
addresses.get(&id).cloned().unwrap_or_default(),
));
let bounds = Rc::new(Cell::new(Bounds::default()));
@@ -761,17 +754,17 @@ fn render_row(
let drop_target_for_drop = drop_target.clone();
let parents_for_move = group_parents.clone();
let label = item.label.clone();
row.on_drag(DraggedSessionNode { node, label }, |drag, _, _, cx| {
row.on_drag(DraggedHostNode { node, label }, |drag, _, _, cx| {
cx.new(|_| drag.clone())
})
.on_drag_move(move |event: &DragMoveEvent<DraggedSessionNode>, _, cx| {
.on_drag_move(move |event: &DragMoveEvent<DraggedHostNode>, _, cx| {
if event.bounds.contains(&event.event.position) {
let source = event.drag(cx).node;
let relative = (event.event.position.y - event.bounds.top())
/ event.bounds.size.height;
let destination = match (source, node) {
(SessionNode::Session(_), SessionNode::Group(id)) => NodeDrop::Into(id),
(_, SessionNode::Group(id)) if relative > 0.25 && relative < 0.75 => {
(HostNode::Host(_), HostNode::Group(id)) => NodeDrop::Into(id),
(_, HostNode::Group(id)) if relative > 0.25 && relative < 0.75 => {
NodeDrop::Into(id)
}
_ if relative < 0.5 => NodeDrop::Before(node),
@@ -782,7 +775,7 @@ fn render_row(
}
}
})
.drag_over::<DraggedSessionNode>(move |style, drag, _, cx| {
.drag_over::<DraggedHostNode>(move |style, drag, _, cx| {
if drag.node == node {
return style;
}
@@ -800,13 +793,13 @@ fn render_row(
_ => style,
}
})
.on_drop(move |drag: &DraggedSessionNode, window, cx| {
.on_drop(move |drag: &DraggedHostNode, window, cx| {
if let Some((target, destination)) = drop_target_for_drop.get()
&& target == node
&& drag.node != node
{
window.dispatch_action(
Box::new(MoveSessionNode {
Box::new(MoveHostNode {
source: drag.node,
destination,
}),
@@ -821,12 +814,12 @@ fn render_row(
}
#[derive(Clone)]
struct DraggedSessionNode {
node: SessionNode,
struct DraggedHostNode {
node: HostNode,
label: SharedString,
}
impl Render for DraggedSessionNode {
impl Render for DraggedHostNode {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.px_2()
@@ -842,7 +835,7 @@ impl Render for DraggedSessionNode {
/// Use the render snapshot to keep impossible group drops from looking active.
fn valid_drop(
source: SessionNode,
source: HostNode,
destination: NodeDrop,
parents: &HashMap<GroupId, Option<GroupId>>,
) -> bool {
@@ -854,17 +847,17 @@ fn valid_drop(
return false;
}
match source {
SessionNode::Session(_) => match destination {
NodeDrop::Before(SessionNode::Session(_))
| NodeDrop::After(SessionNode::Session(_))
HostNode::Host(_) => match destination {
NodeDrop::Before(HostNode::Host(_))
| NodeDrop::After(HostNode::Host(_))
| NodeDrop::Root => true,
NodeDrop::Into(group) => parents.contains_key(&group),
_ => false,
},
SessionNode::Group(id) => {
HostNode::Group(id) => {
let mut parent = match destination {
NodeDrop::Before(SessionNode::Group(target))
| NodeDrop::After(SessionNode::Group(target)) => match parents.get(&target) {
NodeDrop::Before(HostNode::Group(target))
| NodeDrop::After(HostNode::Group(target)) => match parents.get(&target) {
Some(parent) => *parent,
None => return false,
},
@@ -889,13 +882,13 @@ fn valid_drop(
}
}
/// The menu of one session, wherever it is listed: the session tree and the
/// start page's recent sessions both build it here, so they cannot drift.
pub fn session_menu(menu: PopupMenu, id: SessionId) -> PopupMenu {
/// The menu of one host, wherever it is listed: the host tree and the
/// start page's recent hosts both build it here, so they cannot drift.
pub fn host_menu(menu: PopupMenu, id: HostId) -> PopupMenu {
menu.menu_with_icon(
"连接",
Icon::new(CatalogIcon::Plug),
Box::new(ConnectSession(id)),
Box::new(ConnectHost(id)),
)
.menu_with_icon(
"打开 SFTP",
@@ -906,31 +899,31 @@ pub fn session_menu(menu: PopupMenu, id: SessionId) -> PopupMenu {
.menu_with_icon(
"编辑主机…",
Icon::new(CatalogIcon::Pencil),
Box::new(EditSession(id)),
Box::new(EditHost(id)),
)
.menu_with_icon(
"复制",
Icon::new(IconName::Copy),
Box::new(DuplicateSession(id)),
Box::new(DuplicateHost(id)),
)
.separator()
.menu_with_icon(
"复制 ID",
Icon::new(CatalogIcon::ClipboardCopy),
Box::new(CopySessionId(id)),
Box::new(CopyHostId(id)),
)
.separator()
.menu_with_icon(
"删除",
Icon::new(CatalogIcon::Trash),
Box::new(DeleteSession(id)),
Box::new(DeleteHost(id)),
)
}
fn build_context_menu(node: Option<SessionNode>, menu: PopupMenu) -> PopupMenu {
fn build_context_menu(node: Option<HostNode>, menu: PopupMenu) -> PopupMenu {
match node {
Some(SessionNode::Session(id)) => session_menu(menu, id),
Some(SessionNode::Group(id)) => menu
Some(HostNode::Host(id)) => host_menu(menu, id),
Some(HostNode::Group(id)) => menu
.menu_with_icon(
"连接组内主机",
Icon::new(CatalogIcon::Plug),
@@ -940,7 +933,7 @@ fn build_context_menu(node: Option<SessionNode>, menu: PopupMenu) -> PopupMenu {
.menu_with_icon(
"新建主机…",
Icon::new(IconName::Plus),
Box::new(NewSessionInGroup(id)),
Box::new(NewHostInGroup(id)),
)
.menu_with_icon(
"新建子分组…",
@@ -971,7 +964,7 @@ fn build_context_menu(node: Option<SessionNode>, menu: PopupMenu) -> PopupMenu {
Box::new(DeleteGroup(id)),
),
None => menu
.menu_with_icon("新建主机…", Icon::new(IconName::Plus), Box::new(NewSession))
.menu_with_icon("新建主机…", Icon::new(IconName::Plus), Box::new(NewHost))
.menu_with_icon(
"新建分组…",
Icon::new(CatalogIcon::FolderPlus),
+36 -38
View File
@@ -7,7 +7,7 @@ use std::path::PathBuf;
use crate::secrets::SecretRef;
use super::{AuthKind, Credential, CredentialKind, ProxyKind, ProxySettings, Session};
use super::{AuthKind, Credential, CredentialKind, Host, ProxyKind, ProxySettings};
/// Which authentication methods a login tries. Each one starts by asking
/// the server to let the user in with nothing at all, which a server without
@@ -30,7 +30,7 @@ pub enum LoginMethod {
/// nothing that only changes what the user sees, such as a name.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SessionLogin {
pub struct HostLogin {
pub host: String,
pub port: u16,
pub user: String,
@@ -74,7 +74,7 @@ pub enum JumpLogin {
/// What the user calls it, for the questions and errors of its hop.
name: String,
/// How it logs in, directly from the hop before it.
login: Box<SessionLogin>,
login: Box<HostLogin>,
},
/// A jump host that has been deleted: the route cannot be taken.
Deleted,
@@ -127,7 +127,7 @@ impl From<&ProxySettings> for ProxyLogin {
}
}
impl SessionLogin {
impl HostLogin {
/// A login of the host's own rather than taken from a credential.
pub fn manual(
host: impl Into<String>,
@@ -186,20 +186,18 @@ impl SessionLogin {
self
}
/// How `session` logs in, given the credential it uses, as if it were
/// How `host` logs in, given the credential it uses, as if it were
/// reached directly: its route takes the store to look up. `credential`
/// is ignored unless it is the one the session names, so a session whose
/// is ignored unless it is the one the host names, so a host whose
/// credential has gone logs in with what the form holds.
pub fn of(session: &Session, credential: Option<&Credential>) -> Self {
match credential.filter(|credential| session.credential == Some(credential.id)) {
Some(credential) => {
Self::with_credential(session.host.as_ref(), session.port, credential)
}
pub fn of(host: &Host, credential: Option<&Credential>) -> Self {
match credential.filter(|credential| host.credential == Some(credential.id)) {
Some(credential) => Self::with_credential(host.address.as_ref(), host.port, credential),
None => Self::manual(
session.host.as_ref(),
session.port,
session.user.as_ref(),
session.auth,
host.address.as_ref(),
host.port,
host.user.as_ref(),
host.auth,
),
}
}
@@ -212,13 +210,13 @@ impl SessionLogin {
#[cfg(test)]
mod tests {
use super::super::{CredentialDraft, CredentialId, SessionDraft, SessionId};
use super::super::{CredentialDraft, CredentialId, HostDraft, HostId};
use super::*;
fn session(auth: AuthKind) -> Session {
Session::new(
SessionId(1),
SessionDraft::new("web", "10.0.0.1", 22, "root", auth, None),
fn host(auth: AuthKind) -> Host {
Host::new(
HostId(1),
HostDraft::new("web", "10.0.0.1", 22, "root", auth, None),
)
}
@@ -231,16 +229,16 @@ mod tests {
Credential::new(CredentialId(7), draft)
}
fn using(kind: CredentialKind) -> (Session, Credential) {
fn using(kind: CredentialKind) -> (Host, Credential) {
let credential = credential(kind);
let mut session = session(AuthKind::Password);
session.credential = Some(credential.id);
(session, credential)
let mut host = host(AuthKind::Password);
host.credential = Some(credential.id);
(host, credential)
}
#[test]
fn a_manual_host_logs_in_with_its_endpoint_password() {
let login = SessionLogin::of(&session(AuthKind::Password), None);
let login = HostLogin::of(&host(AuthKind::Password), None);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.user, "root");
assert_eq!(login.password, SecretRef::password("root", "10.0.0.1", 22));
@@ -249,7 +247,7 @@ mod tests {
#[test]
fn a_host_without_a_password_keeps_its_endpoint_for_a_partial_success() {
let login = SessionLogin::of(&session(AuthKind::NoPassword), None);
let login = HostLogin::of(&host(AuthKind::NoPassword), None);
assert_eq!(login.method, LoginMethod::NoPassword);
assert_eq!(login.key_path, None);
assert_eq!(login.password, SecretRef::password("root", "10.0.0.1", 22));
@@ -257,8 +255,8 @@ mod tests {
#[test]
fn a_password_credential_logs_in_with_its_own_keychain_entry() {
let (session, credential) = using(CredentialKind::Password);
let login = SessionLogin::of(&session, Some(&credential));
let (host, credential) = using(CredentialKind::Password);
let login = HostLogin::of(&host, Some(&credential));
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.user, "deploy");
assert_eq!(login.password, credential.password_secret());
@@ -266,14 +264,14 @@ mod tests {
#[test]
fn key_and_agent_credentials_keep_the_endpoint_for_a_partial_success() {
let (session, credential) = using(CredentialKind::Key);
let key = SessionLogin::of(&session, Some(&credential));
let (host, credential) = using(CredentialKind::Key);
let key = HostLogin::of(&host, Some(&credential));
assert_eq!(key.method, LoginMethod::Key);
assert_eq!(key.key_path, Some(PathBuf::from("/tmp/id_deploy")));
assert_eq!(key.password, SecretRef::password("deploy", "10.0.0.1", 22));
let (session, credential) = using(CredentialKind::Agent);
let agent = SessionLogin::of(&session, Some(&credential));
let (host, credential) = using(CredentialKind::Agent);
let agent = HostLogin::of(&host, Some(&credential));
assert_eq!(agent.method, LoginMethod::Agent);
assert_eq!(agent.key_path, None);
assert_eq!(
@@ -285,7 +283,7 @@ mod tests {
#[test]
fn a_credential_the_host_does_not_name_is_not_used() {
let credential = credential(CredentialKind::Password);
let login = SessionLogin::of(&session(AuthKind::NoPassword), Some(&credential));
let login = HostLogin::of(&host(AuthKind::NoPassword), Some(&credential));
assert_eq!(login.method, LoginMethod::NoPassword);
assert_eq!(login.user, "root");
}
@@ -294,7 +292,7 @@ mod tests {
fn a_jump_hosts_name_is_not_part_of_the_login() {
let hop = |name: &str, host: &str| JumpLogin::Host {
name: name.into(),
login: Box::new(SessionLogin::manual(host, 22, "root", AuthKind::Password)),
login: Box::new(HostLogin::manual(host, 22, "root", AuthKind::Password)),
};
assert_eq!(hop("阿里云99", "10.0.0.1"), hop("跳板", "10.0.0.1"));
assert_ne!(hop("阿里云99", "10.0.0.1"), hop("阿里云99", "10.0.0.2"));
@@ -324,11 +322,11 @@ mod tests {
#[test]
fn renaming_a_credential_does_not_change_the_login() {
let (session, mut credential) = using(CredentialKind::Password);
let before = SessionLogin::of(&session, Some(&credential));
let (host, mut credential) = using(CredentialKind::Password);
let before = HostLogin::of(&host, Some(&credential));
credential.name = "别的名字".into();
assert_eq!(SessionLogin::of(&session, Some(&credential)), before);
assert_eq!(HostLogin::of(&host, Some(&credential)), before);
credential.user = "admin".into();
assert_ne!(SessionLogin::of(&session, Some(&credential)), before);
assert_ne!(HostLogin::of(&host, Some(&credential)), before);
}
}
+28
View File
@@ -0,0 +1,28 @@
//! 主机管理: the saved hosts, which the code calls hosts. The host
//! model, its store, and the host panel/dialog.
mod credential;
mod database;
mod forward;
mod group_dialog;
mod host_dialog;
mod host_panel;
mod login;
mod model;
mod outline;
mod private_key;
mod secret_fields;
mod store;
pub use credential::*;
pub use database::{HostDatabase, StoredData};
pub use forward::*;
pub use group_dialog::{GroupForm, confirm_delete_group, open_group_dialog};
pub use host_dialog::{DeleteHandler, Dependents, HostForm, confirm_delete_host, open_host_dialog};
pub use host_panel::{HostPanel, host_menu};
pub use login::{HostLogin, JumpLogin, LoginMethod, LoginRoute, ProxyLogin};
pub use model::*;
pub use outline::{HostNode, NodeDrop, group_options, host_tree_items, matches_query};
pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key};
pub use secret_fields::SecretFields;
pub use store::{HostStore, HostStoreEvent};
+65 -67
View File
@@ -5,16 +5,16 @@ use crate::secrets::SecretRef;
use super::CredentialId;
/// Stable identity of a session. Never reused within a process.
/// Stable identity of a host. Never reused within a process.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SessionId(pub u64);
pub struct HostId(pub u64);
/// The identity a session shows outside the app, such as
/// The identity a host shows outside the app, such as
/// `Jwg5rHvXCxw89paM`: what 复制 ID copies, so a script or another tool can
/// name the machine. Random letters and digits, not derived from
/// [`SessionId`]: that one is an allocator detail that can come back after a
/// restart, while this one is never reused and stays with its session
/// through renames and address changes. It names a session; it grants
/// [`HostId`]: that one is an allocator detail that can come back after a
/// restart, while this one is never reused and stays with its host
/// through renames and address changes. It names a host; it grants
/// nothing.
///
/// A credential uses one the same way to name its password in the keychain.
@@ -61,11 +61,11 @@ impl std::fmt::Display for PublicId {
}
}
/// Stable identity of a session group (a folder in the session tree).
/// Stable identity of a host group (a folder in the host tree).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct GroupId(pub u64);
/// How a session logs in on its own, without a credential. A key, or a
/// How a host logs in on its own, without a credential. A key, or a
/// password shared by several hosts, is a credential.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AuthKind {
@@ -89,9 +89,8 @@ impl AuthKind {
}
}
/// Parse a stored spelling. Anything else, including what versions
/// before 10 wrote (`auto`, `key`), is a password: version 10's
/// migration turned those rows into passwords and key credentials.
/// Parse a stored spelling. Anything else is the default: `credential`,
/// which a host using a credential stores, keeps the default here.
pub fn from_stored(value: &str) -> Self {
match value {
"no-password" => AuthKind::NoPassword,
@@ -113,14 +112,14 @@ pub enum Route {
/// `None` is a jump host that has been deleted. It stays in its place
/// so that the host fails to connect, saying why, rather than quietly
/// skipping a hop or going direct.
Jump(Vec<Option<SessionId>>),
Jump(Vec<Option<HostId>>),
/// Through an HTTP or SOCKS5 proxy.
Proxy(ProxySettings),
}
impl Route {
/// The jump hosts this route goes through, deleted ones left out.
pub fn jump_hosts(&self) -> impl Iterator<Item = SessionId> + '_ {
pub fn jump_hosts(&self) -> impl Iterator<Item = HostId> + '_ {
let hops = match self {
Route::Jump(hops) => hops.as_slice(),
_ => &[],
@@ -205,7 +204,7 @@ impl ProxyKind {
}
}
/// Which pane of a session's SFTP tab a bookmark belongs to.
/// Which pane of a host's SFTP tab a bookmark belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BookmarkSide {
Local,
@@ -234,7 +233,7 @@ impl BookmarkSide {
}
}
/// The operating system running on a host, as reported by the session's own
/// The operating system running on a host, as reported by the host's own
/// shell after it connects.
///
/// Each variant owns four things that must stay in step: the spelling kept in
@@ -354,8 +353,8 @@ fn is_light(color: Rgba) -> bool {
luminance > 0.5
}
/// Connection state of a session. Runtime only: it is never persisted, so a
/// freshly loaded session always starts disconnected.
/// Connection state of a host. Runtime only: it is never persisted, so a
/// freshly loaded host always starts disconnected.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ConnectionState {
#[default]
@@ -378,21 +377,22 @@ impl ConnectionState {
}
}
/// A saved SSH session. `group` is `None` for a session that sits at the root
/// A saved SSH host. `group` is `None` for a host that sits at the root
/// of the tree rather than inside a folder.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Session {
pub id: SessionId,
/// Set once when the session is created; editing never changes it.
pub struct Host {
pub id: HostId,
/// Set once when the host is created; editing never changes it.
pub public_id: PublicId,
pub name: SharedString,
pub host: SharedString,
/// The host name or IP address it is reached at: 「地址」 in the form.
pub address: SharedString,
pub port: u16,
/// The user it logs in as. For a session using a credential this is the
/// The user it logs in as. For a host using a credential this is the
/// credential's user, which the store keeps in step.
pub user: SharedString,
/// How a session logs in on its own. A session using a credential keeps
/// How a host logs in on its own. A host using a credential keeps
/// the default here.
pub auth: AuthKind,
/// The credential it logs in with instead of `auth`, if any.
@@ -402,24 +402,24 @@ pub struct Session {
pub group: Option<GroupId>,
/// Whatever the user wants to remember about it; empty for none.
pub notes: SharedString,
/// Order among sessions in the same group.
/// Order among hosts in the same group.
pub sort_order: i64,
pub state: ConnectionState,
/// Detected on every successful connection and persisted, so the tree can
/// show the right mark before anyone connects. `None` until a probe
/// succeeds, and the session tree falls back to the name's first letter.
/// succeeds, and the host tree falls back to the name's first letter.
pub os: Option<HostOs>,
}
impl Session {
/// A session with a fresh [`PublicId`]. The store makes sure it is not
/// one another session already has.
pub fn new(id: SessionId, draft: SessionDraft) -> Self {
impl Host {
/// A host with a fresh [`PublicId`]. The store makes sure it is not
/// one another host already has.
pub fn new(id: HostId, draft: HostDraft) -> Self {
Self {
id,
public_id: PublicId::generate(),
name: draft.name,
host: draft.host,
address: draft.address,
port: draft.port,
user: draft.user,
auth: draft.auth,
@@ -433,29 +433,29 @@ impl Session {
}
}
/// `user@host:port`, as shown in the status bar.
pub fn address(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
/// `user@address:port`, as shown in the status bar.
pub fn endpoint(&self) -> String {
format!("{}@{}:{}", self.user, self.address, self.port)
}
/// Whether the host field is a literal IP address rather than a name, so
/// a command can say which of the two it copies.
pub fn host_is_ip(&self) -> bool {
is_ip_address(&self.host)
/// Whether the address is a literal IP address rather than a name, so a
/// command can say which of the two it copies.
pub fn address_is_ip(&self) -> bool {
is_ip_address(&self.address)
}
/// Where this session's login password lives in the system keychain.
/// Keyed by the endpoint, so renaming or copying a session keeps the
/// password and two sessions on the same account share one entry.
/// Where this host's login password lives in the system keychain.
/// Keyed by the endpoint, so renaming or copying a host keeps the
/// password and two hosts on the same account share one entry.
pub fn password_secret(&self) -> SecretRef {
SecretRef::password(self.user.as_ref(), self.host.as_ref(), self.port)
SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port)
}
/// The editable fields, for pre-filling the session form.
pub fn draft(&self) -> SessionDraft {
SessionDraft {
/// The editable fields, for pre-filling the host form.
pub fn draft(&self) -> HostDraft {
HostDraft {
name: self.name.clone(),
host: self.host.clone(),
address: self.address.clone(),
port: self.port,
user: self.user.clone(),
auth: self.auth,
@@ -478,21 +478,21 @@ fn is_ip_address(host: &str) -> bool {
bare.parse::<std::net::IpAddr>().is_ok()
}
/// A folder in the session tree. Groups nest: `parent` is `None` for a
/// A folder in the host tree. Groups nest: `parent` is `None` for a
/// top-level folder.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct SessionGroup {
pub struct HostGroup {
pub id: GroupId,
pub name: SharedString,
pub parent: Option<GroupId>,
/// Order among groups with the same parent.
pub sort_order: i64,
/// Whether the group is expanded in the session tree across launches.
/// Whether the group is expanded in the host tree across launches.
pub expanded: bool,
}
impl SessionGroup {
impl HostGroup {
pub fn new(id: GroupId, draft: GroupDraft) -> Self {
Self {
id,
@@ -512,12 +512,12 @@ impl SessionGroup {
}
}
/// The values the session form commits.
/// The values the host form commits.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SessionDraft {
pub struct HostDraft {
pub name: SharedString,
pub host: SharedString,
pub address: SharedString,
pub port: u16,
pub user: SharedString,
pub auth: AuthKind,
@@ -527,10 +527,10 @@ pub struct SessionDraft {
pub notes: SharedString,
}
impl SessionDraft {
impl HostDraft {
pub fn new(
name: impl Into<SharedString>,
host: impl Into<SharedString>,
address: impl Into<SharedString>,
port: u16,
user: impl Into<SharedString>,
auth: AuthKind,
@@ -538,7 +538,7 @@ impl SessionDraft {
) -> Self {
Self {
name: name.into(),
host: host.into(),
address: address.into(),
port,
user: user.into(),
auth,
@@ -568,9 +568,9 @@ impl SessionDraft {
}
/// The keychain entry this draft would log in with. Matches
/// [`Session::password_secret`] once the draft is applied.
/// [`Host::password_secret`] once the draft is applied.
pub fn password_secret(&self) -> SecretRef {
SecretRef::password(self.user.as_ref(), self.host.as_ref(), self.port)
SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port)
}
}
@@ -641,23 +641,21 @@ mod tests {
}
#[test]
fn a_new_session_has_not_been_probed_yet() {
let session = Session::new(
SessionId(1),
SessionDraft::new("s", "h", 22, "root", AuthKind::Password, None),
fn a_new_host_has_not_been_probed_yet() {
let host = Host::new(
HostId(1),
HostDraft::new("s", "h", 22, "root", AuthKind::Password, None),
);
assert_eq!(session.os, None);
assert_eq!(host.os, None);
}
#[test]
fn a_password_is_the_default_and_old_spellings_read_as_one() {
fn a_password_is_the_default_and_spellings_round_trip() {
assert_eq!(AuthKind::default(), AuthKind::Password);
for auth in [AuthKind::Password, AuthKind::NoPassword] {
assert_eq!(AuthKind::from_stored(auth.as_str()), auth);
}
// What versions before 10 wrote.
assert_eq!(AuthKind::from_stored("auto"), AuthKind::Password);
assert_eq!(AuthKind::from_stored("key"), AuthKind::Password);
assert_eq!(AuthKind::from_stored("credential"), AuthKind::Password);
}
}
+65 -77
View File
@@ -1,35 +1,35 @@
//! Pure functions that turn the session store into tree items.
//! Pure functions that turn the host store into tree items.
use std::collections::HashSet;
use gpui_kit::SharedString;
use gpui_kit::component::tree::TreeItem;
use super::{GroupId, Session, SessionGroup, SessionId};
use super::{GroupId, Host, HostGroup, HostId};
/// What a tree row stands for. Encoded into the row's `TreeItem` id so the
/// renderer and the context menu can recover the domain object.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionNode {
pub enum HostNode {
Group(GroupId),
Session(SessionId),
Host(HostId),
}
/// A drop location in the session tree. Groups and sessions each have their
/// A drop location in the host tree. Groups and hosts each have their
/// own order within a parent; a drop into a group changes the parent.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NodeDrop {
Before(SessionNode),
After(SessionNode),
Before(HostNode),
After(HostNode),
Into(GroupId),
Root,
}
impl SessionNode {
impl HostNode {
pub fn id(self) -> SharedString {
match self {
SessionNode::Group(GroupId(id)) => format!("g:{id}").into(),
SessionNode::Session(SessionId(id)) => format!("s:{id}").into(),
HostNode::Group(GroupId(id)) => format!("g:{id}").into(),
HostNode::Host(HostId(id)) => format!("s:{id}").into(),
}
}
@@ -37,56 +37,56 @@ impl SessionNode {
let (kind, number) = id.split_once(':')?;
let number = number.parse().ok()?;
match kind {
"g" => Some(SessionNode::Group(GroupId(number))),
"s" => Some(SessionNode::Session(SessionId(number))),
"g" => Some(HostNode::Group(GroupId(number))),
"s" => Some(HostNode::Host(HostId(number))),
_ => None,
}
}
pub fn session_id(self) -> Option<SessionId> {
pub fn host_id(self) -> Option<HostId> {
match self {
SessionNode::Session(id) => Some(id),
SessionNode::Group(_) => None,
HostNode::Host(id) => Some(id),
HostNode::Group(_) => None,
}
}
pub fn group_id(self) -> Option<GroupId> {
match self {
SessionNode::Group(id) => Some(id),
SessionNode::Session(_) => None,
HostNode::Group(id) => Some(id),
HostNode::Host(_) => None,
}
}
}
/// Case-insensitive match on name, host or user.
pub fn matches_query(session: &Session, query: &str) -> bool {
pub fn matches_query(host: &Host, query: &str) -> bool {
let query = query.trim().to_lowercase();
if query.is_empty() {
return true;
}
[&session.name, &session.host, &session.user]
[&host.name, &host.address, &host.user]
.iter()
.any(|field| field.to_lowercase().contains(&query))
}
/// Build the nested tree. Each level lists its subgroups first, then the
/// sessions that belong to it; sessions with no group sit at the root beside
/// the top-level groups. A non-empty query keeps only matching sessions, drops
/// hosts that belong to it; hosts with no group sit at the root beside
/// the top-level groups. A non-empty query keeps only matching hosts, drops
/// the groups left without any, and expands what remains; otherwise
/// `expanded` decides which groups are open.
pub fn session_tree_items(
groups: &[SessionGroup],
sessions: &[Session],
pub fn host_tree_items(
groups: &[HostGroup],
hosts: &[Host],
query: &str,
expanded: &HashSet<GroupId>,
) -> Vec<TreeItem> {
let filtering = !query.trim().is_empty();
items_under(None, 0, groups, sessions, query, filtering, expanded)
items_under(None, 0, groups, hosts, query, filtering, expanded)
}
/// The groups directly under `parent`, in display order. `None` asks for the
/// top-level groups.
fn child_groups(groups: &[SessionGroup], parent: Option<GroupId>) -> Vec<&SessionGroup> {
fn child_groups(groups: &[HostGroup], parent: Option<GroupId>) -> Vec<&HostGroup> {
let mut children: Vec<_> = groups
.iter()
.filter(|group| group.parent == parent)
@@ -98,8 +98,8 @@ fn child_groups(groups: &[SessionGroup], parent: Option<GroupId>) -> Vec<&Sessio
fn items_under(
parent: Option<GroupId>,
depth: usize,
groups: &[SessionGroup],
sessions: &[Session],
groups: &[HostGroup],
hosts: &[Host],
query: &str,
filtering: bool,
expanded: &HashSet<GroupId>,
@@ -116,7 +116,7 @@ fn items_under(
Some(group.id),
depth + 1,
groups,
sessions,
hosts,
query,
filtering,
expanded,
@@ -125,20 +125,20 @@ fn items_under(
continue;
}
items.push(
TreeItem::new(SessionNode::Group(group.id).id(), group.name.clone())
TreeItem::new(HostNode::Group(group.id).id(), group.name.clone())
.expanded(filtering || expanded.contains(&group.id))
.children(children),
);
}
let mut child_sessions: Vec<_> = sessions
let mut child_hosts: Vec<_> = hosts
.iter()
.filter(|s| s.group == parent && matches_query(s, query))
.collect();
child_sessions.sort_by_key(|session| (session.sort_order, session.id));
child_hosts.sort_by_key(|host| (host.sort_order, host.id));
items.extend(
child_sessions.into_iter().map(|session| {
TreeItem::new(SessionNode::Session(session.id).id(), session.name.clone())
}),
child_hosts
.into_iter()
.map(|host| TreeItem::new(HostNode::Host(host.id).id(), host.name.clone())),
);
items
}
@@ -147,17 +147,14 @@ fn items_under(
/// `生产 / 数据库`), for the group pickers in the forms. An excluded group is
/// skipped along with everything below it, which is how the rename dialog
/// keeps a group from being moved into its own subtree.
pub fn group_options(
groups: &[SessionGroup],
excluded: &[GroupId],
) -> Vec<(GroupId, SharedString)> {
pub fn group_options(groups: &[HostGroup], excluded: &[GroupId]) -> Vec<(GroupId, SharedString)> {
let mut options = Vec::new();
collect_group_options(groups, None, "", 0, excluded, &mut options);
options
}
fn collect_group_options(
groups: &[SessionGroup],
groups: &[HostGroup],
parent: Option<GroupId>,
prefix: &str,
depth: usize,
@@ -184,9 +181,9 @@ fn collect_group_options(
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{AuthKind, GroupDraft, SessionDraft, SessionStore};
use crate::host::{AuthKind, GroupDraft, HostDraft, HostStore};
fn all_groups(store: &SessionStore) -> HashSet<GroupId> {
fn all_groups(store: &HostStore) -> HashSet<GroupId> {
store.groups().iter().map(|g| g.id).collect()
}
@@ -196,20 +193,17 @@ mod tests {
#[test]
fn node_id_round_trips() {
for node in [
SessionNode::Group(GroupId(7)),
SessionNode::Session(SessionId(42)),
] {
assert_eq!(SessionNode::parse(&node.id()), Some(node));
for node in [HostNode::Group(GroupId(7)), HostNode::Host(HostId(42))] {
assert_eq!(HostNode::parse(&node.id()), Some(node));
}
assert_eq!(SessionNode::parse("x:1"), None);
assert_eq!(SessionNode::parse("s:abc"), None);
assert_eq!(HostNode::parse("x:1"), None);
assert_eq!(HostNode::parse("s:abc"), None);
}
#[test]
fn empty_query_keeps_every_group_and_session() {
let store = SessionStore::seed();
let items = session_tree_items(store.groups(), store.sessions(), "", &all_groups(&store));
fn empty_query_keeps_every_group_and_host() {
let store = HostStore::seed();
let items = host_tree_items(store.groups(), store.hosts(), "", &all_groups(&store));
assert_eq!(items.len(), 3);
let total: usize = items.iter().map(|g| g.children.len()).sum();
assert_eq!(total, 6);
@@ -218,44 +212,38 @@ mod tests {
#[test]
fn collapsed_groups_follow_the_expanded_set() {
let store = SessionStore::seed();
let items = session_tree_items(store.groups(), store.sessions(), "", &HashSet::new());
let store = HostStore::seed();
let items = host_tree_items(store.groups(), store.hosts(), "", &HashSet::new());
assert!(items.iter().all(|g| !g.is_expanded()));
}
#[test]
fn query_matches_host_and_user_case_insensitively() {
let store = SessionStore::seed();
let by_host =
session_tree_items(store.groups(), store.sessions(), "10.0.9", &HashSet::new());
let store = HostStore::seed();
let by_host = host_tree_items(store.groups(), store.hosts(), "10.0.9", &HashSet::new());
let names: Vec<_> = by_host
.iter()
.flat_map(|g| g.children.iter().map(|c| c.label.to_string()))
.collect();
assert_eq!(names, ["staging-api", "qa-runner"]);
let by_user = session_tree_items(
store.groups(),
store.sessions(),
"POSTGRES",
&HashSet::new(),
);
let by_user = host_tree_items(store.groups(), store.hosts(), "POSTGRES", &HashSet::new());
assert_eq!(by_user.len(), 1);
assert_eq!(by_user[0].children[0].label.as_ref(), "db-01");
}
#[test]
fn query_drops_groups_without_matches_and_expands_the_rest() {
let store = SessionStore::seed();
let items = session_tree_items(store.groups(), store.sessions(), "dev", &HashSet::new());
let store = HostStore::seed();
let items = host_tree_items(store.groups(), store.hosts(), "dev", &HashSet::new());
assert_eq!(items.len(), 1);
assert_eq!(items[0].label.as_ref(), "开发");
assert!(items[0].is_expanded());
}
/// 生产 / 数据库 with one session each, plus a session at the root.
fn nested_store() -> SessionStore {
let mut store = SessionStore::empty();
/// 生产 / 数据库 with one host each, plus a host at the root.
fn nested_store() -> HostStore {
let mut store = HostStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
for (name, group) in [
@@ -263,7 +251,7 @@ mod tests {
("db-01", Some(databases)),
("jump", None),
] {
store.insert_unnotified(SessionDraft::new(
store.insert_unnotified(HostDraft::new(
name,
"10.0.0.1",
22,
@@ -276,12 +264,12 @@ mod tests {
}
#[test]
fn subgroups_nest_and_ungrouped_sessions_sit_at_the_root() {
fn subgroups_nest_and_ungrouped_hosts_sit_at_the_root() {
let store = nested_store();
let items = session_tree_items(store.groups(), store.sessions(), "", &all_groups(&store));
// A top-level group, then the session that belongs to no group.
let items = host_tree_items(store.groups(), store.hosts(), "", &all_groups(&store));
// A top-level group, then the host that belongs to no group.
assert_eq!(labels(&items), ["生产", "jump"]);
// Inside 生产: the subgroup first, then its own session.
// Inside 生产: the subgroup first, then its own host.
assert_eq!(labels(&items[0].children), ["数据库", "web-01"]);
assert_eq!(labels(&items[0].children[0].children), ["db-01"]);
}
@@ -289,7 +277,7 @@ mod tests {
#[test]
fn a_match_deep_in_the_tree_keeps_its_whole_ancestor_chain() {
let store = nested_store();
let items = session_tree_items(store.groups(), store.sessions(), "db-01", &HashSet::new());
let items = host_tree_items(store.groups(), store.hosts(), "db-01", &HashSet::new());
assert_eq!(labels(&items), ["生产"]);
assert!(items[0].is_expanded());
assert_eq!(labels(&items[0].children), ["数据库"]);
@@ -299,7 +287,7 @@ mod tests {
#[test]
fn group_options_are_depth_first_and_skip_an_excluded_subtree() {
let mut store = SessionStore::empty();
let mut store = HostStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
store.insert_group_unnotified(GroupDraft::new("只读副本", Some(databases)));
@@ -325,9 +313,9 @@ mod tests {
}
#[test]
fn a_query_matching_only_a_root_session_drops_every_group() {
fn a_query_matching_only_a_root_host_drops_every_group() {
let store = nested_store();
let items = session_tree_items(store.groups(), store.sessions(), "jump", &HashSet::new());
let items = host_tree_items(store.groups(), store.hosts(), "jump", &HashSet::new());
assert_eq!(labels(&items), ["jump"]);
}
}
File renamed without changes.
File renamed without changes.
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,7 +1,7 @@
//! ShellRS: an SSH session manager with real SSH and local terminals.
//! ShellRS: an SSH host manager with real SSH and local terminals.
//!
//! Modules are organized by capability so they can become crates later:
//! `session` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
//! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
//! (凭据), `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update`
//! (在线升级), `connection` 与 `shared` (跨模块共用), `workspace` (窗口壳), `app`
@@ -13,8 +13,8 @@ pub mod connection;
pub mod credential;
pub mod explorer;
pub mod forward;
pub mod host;
pub mod secrets;
pub mod session;
pub mod settings;
pub mod sftp;
pub mod shared;
+8 -8
View File
@@ -5,7 +5,7 @@
use gpui_kit::component::{Root, notification::Notification};
use gpui_kit::*;
use shellrs::session::{SessionDatabase, SessionStore};
use shellrs::host::{HostDatabase, HostStore};
use shellrs::settings::SettingsStore;
fn main() {
@@ -16,7 +16,7 @@ fn main() {
if let Some(args) = shellrs::cli::command_line_arguments() {
std::process::exit(shellrs::cli::main(args));
}
// One ShellRS per data directory. Each keeps the sessions in memory and
// One ShellRS per data directory. Each keeps the hosts in memory and
// writes its changes through, so a second one would write over the
// first one's; opened again, ShellRS brings the running one forward.
if shellrs::cli::activate_running_app(&shellrs::app::cli_socket_path()) {
@@ -59,15 +59,15 @@ fn main() {
shellrs::update::start_handed_over();
}
/// Load the sessions saved on disk. If the database cannot be opened the
/// Load the hosts saved on disk. If the database cannot be opened the
/// window still comes up, on a store that forgets everything when it closes;
/// the returned message says so.
///
/// Either way the store gets the system keychain, which is where passwords
/// live. A machine without one still runs; the session form says so and
/// live. A machine without one still runs; the host form says so and
/// disables its password field. Pasted and generated private keys go
/// beside the database.
fn open_store() -> (SessionStore, Option<SharedString>) {
fn open_store() -> (HostStore, Option<SharedString>) {
let secrets = shellrs::secrets::system_secret_store();
match load_store() {
// Keys are kept only with a database to remember them: a store
@@ -81,7 +81,7 @@ fn open_store() -> (SessionStore, Option<SharedString>) {
Err(error) => {
eprintln!("shellrs: 无法打开本地数据库:{error}");
(
SessionStore::empty().with_secrets(secrets),
HostStore::empty().with_secrets(secrets),
Some(format!("无法打开本地数据库,本次运行的改动不会被保存:{error}").into()),
)
}
@@ -100,7 +100,7 @@ fn open_settings() -> (SettingsStore, Option<SharedString>) {
}
}
fn load_store() -> anyhow::Result<SessionStore> {
fn load_store() -> anyhow::Result<HostStore> {
let path = shellrs::app::database_path()?;
Ok(SessionStore::load(SessionDatabase::open(&path)?)?)
Ok(HostStore::load(HostDatabase::open(&path)?)?)
}
+1 -1
View File
@@ -51,7 +51,7 @@ impl SecretStore for InMemorySecretStore {
}
/// 用在没有系统钥匙串的机器上:读永远是空,写明确报错,界面据此把密码字段
/// 禁用掉。也是 `SessionStore` 的默认值,所以单元测试不会碰到任何秘密存储。
/// 禁用掉。也是 `HostStore` 的默认值,所以单元测试不会碰到任何秘密存储。
pub struct NoSecretStore;
impl SecretStore for NoSecretStore {
+1 -1
View File
@@ -1,6 +1,6 @@
//! 系统钥匙串(macOS Keychain Services / Windows 凭据管理器 / Secret Service)。
//!
//! ShellRS 的秘密只存在这里。数据库里永远不出现密码或口令,`session/database.rs`
//! ShellRS 的秘密只存在这里。数据库里永远不出现密码或口令,`host/database.rs`
//! 的 `schema_never_contains_secret_columns` 守着这条不变量。
mod keychain;
File diff suppressed because it is too large. Load diff
-30
View File
@@ -1,30 +0,0 @@
//! 主机管理: the saved hosts, which the code calls sessions. The session
//! model, its store, and the session panel/dialog.
mod credential;
mod database;
mod forward;
mod group_dialog;
mod login;
mod model;
mod outline;
mod private_key;
mod secret_fields;
mod session_dialog;
mod session_panel;
mod store;
pub use credential::*;
pub use database::{SessionDatabase, StoredData};
pub use forward::*;
pub use group_dialog::{GroupForm, confirm_delete_group, open_group_dialog};
pub use login::{JumpLogin, LoginMethod, LoginRoute, ProxyLogin, SessionLogin};
pub use model::*;
pub use outline::{NodeDrop, SessionNode, group_options, matches_query, session_tree_items};
pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key};
pub use secret_fields::SecretFields;
pub use session_dialog::{
DeleteHandler, Dependents, SessionForm, confirm_delete_session, open_session_dialog,
};
pub use session_panel::{SessionPanel, session_menu};
pub use store::{SessionStore, SessionStoreEvent};
+1 -1
View File
@@ -25,7 +25,7 @@ pub struct AppSettings {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ExternalCliSettings {
/// Whether the `shellrs` command may use the saved sessions. Off until
/// Whether the `shellrs` command may use the saved hosts. Off until
/// the user turns it on.
pub enabled: bool,
}
+1 -1
View File
@@ -318,7 +318,7 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
]
}
/// 外部 CLI: whether the `shellrs` command may use the saved sessions, the
/// 外部 CLI: whether the `shellrs` command may use the saved hosts, the
/// command itself, and the skill that teaches agents to use it.
fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
let (reader, writer) = (panel.store.clone(), panel.store.clone());
+1 -1
View File
@@ -14,7 +14,7 @@ pub enum SettingsStoreEvent {
}
/// The settings, shared by the settings page and the workspace that applies
/// them. Like `SessionStore`, memory is the source of truth and every change
/// them. Like `HostStore`, memory is the source of truth and every change
/// is written through to disk at once; a failed write keeps the change and
/// says so.
pub struct SettingsStore {
+5 -5
View File
@@ -2,8 +2,8 @@
use super::*;
use crate::{
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret},
host::{AuthKind, Host, HostDraft, HostId, HostLogin},
secrets::InMemorySecretStore,
session::{AuthKind, Session, SessionDraft, SessionId, SessionLogin},
ssh::SshConnector,
};
use russh::{
@@ -249,9 +249,9 @@ fn worker(port: u16, data: &std::path::Path) -> Worker {
data.join("upload-resume"),
data.join("download-resume"),
);
let session = Session::new(
SessionId(1),
SessionDraft::new(
let host = Host::new(
HostId(1),
HostDraft::new(
"fixture",
"127.0.0.1",
port,
@@ -260,7 +260,7 @@ fn worker(port: u16, data: &std::path::Path) -> Worker {
None,
),
);
let transport = provider.create(&SessionLogin::of(&session, None));
let transport = provider.create(&HostLogin::of(&host, None));
let (commands, rx) = async_channel::unbounded();
let (tx, events) = async_channel::unbounded();
let thread = std::thread::spawn(move || transport.run(rx, tx).unwrap());
+2 -2
View File
@@ -4,7 +4,7 @@ use super::{
};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptReply},
session::SessionLogin,
host::HostLogin,
};
use anyhow::Result;
use async_channel::{Receiver, Sender};
@@ -96,6 +96,6 @@ pub trait SftpTransport: Send + 'static {
) -> Result<()>;
}
pub trait SftpTransportProvider: Send + Sync + 'static {
fn create(&self, login: &SessionLogin) -> Box<dyn SftpTransport>;
fn create(&self, login: &HostLogin) -> Box<dyn SftpTransport>;
}
pub type SharedSftpTransportProvider = Arc<dyn SftpTransportProvider>;
+3 -3
View File
@@ -12,7 +12,7 @@ use super::{
upload::UploadBatch,
};
use crate::{
session::SessionLogin,
host::HostLogin,
ssh::{SshConnectionConfig, SshConnector, SshPrompts},
};
use anyhow::{Result, anyhow};
@@ -43,7 +43,7 @@ impl SshSftpTransportProvider {
}
}
impl SftpTransportProvider for SshSftpTransportProvider {
fn create(&self, login: &SessionLogin) -> Box<dyn SftpTransport> {
fn create(&self, login: &HostLogin) -> Box<dyn SftpTransport> {
Box::new(SshSftpTransport {
connector: self.connector.clone(),
config: SshConnectionConfig::from(login),
@@ -59,7 +59,7 @@ struct SshSftpTransport {
download_journal: DownloadJournal,
}
/// The session's one transfer batch, whichever way it goes.
/// The host's one transfer batch, whichever way it goes.
enum TransferBatch {
Upload(UploadBatch),
Download(DownloadBatch),
+4 -4
View File
@@ -9,7 +9,7 @@ use gpui_kit::component::{
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::ToggleSessionPanel;
use crate::app::ToggleHostPanel;
type MenuBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
@@ -20,9 +20,9 @@ type MenuBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) ->
/// Closing goes through an action rather than `TabGroup::close_panel` on
/// purpose: the group refuses to close the last panel of a region, while the
/// workspace wants every tab closable (an empty center shows the recent
/// sessions instead) and removes the panel from the dock itself.
/// hosts instead) and removes the panel from the dock itself.
///
/// Double-clicking the title shows or hides the session sidebar, the way a
/// Double-clicking the title shows or hides the host sidebar, the way a
/// double click on an editor tab gives it the room elsewhere.
///
/// The context menu and the double click hang on the title rather than on
@@ -97,7 +97,7 @@ impl RenderOnce for ClosableTabTitle {
// The first click selects the tab as usual.
.on_click(|event, window, cx| {
if event.click_count() == 2 {
window.dispatch_action(Box::new(ToggleSessionPanel), cx);
window.dispatch_action(Box::new(ToggleHostPanel), cx);
}
})
.test_support()
+5 -5
View File
@@ -4,13 +4,13 @@ use gpui_kit::component::{
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::session::HostOs;
use crate::host::HostOs;
/// The mark that stands for a session: the operating system found on its host,
/// The mark that stands for a host: the operating system found on its host,
/// drawn on a badge in that project's own colour, or the first character of
/// the session name until a probe succeeds.
/// the host name until a probe succeeds.
///
/// Shared so the session tree and the start page show one identity for the
/// Shared so the host tree and the start page show one identity for the
/// same host. The badge borrows `Avatar`'s treatment, a circle at the theme's
/// radius, so identity marks look alike wherever the product shows one.
#[derive(IntoElement)]
@@ -38,7 +38,7 @@ impl HostMark {
}
/// No tooltip of its own, for a row that shows one for the whole row, as
/// the session tree does, so two tooltips never stack.
/// the host tree does, so two tooltips never stack.
pub fn without_tooltip(mut self) -> Self {
self.tooltip = false;
self
+13 -13
View File
@@ -3,8 +3,8 @@ use crate::{
connection::{
ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply,
},
host::{HostLogin, JumpLogin, LoginMethod, LoginRoute, ProxyLogin},
secrets::{SecretRef, SharedSecretStore},
session::{JumpLogin, LoginMethod, LoginRoute, ProxyLogin, SessionLogin},
};
use anyhow::{Context as _, Result, anyhow, bail};
use russh::keys::{
@@ -46,11 +46,11 @@ static NEXT_PROMPT_ID: AtomicU64 = AtomicU64::new(1);
/// it on the UI thread, credential and all.
#[derive(Clone)]
pub struct SshConnectionConfig {
login: SessionLogin,
login: HostLogin,
}
impl From<&SessionLogin> for SshConnectionConfig {
fn from(login: &SessionLogin) -> Self {
impl From<&HostLogin> for SshConnectionConfig {
fn from(login: &HostLogin) -> Self {
Self {
login: login.clone(),
}
@@ -159,7 +159,7 @@ impl SshConnector {
async fn log_in(
&self,
tunnel: Tunnel,
login: &SessionLogin,
login: &HostLogin,
jump_host: Option<&str>,
broker: &Arc<SshPrompts>,
secrets: &SharedSecretStore,
@@ -200,7 +200,7 @@ impl SshConnector {
async fn through_jumps(
&self,
hops: &[JumpLogin],
login: &SessionLogin,
login: &HostLogin,
broker: &Arc<SshPrompts>,
secrets: &SharedSecretStore,
) -> Result<Tunnel> {
@@ -255,7 +255,7 @@ impl SshConnector {
&self,
tunnel: Option<Tunnel>,
name: &str,
hop: &SessionLogin,
hop: &HostLogin,
next: (&str, u16),
broker: &Arc<SshPrompts>,
secrets: &SharedSecretStore,
@@ -349,7 +349,7 @@ async fn tcp(host: &str, port: u16) -> Result<TcpStream> {
/// A stream to `login`'s host through `proxy`.
async fn through_proxy(
proxy: &ProxyLogin,
login: &SessionLogin,
login: &HostLogin,
secrets: &SharedSecretStore,
) -> Result<TcpStream> {
let mut socket = TcpStream::connect((proxy.host.as_str(), proxy.port))
@@ -778,7 +778,7 @@ fn read_known_keys(host: &str, port: u16, path: &Path) -> Result<Vec<(usize, Pub
async fn authenticate(
handle: &mut SshHandle,
login: &SessionLogin,
login: &HostLogin,
secrets: &SharedSecretStore,
agent: &AgentLocation,
broker: Asker<'_>,
@@ -863,7 +863,7 @@ async fn authenticate(
if method == LoginMethod::Password || partial {
if methods.contains(&MethodKind::Password) {
// Try what the session has saved before bothering anyone.
// Try what the host has saved before bothering anyone.
let mut saved_rejected = false;
if let Some(saved) = saved_secret(secrets, &login.password) {
let result = handle
@@ -873,7 +873,7 @@ async fn authenticate(
if result.success() {
return Ok(());
}
// The entry stays: the user typed it into the session dialog,
// The entry stays: the user typed it into the host dialog,
// and deleting it behind their back would be baffling. This
// connection just falls back to asking, and says why.
saved_rejected = true;
@@ -1042,7 +1042,7 @@ async fn connect_agent(location: &AgentLocation) -> Result<Agent, AgentProblem>
Err(AgentProblem::Unreachable("这个平台不支持".to_string()))
}
fn key_paths(login: &SessionLogin) -> Result<Vec<PathBuf>> {
fn key_paths(login: &HostLogin) -> Result<Vec<PathBuf>> {
if login.method == LoginMethod::Key {
return login
.key_path
@@ -1073,7 +1073,7 @@ async fn load_private_key(
Err(_) => bail!("无法读取私钥文件:{}", path.display()),
}
// Passphrases are saved per key file, so one saved answer unlocks the same
// key for every session that uses it.
// key for every host that uses it.
let mut saved_rejected = false;
if let Some(saved) = saved_secret(secrets, &SecretRef::passphrase(path)) {
if let Ok(key) = load_secret_key(path, Some(saved.as_str())) {
+1 -1
View File
@@ -1,4 +1,4 @@
//! Run one command on a session's host and pass on what it prints: the
//! Run one command on a saved host and pass on what it prints: the
//! external CLI's `exec`. Nobody is there to answer a question, so the
//! connection never asks one: a host not trusted yet or a password not saved
//! fails at once and says so.
+2 -2
View File
@@ -3,7 +3,7 @@
//! Runs once per successful connection on its own exec channel, so the user's
//! shell never sees it and its output cannot be confused with theirs.
use crate::session::HostOs;
use crate::host::HostOs;
/// One question for POSIX hosts: the kernel name, then whatever
/// `/etc/os-release` says. stderr is dropped because macOS legitimately has no
@@ -165,7 +165,7 @@ impl HostOsProbe {
#[cfg(test)]
mod tests {
use super::{HostOsProbe, MAX_PROBE_OUTPUT, ProbeOutcome, parse_probe, parse_windows_probe};
use crate::session::HostOs;
use crate::host::HostOs;
#[test]
fn reads_ubuntu() {
+1 -1
View File
@@ -13,7 +13,7 @@ use base64::Engine as _;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use zeroize::Zeroizing;
use crate::session::ProxyKind;
use crate::host::ProxyKind;
/// What a proxy that wants a user name and password is given.
pub(super) struct ProxyAuth<'a> {
+3 -5
View File
@@ -23,10 +23,8 @@ use crate::{
ConnectionPrompt, ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret,
ConnectionTester as _, LoginTest,
},
host::{AuthKind, HostLogin, JumpLogin, LoginRoute, ProxyKind, ProxyLogin, ProxySettings},
secrets::{InMemorySecretStore, SecretRef, SecretStore as _},
session::{
AuthKind, JumpLogin, LoginRoute, ProxyKind, ProxyLogin, ProxySettings, SessionLogin,
},
};
/// An SSH server that lets one user in with one password and forwards
@@ -282,8 +280,8 @@ const TARGET_PASSWORD: &str = "target-password";
const JUMP_USER: &str = "jumper";
const JUMP_PASSWORD: &str = "jump-password";
fn manual(host: &str, port: u16, user: &str) -> SessionLogin {
SessionLogin::manual(host, port, user, AuthKind::Password)
fn manual(host: &str, port: u16, user: &str) -> HostLogin {
HostLogin::manual(host, port, user, AuthKind::Password)
}
fn hop(name: &str, port: u16) -> JumpLogin {
+2 -2
View File
@@ -1,4 +1,4 @@
//! Log in once with the session form's values, for its 「测试连接」 button.
//! Log in once with the host form's values, for its 「测试连接」 button.
use std::{
sync::{Arc, Mutex, OnceLock, Weak},
time::Duration,
@@ -17,8 +17,8 @@ use crate::{
ConnectionPrompt, ConnectionPromptKind, ConnectionPromptReply, ConnectionTester, LoginTest,
TrustCallback,
},
host::{JumpLogin, LoginRoute},
secrets::{SecretRef, SecretStore, SharedSecretStore},
session::{JumpLogin, LoginRoute},
};
/// How long a test may take, not counting the time a person spends deciding
+65 -71
View File
@@ -3,7 +3,7 @@ use super::{
probe::{HostOsProbe, ProbeOutcome},
};
use crate::{
session::SessionLogin,
host::HostLogin,
terminal::{
Latency, RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize,
TerminalTransport, TerminalTransportCommand, TerminalTransportEvent,
@@ -42,7 +42,7 @@ impl SshTerminalTransportProvider {
}
}
impl RemoteTerminalTransportProvider for SshTerminalTransportProvider {
fn factory_for(&self, login: &SessionLogin) -> SharedTerminalTransportFactory {
fn factory_for(&self, login: &HostLogin) -> SharedTerminalTransportFactory {
Arc::new(SshTerminalTransport {
config: SshConnectionConfig::from(login),
connector: self.connector.clone(),
@@ -50,7 +50,7 @@ impl RemoteTerminalTransportProvider for SshTerminalTransportProvider {
}
}
/// One session's connection settings. It is its own factory: every launch
/// One host's connection settings. It is its own factory: every launch
/// and restart runs a fresh copy.
#[derive(Clone)]
struct SshTerminalTransport {
@@ -177,7 +177,7 @@ impl SshTerminalTransport {
// loop below collect the answer alongside the shell's output. Opening
// it after `Started` keeps the terminal from waiting on a round trip,
// and a host that refuses or ignores the probe simply keeps whatever
// mark the session already had.
// mark the host already had.
let mut probe = HostOsProbe::new();
let mut probe_channel = open_probe(&handle, probe.command()).await;
@@ -299,7 +299,7 @@ fn pixel_dimension(cells: usize, cell_size: u16) -> u32 {
}
/// Open a channel and run one probe command on it. Best effort throughout: a
/// server that refuses the channel or the command just leaves the session's
/// server that refuses the channel or the command just leaves the host's
/// recorded operating system as it was.
async fn open_probe(
handle: &SshHandle,
@@ -316,14 +316,14 @@ async fn open_probe(
#[cfg(test)]
mod tests {
use super::*;
use crate::host::HostOs;
use crate::secrets::{
InMemorySecretStore, NoSecretStore, SecretRef, SecretStore as _, SharedSecretStore,
};
use crate::session::HostOs;
use crate::ssh::probe::{PROBE_COMMAND, WINDOWS_PROBE_COMMAND};
use crate::{
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret},
session::{AuthKind, Session},
host::{AuthKind, Host},
};
use russh::keys::{PublicKey, known_hosts::learn_known_hosts_path};
use russh::server::{self, Server as _};
@@ -651,12 +651,12 @@ mod tests {
}
fn connect_then_shutdown(
session: Session,
host: Host,
known_hosts: &Path,
answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply,
) {
connect(
SessionLogin::of(&session, None),
HostLogin::of(&host, None),
known_hosts,
Arc::new(NoSecretStore),
false,
@@ -665,13 +665,13 @@ mod tests {
}
fn connect_with_secrets(
session: Session,
host: Host,
known_hosts: &Path,
secrets: SharedSecretStore,
answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply,
) -> ConnectionReport {
connect(
SessionLogin::of(&session, None),
HostLogin::of(&host, None),
known_hosts,
secrets,
false,
@@ -682,12 +682,12 @@ mod tests {
/// Same, but waits for the host-operating-system probe and the first
/// round-trip measurement to report before shutting the connection down.
fn connect_and_probe(
session: Session,
host: Host,
known_hosts: &Path,
answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply,
) -> ConnectionReport {
connect(
SessionLogin::of(&session, None),
HostLogin::of(&host, None),
known_hosts,
Arc::new(NoSecretStore),
true,
@@ -706,7 +706,7 @@ mod tests {
/// Connect, answer whatever is asked, then shut down. The report says what
/// was raised along the way, so a test can assert that nothing was.
fn connect(
login: SessionLogin,
login: HostLogin,
known_hosts: &Path,
secrets: SharedSecretStore,
wait_for_host_os: bool,
@@ -788,12 +788,12 @@ mod tests {
};
let directory = tempfile::tempdir().unwrap();
let known_hosts = directory.path().join("known_hosts");
let session = password_session(server.port);
let host = password_host(server.port);
let provider = SshTerminalTransportProvider::with_connector(SshConnector::new(
&known_hosts,
Arc::new(NoSecretStore),
));
let factory = provider.factory_for(&SessionLogin::of(&session, None));
let factory = provider.factory_for(&HostLogin::of(&host, None));
let (command_tx, command_rx) = mpsc::channel();
let (event_tx, event_rx) = async_channel::unbounded();
let worker = thread::spawn(move || {
@@ -866,7 +866,7 @@ mod tests {
// A second connection must trust the saved key without prompting and
// an active close must return the worker within the shutdown bound.
let factory = provider.factory_for(&SessionLogin::of(&session, None));
let factory = provider.factory_for(&HostLogin::of(&host, None));
let (command_tx, command_rx) = mpsc::channel();
let (event_tx, event_rx) = async_channel::unbounded();
let (done_tx, done_rx) = mpsc::channel();
@@ -925,10 +925,10 @@ mod tests {
}
}
fn password_session(port: u16) -> Session {
Session::new(
crate::session::SessionId(1),
crate::session::SessionDraft::new(
fn password_host(port: u16) -> Host {
Host::new(
crate::host::HostId(1),
crate::host::HostDraft::new(
"test",
"127.0.0.1",
port,
@@ -939,7 +939,7 @@ mod tests {
)
}
/// Run a connection test the way the session form does, recording
/// Run a connection test the way the host form does, recording
/// whether (and about what) it asked to trust the host.
fn test_login(
request: crate::connection::LoginTest,
@@ -962,7 +962,7 @@ mod tests {
}
fn login_request(port: u16) -> crate::connection::LoginTest {
crate::connection::LoginTest::typed(SessionLogin::manual(
crate::connection::LoginTest::typed(HostLogin::manual(
"127.0.0.1",
port,
"tester",
@@ -973,7 +973,7 @@ mod tests {
/// The CLI's `exec` against the test server, on a thread of its own so
/// a login that waits instead of failing shows up as a timeout.
fn run_cli_command(
session: &Session,
host: &Host,
known_hosts: &Path,
keychain: Arc<InMemorySecretStore>,
) -> (
@@ -982,7 +982,7 @@ mod tests {
Vec<u8>,
) {
let connector = SshConnector::new(known_hosts, keychain);
let config = SshConnectionConfig::from(&SessionLogin::of(session, None));
let config = SshConnectionConfig::from(&HostLogin::of(host, None));
let (done_tx, done_rx) = mpsc::channel();
thread::spawn(move || {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
@@ -1022,13 +1022,13 @@ mod tests {
let directory = tempfile::tempdir().unwrap();
let known_hosts = directory.path().join("known_hosts");
trust_server(server.port, &known_hosts);
let session = password_session(server.port);
let host = password_host(server.port);
let keychain = Arc::new(InMemorySecretStore::default());
keychain
.set(&session.password_secret(), TEST_PASSWORD)
.set(&host.password_secret(), TEST_PASSWORD)
.unwrap();
let (result, stdout, stderr) = run_cli_command(&session, &known_hosts, keychain);
let (result, stdout, stderr) = run_cli_command(&host, &known_hosts, keychain);
assert_eq!(result, Ok(crate::ssh::ExecExit::Code(3)));
assert_eq!(stdout, b"out");
assert_eq!(stderr, b"err");
@@ -1050,13 +1050,13 @@ mod tests {
};
let directory = tempfile::tempdir().unwrap();
let known_hosts = directory.path().join("known_hosts");
let session = password_session(server.port);
let host = password_host(server.port);
let keychain = Arc::new(InMemorySecretStore::default());
keychain
.set(&session.password_secret(), TEST_PASSWORD)
.set(&host.password_secret(), TEST_PASSWORD)
.unwrap();
let (result, stdout, _) = run_cli_command(&session, &known_hosts, keychain);
let (result, stdout, _) = run_cli_command(&host, &known_hosts, keychain);
let error = result.unwrap_err();
assert_eq!(error.kind, crate::ssh::ExecErrorKind::HostKeyUnknown);
assert!(error.message.contains("请先在 ShellRS 中连接一次"));
@@ -1074,10 +1074,10 @@ mod tests {
crate::ssh::SshConnectionTester::new(connector).test(request, Box::new(|_| true))
}
fn credential(kind: crate::session::CredentialKind) -> crate::session::Credential {
crate::session::Credential::new(
crate::session::CredentialId(1),
crate::session::CredentialDraft::new("运维", kind, "tester"),
fn credential(kind: crate::host::CredentialKind) -> crate::host::Credential {
crate::host::Credential::new(
crate::host::CredentialId(1),
crate::host::CredentialDraft::new("运维", kind, "tester"),
)
}
@@ -1088,7 +1088,7 @@ mod tests {
return;
};
let directory = tempfile::tempdir().unwrap();
let credential = credential(crate::session::CredentialKind::Password);
let credential = credential(crate::host::CredentialKind::Password);
let keychain = Arc::new(InMemorySecretStore::default());
keychain
.set(&credential.password_secret(), TEST_PASSWORD)
@@ -1100,7 +1100,7 @@ mod tests {
"wrong",
)
.unwrap();
let login = SessionLogin::with_credential("127.0.0.1", server.port, &credential);
let login = HostLogin::with_credential("127.0.0.1", server.port, &credential);
let connector = SshConnector::new(directory.path().join("known_hosts"), keychain);
assert_eq!(
@@ -1163,10 +1163,10 @@ mod tests {
/// `agent`.
#[cfg(unix)]
fn agent_login(port: u16, agent: std::path::PathBuf, known_hosts: &Path) -> Result<(), String> {
let login = SessionLogin::with_credential(
let login = HostLogin::with_credential(
"127.0.0.1",
port,
&credential(crate::session::CredentialKind::Agent),
&credential(crate::host::CredentialKind::Agent),
);
let connector = SshConnector::new(known_hosts, Arc::new(NoSecretStore))
.with_agent(crate::ssh::AgentLocation::At(agent));
@@ -1204,7 +1204,7 @@ mod tests {
};
let directory = tempfile::tempdir().unwrap();
let agent = start_agent(&[key]);
let login = SessionLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword);
let login = HostLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword);
let connector = SshConnector::new(
directory.path().join("known_hosts"),
Arc::new(NoSecretStore),
@@ -1267,7 +1267,7 @@ mod tests {
directory.path().join("known_hosts"),
Arc::new(NoSecretStore),
);
let login = SessionLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword);
let login = HostLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword);
// Refused outright: the connection test cannot ask, and would say a
// password was missing if this login tried to.
assert_eq!(
@@ -1285,10 +1285,10 @@ mod tests {
let directory = tempfile::tempdir().unwrap();
let known_hosts = directory.path().join("known_hosts");
trust_server(server.port, &known_hosts);
let session = password_session(server.port);
let host = password_host(server.port);
let (result, _, _) = run_cli_command(
&session,
&host,
&known_hosts,
Arc::new(InMemorySecretStore::default()),
);
@@ -1462,13 +1462,11 @@ mod tests {
};
let directory = tempfile::tempdir().unwrap();
let known_hosts = directory.path().join("known_hosts");
let session = password_session(server.port);
let host = password_host(server.port);
let secrets = Arc::new(InMemorySecretStore::default());
secrets
.set(&session.password_secret(), TEST_PASSWORD)
.unwrap();
secrets.set(&host.password_secret(), TEST_PASSWORD).unwrap();
let report = connect_with_secrets(session, &known_hosts, secrets, |kind| match kind {
let report = connect_with_secrets(host, &known_hosts, secrets, |kind| match kind {
ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave,
other => panic!("unexpected prompt: {other:?}"),
});
@@ -1492,17 +1490,13 @@ mod tests {
};
let directory = tempfile::tempdir().unwrap();
let known_hosts = directory.path().join("known_hosts");
let session = password_session(server.port);
let endpoint = session.password_secret();
let host = password_host(server.port);
let endpoint = host.password_secret();
let secrets = Arc::new(InMemorySecretStore::default());
secrets.set(&endpoint, "stale-password").unwrap();
let report = connect_with_secrets(
session,
&known_hosts,
secrets.clone(),
trust_and_type_password,
);
let report =
connect_with_secrets(host, &known_hosts, secrets.clone(), trust_and_type_password);
let instructions = report
.prompts
@@ -1539,7 +1533,7 @@ mod tests {
let known_hosts = directory.path().join("known_hosts");
let report = connect_and_probe(
password_session(server.port),
password_host(server.port),
&known_hosts,
trust_and_type_password,
);
@@ -1576,7 +1570,7 @@ mod tests {
let known_hosts = directory.path().join("known_hosts");
let report = connect_and_probe(
password_session(server.port),
password_host(server.port),
&known_hosts,
trust_and_type_password,
);
@@ -1595,7 +1589,7 @@ mod tests {
}
#[test]
fn a_host_that_answers_nothing_leaves_the_session_unmarked() {
fn a_host_that_answers_nothing_leaves_the_host_unmarked() {
let Some(server) = start_server(TestAuth::Password) else {
eprintln!(
"loopback sockets are unavailable in this sandbox; skipping integration body"
@@ -1606,7 +1600,7 @@ mod tests {
let known_hosts = directory.path().join("known_hosts");
let report = connect_and_probe(
password_session(server.port),
password_host(server.port),
&known_hosts,
trust_and_type_password,
);
@@ -1639,17 +1633,17 @@ mod tests {
client_key
.write_openssh_file(&key_path, russh::keys::ssh_key::LineEnding::LF)
.unwrap();
let key_credential = crate::session::Credential::new(
crate::session::CredentialId(1),
crate::session::CredentialDraft::new(
let key_credential = crate::host::Credential::new(
crate::host::CredentialId(1),
crate::host::CredentialDraft::new(
"key-test",
crate::session::CredentialKind::Key,
crate::host::CredentialKind::Key,
"tester",
)
.with_key_path(key_path.to_string_lossy().into_owned()),
);
connect(
SessionLogin::with_credential("127.0.0.1", key_server.port, &key_credential),
HostLogin::with_credential("127.0.0.1", key_server.port, &key_credential),
&directory.path().join("key-known-hosts"),
Arc::new(NoSecretStore),
false,
@@ -1663,9 +1657,9 @@ mod tests {
let Some(interactive_server) = start_server(TestAuth::KeyboardInteractive) else {
panic!("loopback became unavailable during keyboard-interactive test")
};
let interactive_session = Session::new(
crate::session::SessionId(2),
crate::session::SessionDraft::new(
let interactive_host = Host::new(
crate::host::HostId(2),
crate::host::HostDraft::new(
"interactive-test",
"127.0.0.1",
interactive_server.port,
@@ -1676,7 +1670,7 @@ mod tests {
);
let mut saw_challenge = false;
connect_then_shutdown(
interactive_session,
interactive_host,
&directory.path().join("interactive-known-hosts"),
|prompt| match prompt {
ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave,
@@ -1717,12 +1711,12 @@ mod tests {
&known_hosts,
)
.unwrap();
let session = password_session(server.port);
let host = password_host(server.port);
let provider = SshTerminalTransportProvider::with_connector(SshConnector::new(
&known_hosts,
Arc::new(NoSecretStore),
));
let factory = provider.factory_for(&SessionLogin::of(&session, None));
let factory = provider.factory_for(&HostLogin::of(&host, None));
let (_command_tx, command_rx) = mpsc::channel();
let (event_tx, event_rx) = async_channel::unbounded();
let (done_tx, done_rx) = mpsc::channel();
+1 -1
View File
@@ -16,7 +16,7 @@ use alacritty_terminal::term::{Config, TermMode};
use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, Rgb};
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
use crate::session::HostOs;
use crate::host::HostOs;
use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch};
use super::{
+50 -50
View File
@@ -9,11 +9,11 @@ use gpui_kit::component::{
use gpui_kit::*;
use crate::app::{
CatalogIcon, CenterTab, CloseTerminal, ConnectSession, CopySessionHost, DisconnectTerminal,
EditSession, OpenExplorer, ReconnectTerminal, RenameTerminal,
CatalogIcon, CenterTab, CloseTerminal, ConnectHost, CopyHostAddress, DisconnectTerminal,
EditHost, OpenExplorer, ReconnectTerminal, RenameTerminal,
};
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
use crate::session::{HostOs, SessionId, SessionStore};
use crate::host::{HostId, HostOs, HostStore};
use crate::shared::{ClosableTabTitle, HostMark, RenamableTab, close_tab_items};
use super::{
@@ -23,19 +23,19 @@ use super::{
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TerminalPanelEvent {
Activated(RemoteTerminalId, SessionId),
Closed(RemoteTerminalId, SessionId),
StatusChanged(RemoteTerminalId, SessionId),
PromptRequested(RemoteTerminalId, SessionId, ConnectionPrompt),
HostOsDetected(SessionId, HostOs),
Activated(RemoteTerminalId, HostId),
Closed(RemoteTerminalId, HostId),
StatusChanged(RemoteTerminalId, HostId),
PromptRequested(RemoteTerminalId, HostId, ConnectionPrompt),
HostOsDetected(HostId, HostOs),
}
/// A remote-session Dock panel backed by the shared terminal engine. The
/// transport is created from the latest saved session on every connection.
/// A remote-host Dock panel backed by the shared terminal engine. The
/// transport is created from the latest saved host on every connection.
pub struct TerminalPanel {
id: RemoteTerminalId,
session_id: SessionId,
store: Entity<SessionStore>,
host_id: HostId,
store: Entity<HostStore>,
terminal: Entity<TerminalView>,
remote_provider: SharedRemoteTerminalTransportProvider,
tab_group: Option<WeakEntity<TabGroup>>,
@@ -48,36 +48,36 @@ pub struct TerminalPanel {
impl TerminalPanel {
pub fn new(
id: RemoteTerminalId,
session_id: SessionId,
store: Entity<SessionStore>,
host_id: HostId,
store: Entity<HostStore>,
remote_provider: SharedRemoteTerminalTransportProvider,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let (session, login) = {
let (host, login) = {
let store = store.read(cx);
let session = store
.session(session_id)
let host = store
.host(host_id)
.cloned()
.expect("terminal sessions must exist in the store");
let login = store.login_of(&session);
(session, login)
.expect("terminal hosts must exist in the store");
let login = store.login_of(&host);
(host, login)
};
let terminal = cx.new(|cx| {
let mut terminal = TerminalView::new(
("terminal", id.0),
format!("{} 的终端", session.name),
format!("{} 的终端", host.name),
remote_provider.factory_for(&login),
window,
cx,
);
terminal.set_menu_items(connection_menu_items(id, session_id));
terminal.set_menu_items(connection_menu_items(id, host_id));
terminal
});
let subscriptions = vec![
cx.observe(&store, |_, _, cx| cx.notify()),
cx.observe(&terminal, |this, _, cx| {
cx.emit(TerminalPanelEvent::StatusChanged(this.id, this.session_id));
cx.emit(TerminalPanelEvent::StatusChanged(this.id, this.host_id));
cx.notify();
}),
cx.subscribe(
@@ -86,12 +86,12 @@ impl TerminalPanel {
TerminalEvent::PromptRequested(prompt) => {
cx.emit(TerminalPanelEvent::PromptRequested(
this.id,
this.session_id,
this.host_id,
prompt.clone(),
));
}
TerminalEvent::HostOsDetected(os) => {
cx.emit(TerminalPanelEvent::HostOsDetected(this.session_id, *os));
cx.emit(TerminalPanelEvent::HostOsDetected(this.host_id, *os));
}
},
),
@@ -99,7 +99,7 @@ impl TerminalPanel {
Self {
id,
session_id,
host_id,
store,
terminal,
remote_provider,
@@ -113,8 +113,8 @@ impl TerminalPanel {
self.id
}
pub fn session_id(&self) -> SessionId {
self.session_id
pub fn host_id(&self) -> HostId {
self.host_id
}
pub fn tab_group(&self) -> Option<WeakEntity<TabGroup>> {
@@ -124,12 +124,12 @@ impl TerminalPanel {
fn tab_menu(&self, cx: &Context<Self>) -> TabMenu {
TabMenu {
id: self.id,
session_id: self.session_id,
host_id: self.host_id,
host_is_ip: self
.store
.read(cx)
.session(self.session_id)
.is_some_and(|session| session.host_is_ip()),
.host(self.host_id)
.is_some_and(|host| host.address_is_ip()),
group: self.tab_group.clone(),
panel: cx.entity_id(),
}
@@ -149,7 +149,7 @@ impl TerminalPanel {
}
pub fn reconnect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(login) = self.store.read(cx).login(self.session_id) else {
let Some(login) = self.store.read(cx).login(self.host_id) else {
return;
};
let factory = self.remote_provider.factory_for(&login);
@@ -202,7 +202,7 @@ impl BasePanel for TerminalPanel {
if !active {
return;
}
let id = self.session_id;
let id = self.host_id;
self.store
.update(cx, |store, cx| store.set_active(Some(id), cx));
let focus = self.terminal.read(cx).focus_handle();
@@ -218,7 +218,7 @@ impl BasePanel for TerminalPanel {
self.tab_group = None;
self.terminal
.update(cx, |terminal, cx| terminal.shutdown(cx));
let id = self.session_id;
let id = self.host_id;
cx.emit(TerminalPanelEvent::Closed(self.id, id));
}
}
@@ -229,8 +229,8 @@ impl Panel for TerminalPanel {
let os = self
.store
.read(cx)
.session(self.session_id)
.and_then(|session| session.os);
.host(self.host_id)
.and_then(|host| host.os);
let mark = HostMark::new(("terminal-tab-os", id.0), self.default_title(cx), os).small();
let tab_menu = self.tab_menu(cx);
ClosableTabTitle::new(("terminal-tab", id.0), mark, self.tab_title(cx))
@@ -268,7 +268,7 @@ impl Panel for TerminalPanel {
}
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
let session_id = self.session_id;
let host_id = self.host_id;
let terminal_id = self.id;
Some(vec![
Button::new(("sftp", terminal_id.0))
@@ -276,7 +276,7 @@ impl Panel for TerminalPanel {
.label("SFTP")
.tooltip("打开 SFTP 文件浏览")
.on_click(move |_, window, cx| {
window.dispatch_action(Box::new(OpenExplorer(session_id)), cx)
window.dispatch_action(Box::new(OpenExplorer(host_id)), cx)
}),
Button::new(("reconnect", terminal_id.0))
.icon(Icon::new(CatalogIcon::RefreshCw))
@@ -302,7 +302,7 @@ impl Panel for TerminalPanel {
}
/// The connection commands at the bottom of a remote terminal's context menu.
fn connection_menu_items(id: RemoteTerminalId, session_id: SessionId) -> TerminalMenuItems {
fn connection_menu_items(id: RemoteTerminalId, host_id: HostId) -> TerminalMenuItems {
Rc::new(move |menu, lifecycle| {
let connected = matches!(
lifecycle,
@@ -311,7 +311,7 @@ fn connection_menu_items(id: RemoteTerminalId, session_id: SessionId) -> Termina
menu.menu_with_icon(
"打开 SFTP",
Icon::new(CatalogIcon::FolderTree),
Box::new(OpenExplorer(session_id)),
Box::new(OpenExplorer(host_id)),
)
.menu_with_icon(
"重新连接",
@@ -333,22 +333,22 @@ fn connection_menu_items(id: RemoteTerminalId, session_id: SessionId) -> Termina
#[derive(Clone)]
struct TabMenu {
id: RemoteTerminalId,
session_id: SessionId,
host_id: HostId,
host_is_ip: bool,
group: Option<WeakEntity<TabGroup>>,
panel: EntityId,
}
impl RenamableTab for TerminalPanel {
/// The session's name, as the tab shows it without a title of its own.
/// The host's name, as the tab shows it without a title of its own.
fn default_title(&self, cx: &App) -> SharedString {
self.store
.read(cx)
.session(self.session_id)
.map(|session| session.name.clone())
.host(self.host_id)
.map(|host| host.name.clone())
.unwrap_or_else(|| "终端".into())
}
/// The tab's label: its own title when it has one, else the session name.
/// The tab's label: its own title when it has one, else the host name.
fn tab_title(&self, cx: &App) -> SharedString {
self.custom_title
.clone()
@@ -362,7 +362,7 @@ impl RenamableTab for TerminalPanel {
impl TabMenu {
fn build(&self, menu: PopupMenu, cx: &App) -> PopupMenu {
let (id, session_id) = (self.id, self.session_id);
let (id, host_id) = (self.id, self.host_id);
let copy_host = if self.host_is_ip {
"复制 IP 地址"
} else {
@@ -377,17 +377,17 @@ impl TabMenu {
.menu_with_icon(
"在新标签页中连接",
Icon::new(CatalogIcon::Plug),
Box::new(ConnectSession(session_id)),
Box::new(ConnectHost(host_id)),
)
.menu_with_icon(
"打开 SFTP",
Icon::new(CatalogIcon::FolderTree),
Box::new(OpenExplorer(session_id)),
Box::new(OpenExplorer(host_id)),
)
.menu_with_icon(
copy_host,
Icon::new(IconName::Copy),
Box::new(CopySessionHost(session_id)),
Box::new(CopyHostAddress(host_id)),
)
.menu_with_icon(
"重新连接",
@@ -397,7 +397,7 @@ impl TabMenu {
.menu_with_icon(
"编辑主机…",
Icon::new(CatalogIcon::Pencil),
Box::new(EditSession(session_id)),
Box::new(EditHost(host_id)),
)
.separator();
close_tab_items(
+5 -5
View File
@@ -5,7 +5,7 @@ use async_channel::Sender;
use super::{Latency, TerminalSize};
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
use crate::session::{HostOs, SessionLogin};
use crate::host::{HostLogin, HostOs};
/// Commands accepted by every terminal byte-stream transport.
pub enum TerminalTransportCommand {
@@ -68,16 +68,16 @@ pub trait TerminalTransportFactory: Send + Sync + 'static {
fn create(&self) -> Box<dyn TerminalTransport>;
}
/// Creates a fresh remote factory from the latest saved login of a session.
/// Creates a fresh remote factory from the latest saved login of a host.
/// Keeping this boundary at the workspace makes reconnects pick up edits
/// immediately and lets UI tests inject a deterministic fake.
pub trait RemoteTerminalTransportProvider: Send + Sync + 'static {
fn factory_for(&self, login: &SessionLogin) -> SharedTerminalTransportFactory;
fn factory_for(&self, login: &HostLogin) -> SharedTerminalTransportFactory;
}
pub type SharedRemoteTerminalTransportProvider = Arc<dyn RemoteTerminalTransportProvider>;
/// Provider useful for tests and embedders that want every session to use the
/// Provider useful for tests and embedders that want every host to use the
/// same factory.
pub struct FixedRemoteTerminalTransportProvider {
factory: SharedTerminalTransportFactory,
@@ -90,7 +90,7 @@ impl FixedRemoteTerminalTransportProvider {
}
impl RemoteTerminalTransportProvider for FixedRemoteTerminalTransportProvider {
fn factory_for(&self, _: &SessionLogin) -> SharedTerminalTransportFactory {
fn factory_for(&self, _: &HostLogin) -> SharedTerminalTransportFactory {
self.factory.clone()
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ use crate::app::{
NewCredential, ShowCredentials,
};
use crate::credential::{CredentialDialog, open_credential_dialog};
use crate::session::{AuthKind, CredentialKind, read_public_key};
use crate::host::{AuthKind, CredentialKind, read_public_key};
use crate::shared::confirm_delete;
use super::{sidebar::SidebarMode, workspace_view::Workspace};
@@ -117,7 +117,7 @@ impl Workspace {
else {
return;
};
let hosts = store.sessions_using(id).count();
let hosts = store.hosts_using(id).count();
let store = self.store.clone();
confirm_delete(
&name,
@@ -147,7 +147,7 @@ fn describe_credential_delete(hosts: usize, kind: CredentialKind) -> Option<Shar
#[cfg(test)]
mod tests {
use super::describe_credential_delete;
use crate::session::CredentialKind;
use crate::host::CredentialKind;
#[test]
fn deleting_a_credential_says_how_many_hosts_and_what_they_become() {
+10 -10
View File
@@ -7,10 +7,10 @@ use gpui_kit::component::{WindowExt as _, dock::DockPlacement, notification::Not
use gpui_kit::*;
use crate::app::{
DeleteForward, EditForward, NewForward, ShowForwards, ShowSessions, StartForward, StopForward,
DeleteForward, EditForward, NewForward, ShowForwards, ShowHosts, StartForward, StopForward,
};
use crate::forward::{ForwardManagerEvent, open_forward_dialog};
use crate::session::ForwardId;
use crate::host::ForwardId;
use crate::shared::confirm_delete;
use super::{
@@ -45,13 +45,13 @@ impl Workspace {
cx.notify();
}
pub(super) fn on_show_sessions(
pub(super) fn on_show_hosts(
&mut self,
_: &ShowSessions,
_: &ShowHosts,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.show_sidebar(SidebarMode::Sessions, window, cx);
self.show_sidebar(SidebarMode::Hosts, window, cx);
}
pub(super) fn on_show_forwards(
@@ -70,7 +70,7 @@ impl Workspace {
cx: &mut Context<Self>,
) {
let store = self.store.read(cx);
if store.sessions().is_empty() {
if store.hosts().is_empty() {
window.push_notification(
Notification::warning("端口转发经由一台主机的 SSH 连接,请先新建主机。"),
cx,
@@ -159,10 +159,10 @@ impl Workspace {
// The title bar counts the forwards that are running.
cx.notify();
}
ForwardManagerEvent::PromptRequested(id, session, generation, prompt) => self
ForwardManagerEvent::PromptRequested(id, host, generation, prompt) => self
.enqueue_prompt(
PromptOwner::Forward(*id, *generation),
*session,
*host,
prompt.clone(),
window,
cx,
@@ -211,11 +211,11 @@ impl Workspace {
pub(super) fn forward_prompt_origin(&self, id: ForwardId, cx: &App) -> Option<String> {
let store = self.store.read(cx);
let rule = store.forward(id)?;
let session = store.session(rule.session)?;
let host = store.host(rule.host)?;
Some(format!(
"端口转发“{}”正在通过 {} 连接。",
rule.title(),
session.name
host.name
))
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
//! The window shell: title bar, dock area with its sidebar, status bar, and
//! the start page (recent sessions) the center shows while no tab is open.
//! the start page (recent hosts) the center shows while no tab is open.
mod credentials;
mod dock_skin;
mod forwards;
mod recent_sessions;
mod recent_hosts;
mod sidebar;
mod status_bar;
mod title_bar;
@@ -7,34 +7,32 @@ use gpui_kit::component::{
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{
CatalogIcon, ConnectSelected, ConnectSession, NewSession, RECENT_SESSIONS_CONTEXT,
};
use crate::session::{HostOs, SessionId, SessionStore, session_menu};
use crate::app::{CatalogIcon, ConnectHost, ConnectSelected, NewHost, RECENT_HOSTS_CONTEXT};
use crate::host::{HostId, HostOs, HostStore, host_menu};
use crate::shared::HostMark;
/// The center's empty state: shown in place of the tabs while none is open.
///
/// Lists the sessions most recently connected, newest first, so the next
/// Lists the hosts most recently connected, newest first, so the next
/// connection is a double click away. Not a dock panel: the workspace's dock skin
/// lays it over the empty center, and it goes away as soon as a tab opens.
pub struct RecentSessions {
store: Entity<SessionStore>,
pub struct RecentHosts {
store: Entity<HostStore>,
/// The workspace's focus handle: actions dispatched on it reach the
/// workspace handlers whatever is focused.
target: FocusHandle,
focus_handle: FocusHandle,
scroll_handle: ScrollHandle,
selected: Option<SessionId>,
selected: Option<HostId>,
/// The row a right click landed on, for the menu about to open. Blank
/// space leaves it empty, and an empty menu does not open.
menu_hit: Rc<Cell<Option<SessionId>>>,
menu_hit: Rc<Cell<Option<HostId>>>,
_subscription: Subscription,
}
/// One row of the list: a plain snapshot taken from the store in `render`.
struct RecentRow {
id: SessionId,
id: HostId,
name: SharedString,
address: String,
group: Option<SharedString>,
@@ -42,8 +40,8 @@ struct RecentRow {
os: Option<HostOs>,
}
impl RecentSessions {
pub fn new(store: Entity<SessionStore>, target: FocusHandle, cx: &mut Context<Self>) -> Self {
impl RecentHosts {
pub fn new(store: Entity<HostStore>, target: FocusHandle, cx: &mut Context<Self>) -> Self {
let subscription = cx.observe(&store, |_, _, cx| cx.notify());
Self {
store,
@@ -66,18 +64,18 @@ impl RecentSessions {
fn rows(&self, cx: &App) -> Vec<RecentRow> {
let store = self.store.read(cx);
store
.recent_sessions()
.map(|session| RecentRow {
id: session.id,
name: session.name.clone(),
address: session.address(),
.recent_hosts()
.map(|host| RecentRow {
id: host.id,
name: host.name.clone(),
address: host.endpoint(),
// The full path, so two 数据库 groups under different
// parents do not read the same here.
group: session
group: host
.group
.map(|id| SharedString::from(store.group_path(id))),
connected: session.state.is_connected(),
os: session.os,
connected: host.state.is_connected(),
os: host.os,
})
.collect()
}
@@ -105,10 +103,10 @@ impl RecentSessions {
),
)
.child(
Button::new("recent-new-session")
Button::new("recent-new-host")
.icon(IconName::Plus)
.label("新建主机…")
.on_click(move |_, window, cx| target.dispatch_action(&NewSession, window, cx)),
.on_click(move |_, window, cx| target.dispatch_action(&NewHost, window, cx)),
)
}
@@ -131,11 +129,11 @@ impl RecentSessions {
cx: &mut Context<Self>,
) {
if let Some(id) = self.selected {
self.target.dispatch_action(&ConnectSession(id), window, cx);
self.target.dispatch_action(&ConnectHost(id), window, cx);
}
}
/// The rows, with the session menu of the session tree. Like the tree's,
/// The rows, with the host menu of the host tree. Like the tree's,
/// the menu hangs off the list rather than off each row: capture clears
/// the hit before a right-clicked row writes itself back, and the menu
/// is built after both.
@@ -143,12 +141,12 @@ impl RecentSessions {
let clear_hit = self.menu_hit.clone();
let menu_hit = self.menu_hit.clone();
v_flex()
.id("recent-session-list")
.id("recent-host-list")
.gap_1()
.capture_any_mouse_down(move |_, _, _| clear_hit.set(None))
.children(rows.into_iter().map(|row| self.render_row(row, cx)))
.context_menu(move |menu, _, _| match menu_hit.get() {
Some(id) => session_menu(menu, id),
Some(id) => host_menu(menu, id),
None => menu,
})
}
@@ -157,7 +155,7 @@ impl RecentSessions {
let muted = cx.theme().muted_foreground;
let id = row.id;
let selected = self.selected == Some(id);
ListItem::new(("recent-session", id.0))
ListItem::new(("recent-host", id.0))
.w_full()
.px_3()
.py_2()
@@ -168,7 +166,7 @@ impl RecentSessions {
h_flex()
.gap_3()
.child(HostMark::new(
("recent-session-os", id.0),
("recent-host-os", id.0),
row.name.clone(),
row.os,
))
@@ -222,27 +220,27 @@ impl RecentSessions {
this.focus_handle.focus(window, cx);
cx.notify();
if event.click_count() == 2 {
this.target.dispatch_action(&ConnectSession(id), window, cx);
this.target.dispatch_action(&ConnectHost(id), window, cx);
}
}))
}
}
impl Focusable for RecentSessions {
impl Focusable for RecentHosts {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RecentSessions {
impl Render for RecentHosts {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rows = self.rows(cx);
div()
.id("recent-sessions")
.id("recent-hosts")
.test_support()
.track_focus(&self.focus_handle)
.key_context(RECENT_SESSIONS_CONTEXT)
.key_context(RECENT_HOSTS_CONTEXT)
.on_action(cx.listener(Self::on_connect_selected))
.size_full()
.overflow_y_scroll()
+14 -14
View File
@@ -9,18 +9,18 @@ use gpui_kit::*;
use crate::app::{CatalogIcon, OpenSettings};
use crate::credential::CredentialPanel;
use crate::forward::ForwardPanel;
use crate::session::SessionPanel;
use crate::host::HostPanel;
/// Which list the left dock is showing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SidebarMode {
#[default]
Sessions,
Hosts,
Forwards,
Credentials,
}
/// The left dock's one panel: the session tree, the port-forwarding list or
/// The left dock's one panel: the host tree, the port-forwarding list or
/// the credential list, whichever the title bar's switch picked, over the
/// 设置 button they share.
///
@@ -31,20 +31,20 @@ pub enum SidebarMode {
/// and its scroll position.
pub struct Sidebar {
mode: SidebarMode,
sessions: Entity<SessionPanel>,
hosts: Entity<HostPanel>,
forwards: Entity<ForwardPanel>,
credentials: Entity<CredentialPanel>,
}
impl Sidebar {
pub fn new(
sessions: Entity<SessionPanel>,
hosts: Entity<HostPanel>,
forwards: Entity<ForwardPanel>,
credentials: Entity<CredentialPanel>,
) -> Self {
Self {
mode: SidebarMode::default(),
sessions,
hosts,
forwards,
credentials,
}
@@ -67,8 +67,8 @@ impl Sidebar {
/// Put the keyboard in the search field of the list being shown.
pub fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
match self.mode {
SidebarMode::Sessions => self
.sessions
SidebarMode::Hosts => self
.hosts
.update(cx, |panel, cx| panel.focus_search(window, cx)),
SidebarMode::Forwards => self
.forwards
@@ -85,7 +85,7 @@ impl EventEmitter<PanelEvent> for Sidebar {}
impl Focusable for Sidebar {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match self.mode {
SidebarMode::Sessions => self.sessions.read(cx).focus_handle(cx),
SidebarMode::Hosts => self.hosts.read(cx).focus_handle(cx),
SidebarMode::Forwards => self.forwards.read(cx).focus_handle(cx),
SidebarMode::Credentials => self.credentials.read(cx).focus_handle(cx),
}
@@ -110,8 +110,8 @@ impl BasePanel for Sidebar {
impl Panel for Sidebar {
fn title(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
match self.mode {
SidebarMode::Sessions => self
.sessions
SidebarMode::Hosts => self
.hosts
.update(cx, |panel, cx| panel.title(window, cx).into_any_element()),
SidebarMode::Forwards => self.forwards.read(cx).title().into_any_element(),
SidebarMode::Credentials => self.credentials.read(cx).title().into_any_element(),
@@ -124,8 +124,8 @@ impl Panel for Sidebar {
cx: &mut Context<Self>,
) -> Option<Vec<Button>> {
match self.mode {
SidebarMode::Sessions => self
.sessions
SidebarMode::Hosts => self
.hosts
.update(cx, |panel, cx| panel.toolbar_buttons(window, cx)),
SidebarMode::Forwards => Some(self.forwards.read(cx).toolbar_buttons()),
SidebarMode::Credentials => Some(self.credentials.read(cx).toolbar_buttons()),
@@ -144,7 +144,7 @@ impl Panel for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let list = match self.mode {
SidebarMode::Sessions => self.sessions.clone().into_any_element(),
SidebarMode::Hosts => self.hosts.clone().into_any_element(),
SidebarMode::Forwards => self.forwards.clone().into_any_element(),
SidebarMode::Credentials => self.credentials.clone().into_any_element(),
};
+14 -19
View File
@@ -7,7 +7,7 @@ use gpui_kit::*;
use crate::app::CatalogIcon;
use crate::explorer::ExplorerStatus;
use crate::session::{ConnectionState, Session};
use crate::host::{ConnectionState, Host};
use crate::terminal::{TerminalLifecycle, TerminalStatus};
/// The active connection/process state on the left and terminal facts on the
@@ -15,12 +15,12 @@ use crate::terminal::{TerminalLifecycle, TerminalStatus};
/// no terminal in front there is no size to show.
#[derive(IntoElement)]
pub enum WorkspaceStatus {
/// The active session, with the terminal in front when that is one of
/// The active host, with the terminal in front when that is one of
/// its terminals: the start page and the settings have none.
Session(Option<Session>, Option<TerminalStatus>),
Host(Option<Host>, Option<TerminalStatus>),
Local(TerminalStatus),
/// An SFTP tab: its own connection and what went wrong in it.
Explorer(Option<Session>, ExplorerStatus),
Explorer(Option<Host>, ExplorerStatus),
}
/// The icon for a connection state; a dropped one, like a problem, in red.
@@ -50,13 +50,13 @@ impl RenderOnce for WorkspaceStatus {
Option<String>,
Option<TerminalStatus>,
) = match self {
WorkspaceStatus::Session(active, terminal) => match active {
Some(session) => {
alarming = session.state == ConnectionState::Disconnected;
WorkspaceStatus::Host(active, terminal) => match active {
Some(host) => {
alarming = host.state == ConnectionState::Disconnected;
(
format!("{} {}", session.state.label(), session.name).into(),
state_icon(session.state, cx),
Some(session.address()),
format!("{} {}", host.state.label(), host.name).into(),
state_icon(host.state, cx),
Some(host.endpoint()),
terminal,
)
}
@@ -67,10 +67,10 @@ impl RenderOnce for WorkspaceStatus {
terminal,
),
},
WorkspaceStatus::Explorer(session, status) => {
let name = session
WorkspaceStatus::Explorer(host, status) => {
let name = host
.as_ref()
.map_or_else(|| "SFTP".into(), |session| session.name.clone());
.map_or_else(|| "SFTP".into(), |host| host.name.clone());
let state = format!("{} {name}", status.state.label());
let (text, icon) = match (status.state, status.problem) {
(ConnectionState::Disconnected, Some(problem)) => {
@@ -90,12 +90,7 @@ impl RenderOnce for WorkspaceStatus {
(state, state_icon(now, cx))
}
};
(
text.into(),
icon,
session.map(|session| session.address()),
None,
)
(text.into(), icon, host.map(|host| host.endpoint()), None)
}
WorkspaceStatus::Local(status) => {
let lifecycle = status.lifecycle();
+10 -10
View File
@@ -8,8 +8,8 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{
CatalogIcon, NewLocalTerminal, NewSession, ShowCredentials, ShowForwards, ShowSessions,
ShowUpdate, ToggleSessionPanel, ToggleTheme,
CatalogIcon, NewHost, NewLocalTerminal, ShowCredentials, ShowForwards, ShowHosts, ShowUpdate,
ToggleHostPanel, ToggleTheme,
};
use crate::update::UpdateBadge;
@@ -31,7 +31,7 @@ pub fn render_title_bar(
cx: &App,
) -> TitleBar {
let dark = cx.theme().is_dark();
let (mode, toggle, new_session, new_local, theme, show_update) = (
let (mode, toggle, new_host, new_local, theme, show_update) = (
target.clone(),
target.clone(),
target.clone(),
@@ -60,11 +60,11 @@ pub fn render_title_bar(
.ghost()
.small()
.child(
Button::new("show-sessions")
Button::new("show-hosts")
.icon(CatalogIcon::Server)
.tooltip("主机")
.accessibility_label("主机")
.selected(sidebar == Some(SidebarMode::Sessions)),
.selected(sidebar == Some(SidebarMode::Hosts)),
)
.child(
Button::new("show-forwards")
@@ -91,7 +91,7 @@ pub fn render_title_bar(
)
.on_click(move |picked: &Vec<usize>, window, cx| {
match picked.first() {
Some(0) => mode.dispatch_action(&ShowSessions, window, cx),
Some(0) => mode.dispatch_action(&ShowHosts, window, cx),
Some(1) => mode.dispatch_action(&ShowForwards, window, cx),
Some(2) => mode.dispatch_action(&ShowCredentials, window, cx),
_ => {}
@@ -107,7 +107,7 @@ pub fn render_title_bar(
.gap_1()
.on_mouse_down(MouseButton::Left, no_drag)
.child(
Button::new("toggle-sessions")
Button::new("toggle-hosts")
.ghost()
.small()
.icon(IconName::PanelLeft)
@@ -117,17 +117,17 @@ pub fn render_title_bar(
"显示侧栏"
})
.on_click(move |_, window, cx| {
toggle.dispatch_action(&ToggleSessionPanel, window, cx)
toggle.dispatch_action(&ToggleHostPanel, window, cx)
}),
)
.child(
Button::new("new-session")
Button::new("new-host")
.ghost()
.small()
.icon(IconName::Plus)
.label("新建主机…")
.on_click(move |_, window, cx| {
new_session.dispatch_action(&NewSession, window, cx)
new_host.dispatch_action(&NewHost, window, cx)
}),
)
.child(
+1 -1
View File
@@ -7,7 +7,7 @@ use gpui_kit::component::{WindowExt as _, notification::Notification};
use gpui_kit::*;
use crate::app::{CheckForUpdates, DownloadUpdate, OpenDownloadPage, RestartToUpdate, ShowUpdate};
use crate::session::ConnectionState;
use crate::host::ConnectionState;
use crate::terminal::TerminalLifecycle;
use crate::update::{RestartImpact, UpdaterEvent, open_update_dialog};
+177 -202
View File
@@ -20,15 +20,15 @@ use gpui_kit::*;
use crate::app::{
CenterTab, ClearTerminal, CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseSettings,
CloseTabs, CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectSession, CopyAgentSkill,
CopySessionHost, CopySessionId, CopyTerminal, DeleteGroup, DeleteSession, DisconnectSession,
DisconnectTerminal, DismissTerminalFind, DuplicateSession, EditSession, ExpandAllGroups,
CloseTabs, CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectHost, CopyAgentSkill,
CopyHostAddress, CopyHostId, CopyTerminal, DeleteGroup, DeleteHost, DisconnectHost,
DisconnectTerminal, DismissTerminalFind, DuplicateHost, EditHost, ExpandAllGroups,
ExplorerAction, ExplorerCommand, ExplorerShortcut, FindInTerminal, FindNextInTerminal,
FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand, MoveSessionNode,
NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer,
OpenSettings, PasteTerminal, ReconnectTerminal, RefreshCliIntegration, RemoveAgentSkill,
RemoveCliCommand, RenameExplorer, RenameGroup, RenameTerminal, RestartLocalTerminal,
SetFileSizeFormat, ToggleSessionPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand, MoveHostNode,
NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal, OpenExplorer, OpenSettings,
PasteTerminal, ReconnectTerminal, RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand,
RenameExplorer, RenameGroup, RenameTerminal, RestartLocalTerminal, SetFileSizeFormat,
ToggleHostPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
};
use crate::cli::{CliIntegration, CliServer, CliTarget, IntegrationPaths, SshCliBackend};
use crate::connection::{
@@ -41,10 +41,9 @@ use crate::forward::{
ForwardManager, ForwardManagerEvent, ForwardPanel, SharedForwardTransportProvider,
SshForwardTransportProvider,
};
use crate::session::{
ConnectionState, Dependents, ForwardId, GroupId, SessionId, SessionNode, SessionPanel,
SessionStore, SessionStoreEvent, confirm_delete_group, confirm_delete_session,
open_group_dialog, open_session_dialog,
use crate::host::{
ConnectionState, Dependents, ForwardId, GroupId, HostId, HostNode, HostPanel, HostStore,
HostStoreEvent, confirm_delete_group, confirm_delete_host, open_group_dialog, open_host_dialog,
};
use crate::settings::{
Appearance, SettingsPanel, SettingsPanelEvent, SettingsStore, SettingsStoreEvent,
@@ -63,7 +62,7 @@ use crate::terminal::{
use crate::update::{UpdateServices, Updater, UpdaterEvent};
use super::{
dock_skin::WorkspaceDockSkin, recent_sessions::RecentSessions, sidebar::Sidebar,
dock_skin::WorkspaceDockSkin, recent_hosts::RecentHosts, sidebar::Sidebar,
status_bar::WorkspaceStatus, title_bar::render_title_bar,
};
@@ -121,21 +120,21 @@ pub(super) enum PromptOwner {
/// The main window content: title bar above the dock, status bar below.
///
/// Owns the session store, the dock and the registry of open per-session
/// Owns the host store, the dock and the registry of open per-host
/// panels, and handles every application action.
pub struct Workspace {
pub(super) store: Entity<SessionStore>,
pub(super) store: Entity<HostStore>,
pub(super) dock_area: Entity<DockArea>,
skin: Rc<WorkspaceDockSkin>,
/// The left dock's panel: the session tree or the forward list.
/// The left dock's panel: the host tree or the forward list.
pub(super) sidebar: Entity<Sidebar>,
session_panel: Entity<SessionPanel>,
host_panel: Entity<HostPanel>,
/// The port forwards that are running, each on a connection of its own.
pub(super) forwards: Entity<ForwardManager>,
/// The start page the dock skin shows while the center has no tab.
recent: Entity<RecentSessions>,
recent: Entity<RecentHosts>,
pub(super) terminals: HashMap<RemoteTerminalId, Entity<TerminalPanel>>,
/// SFTP tabs; a session can have several, like terminals.
/// SFTP tabs; a host can have several, like terminals.
pub(super) explorers: HashMap<ExplorerId, Entity<ExplorerPanel>>,
pub(super) local_terminals: HashMap<LocalTerminalId, Entity<LocalTerminalPanel>>,
/// The settings tab, while it is open. There is only ever one.
@@ -157,15 +156,15 @@ pub struct Workspace {
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
sftp_provider: SharedSftpTransportProvider,
local_directory_provider: SharedLocalDirectoryProvider,
/// Backs the session dialog's 「测试连接」.
/// Backs the host dialog's 「测试连接」.
connection_tester: SharedConnectionTester,
next_remote_terminal_id: u64,
next_local_terminal_id: u64,
next_explorer_id: u64,
/// The center tab displayed most recently; `CloseActiveTab` closes it.
active_tab: Option<CenterTab>,
pub(super) prompt_queue: VecDeque<(PromptOwner, SessionId, ConnectionPrompt)>,
pub(super) active_prompt: Option<(PromptOwner, SessionId, u64)>,
pub(super) prompt_queue: VecDeque<(PromptOwner, HostId, ConnectionPrompt)>,
pub(super) active_prompt: Option<(PromptOwner, HostId, u64)>,
/// Dispatch target for the title bar and start page: actions sent to it
/// reach the workspace handlers whatever is focused.
pub(super) focus_handle: FocusHandle,
@@ -174,10 +173,10 @@ pub struct Workspace {
impl Workspace {
/// `store` is built by `main` from the database on disk, and by the UI
/// tests from `SessionStore::seed`; `settings` likewise from the settings
/// tests from `HostStore::seed`; `settings` likewise from the settings
/// file, or kept in memory.
pub fn new(
store: Entity<SessionStore>,
store: Entity<HostStore>,
settings: Entity<SettingsStore>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -238,7 +237,7 @@ impl Workspace {
/// Bring the window forward whenever ShellRS is opened while it is
/// already running. The second copy only passes the word on and exits:
/// two of them on one data directory would each keep their own copy of
/// the sessions and write over the other's changes.
/// the hosts and write over the other's changes.
///
/// Asked on a timer, like every other worker: the thread that hears the
/// request never wakes the window itself.
@@ -265,7 +264,7 @@ impl Workspace {
}
/// Tell the CLI server what it may use: whether 启用外部 CLI is on, and
/// the sessions as they are now.
/// the hosts as they are now.
fn sync_cli_server(&self, cx: &App) {
if let Some(server) = &self.cli_server {
server.set_enabled(self.settings.read(cx).settings().external_cli.enabled);
@@ -278,7 +277,7 @@ impl Workspace {
// constructor; bundling them would only move the list elsewhere.
#[allow(clippy::too_many_arguments)]
pub fn new_with_services(
store: Entity<SessionStore>,
store: Entity<HostStore>,
settings: Entity<SettingsStore>,
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
local_terminal_factory: SharedTerminalTransportFactory,
@@ -290,7 +289,7 @@ impl Workspace {
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let recent = cx.new(|cx| RecentSessions::new(store.clone(), focus_handle.clone(), cx));
let recent = cx.new(|cx| RecentHosts::new(store.clone(), focus_handle.clone(), cx));
let (dock_area, skin) = WorkspaceDockSkin::dock_area(
DOCK_ID,
Some(DOCK_VERSION),
@@ -298,7 +297,7 @@ impl Workspace {
window,
cx,
);
let session_panel = cx.new(|cx| SessionPanel::new(store.clone(), window, cx));
let host_panel = cx.new(|cx| HostPanel::new(store.clone(), window, cx));
let forwards = cx.new(|cx| ForwardManager::new(store.clone(), forward_provider, cx));
let automatic = settings.read(cx).settings().update.automatic;
let updater = cx.new(|cx| {
@@ -317,13 +316,12 @@ impl Workspace {
});
let credential_panel =
cx.new(|cx| CredentialPanel::new(store.clone(), focus_handle.clone(), window, cx));
let sidebar =
cx.new(|_| Sidebar::new(session_panel.clone(), forward_panel, credential_panel));
// Start with focus in the session panel so window-level actions have a
let sidebar = cx.new(|_| Sidebar::new(host_panel.clone(), forward_panel, credential_panel));
// Start with focus in the host panel so window-level actions have a
// dispatch path. The workspace's own handle is never focused: the
// dialog layer is its child, and a focused ancestor would keep the
// dialog's focus trap from taking focus.
let panel_focus = session_panel.read(cx).focus_handle(cx);
let panel_focus = host_panel.read(cx).focus_handle(cx);
window.focus(&panel_focus, cx);
// Before the first frame, so a dark theme never starts out light.
crate::settings::apply(settings.read(cx).settings(), window, cx);
@@ -336,11 +334,11 @@ impl Workspace {
cx.subscribe_in(
&store,
window,
|this, _, event: &SessionStoreEvent, window, cx| match event {
SessionStoreEvent::PersistFailed(message) => {
|this, _, event: &HostStoreEvent, window, cx| match event {
HostStoreEvent::PersistFailed(message) => {
window.push_notification(Notification::error(message.clone()), cx);
}
SessionStoreEvent::ConnectionSettingsChanged(id) => {
HostStoreEvent::ConnectionSettingsChanged(id) => {
let panels: Vec<_> = this.terminals_of(*id, cx).cloned().collect();
if !panels.is_empty() {
for panel in &panels {
@@ -355,7 +353,7 @@ impl Workspace {
}
}
// The forward manager restarts the rule itself.
SessionStoreEvent::ForwardSettingsChanged(_) => {}
HostStoreEvent::ForwardSettingsChanged(_) => {}
},
),
cx.subscribe_in(
@@ -401,13 +399,13 @@ impl Workspace {
),
];
// Sessions that start out connected get a terminal tab right away.
let connected: Vec<SessionId> = store
// Hosts that start out connected get a terminal tab right away.
let connected: Vec<HostId> = store
.read(cx)
.sessions()
.hosts()
.iter()
.filter(|session| session.state.is_connected())
.map(|session| session.id)
.filter(|host| host.state.is_connected())
.map(|host| host.id)
.collect();
let mut terminals = HashMap::new();
let mut center = DockLayout::tabs();
@@ -456,7 +454,7 @@ impl Workspace {
dock_area,
skin,
sidebar,
session_panel,
host_panel,
forwards,
recent,
terminals,
@@ -483,15 +481,15 @@ impl Workspace {
}
}
/// The session store, for tests and for panels created later.
pub fn store(&self) -> &Entity<SessionStore> {
/// The host store, for tests and for panels created later.
pub fn store(&self) -> &Entity<HostStore> {
&self.store
}
pub fn terminal(&self, id: SessionId, cx: &App) -> Option<&Entity<TerminalPanel>> {
pub fn terminal(&self, id: HostId, cx: &App) -> Option<&Entity<TerminalPanel>> {
self.terminals
.values()
.filter(|panel| panel.read(cx).session_id() == id)
.filter(|panel| panel.read(cx).host_id() == id)
.max_by_key(|panel| panel.read(cx).id().0)
}
@@ -499,31 +497,31 @@ impl Workspace {
self.terminals.get(&id)
}
pub fn terminal_count(&self, id: SessionId, cx: &App) -> usize {
pub fn terminal_count(&self, id: HostId, cx: &App) -> usize {
self.terminals_of(id, cx).count()
}
/// A session's terminal tabs, in no particular order.
/// A host's terminal tabs, in no particular order.
fn terminals_of<'a>(
&'a self,
session: SessionId,
host: HostId,
cx: &'a App,
) -> impl Iterator<Item = &'a Entity<TerminalPanel>> {
self.terminals
.values()
.filter(move |panel| panel.read(cx).session_id() == session)
.filter(move |panel| panel.read(cx).host_id() == host)
}
pub fn explorer(&self, id: ExplorerId) -> Option<&Entity<ExplorerPanel>> {
self.explorers.get(&id)
}
/// A session's SFTP tabs, oldest first.
pub fn explorers_of(&self, session: SessionId, cx: &App) -> Vec<Entity<ExplorerPanel>> {
/// A host's SFTP tabs, oldest first.
pub fn explorers_of(&self, host: HostId, cx: &App) -> Vec<Entity<ExplorerPanel>> {
let mut explorers: Vec<_> = self
.explorers
.iter()
.filter(|(_, panel)| panel.read(cx).session_id() == session)
.filter(|(_, panel)| panel.read(cx).host_id() == host)
.collect();
explorers.sort_by_key(|(id, _)| id.0);
explorers
@@ -532,15 +530,14 @@ impl Workspace {
.collect()
}
/// Whether a session has any tab open in the center.
fn has_tabs(&self, session: SessionId, cx: &App) -> bool {
self.terminals_of(session, cx).next().is_some()
|| !self.explorers_of(session, cx).is_empty()
/// Whether a host has any tab open in the center.
fn has_tabs(&self, host: HostId, cx: &App) -> bool {
self.terminals_of(host, cx).next().is_some() || !self.explorers_of(host, cx).is_empty()
}
/// How many of a session's SFTP tabs are in the middle of a transfer.
fn transfers_of(&self, session: SessionId, cx: &App) -> usize {
self.explorers_of(session, cx)
/// How many of a host's SFTP tabs are in the middle of a transfer.
fn transfers_of(&self, host: HostId, cx: &App) -> usize {
self.explorers_of(host, cx)
.iter()
.filter(|panel| panel.read(cx).is_transferring())
.count()
@@ -590,13 +587,12 @@ impl Workspace {
pub(super) fn enqueue_prompt(
&mut self,
terminal_id: PromptOwner,
session_id: SessionId,
host_id: HostId,
prompt: ConnectionPrompt,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.prompt_queue
.push_back((terminal_id, session_id, prompt));
self.prompt_queue.push_back((terminal_id, host_id, prompt));
cx.spawn_in(window, async move |this, cx| {
loop {
cx.background_executor()
@@ -623,7 +619,7 @@ impl Workspace {
}
fn open_next_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let (terminal_id, session_id, prompt) = loop {
let (terminal_id, host_id, prompt) = loop {
let Some(next) = self.prompt_queue.pop_front() else {
return;
};
@@ -632,7 +628,7 @@ impl Workspace {
}
};
let request_id = prompt.request_id();
self.active_prompt = Some((terminal_id, session_id, request_id));
self.active_prompt = Some((terminal_id, host_id, request_id));
let origin = match terminal_id {
PromptOwner::Forward(id, _) => self.forward_prompt_origin(id, cx),
PromptOwner::Terminal(_) | PromptOwner::Sftp(..) => None,
@@ -777,7 +773,7 @@ impl Workspace {
reply: ConnectionPromptReply,
cx: &mut Context<Self>,
) {
let Some((active_terminal, session_id, active_request)) = self.active_prompt else {
let Some((active_terminal, host_id, active_request)) = self.active_prompt else {
return;
};
if (active_terminal, active_request) != (terminal_id, request_id) {
@@ -802,7 +798,7 @@ impl Workspace {
PromptOwner::Sftp(..) => {}
}
}
self.refresh_session_connection_state(session_id, cx);
self.refresh_host_connection_state(host_id, cx);
}
fn prompt_owner_is_live(&self, owner: PromptOwner, cx: &App) -> bool {
@@ -871,30 +867,30 @@ impl Workspace {
) {
self.cancel_prompts_for_owner(PromptOwner::Terminal(terminal_id), window, cx);
}
fn cancel_prompts_for_session(
fn cancel_prompts_for_host(
&mut self,
session_id: SessionId,
host_id: HostId,
window: &mut Window,
cx: &mut Context<Self>,
) {
// A port forward through the session is on its own: what the
// session's tabs are told does not reach it. Its questions end when
// A port forward through the host is on its own: what the
// host's tabs are told does not reach it. Its questions end when
// the forward does.
let of_a_tab = |owner: &PromptOwner| !matches!(owner, PromptOwner::Forward(..));
self.prompt_queue
.retain(|(owner, id, _)| *id != session_id || !of_a_tab(owner));
.retain(|(owner, id, _)| *id != host_id || !of_a_tab(owner));
if let Some((owner, id, _)) = self.active_prompt
&& id == session_id
&& id == host_id
&& of_a_tab(&owner)
{
self.cancel_prompts_for_owner(owner, window, cx);
}
}
fn refresh_session_connection_state(&mut self, session_id: SessionId, cx: &mut Context<Self>) {
fn refresh_host_connection_state(&mut self, host_id: HostId, cx: &mut Context<Self>) {
let mut has_starting = false;
let mut has_running = false;
for panel in self.terminals_of(session_id, cx) {
for panel in self.terminals_of(host_id, cx) {
match panel.read(cx).lifecycle(cx) {
TerminalLifecycle::Running => has_running = true,
TerminalLifecycle::Starting => has_starting = true,
@@ -903,7 +899,7 @@ impl Workspace {
| TerminalLifecycle::Closing => {}
}
}
for panel in self.explorers_of(session_id, cx) {
for panel in self.explorers_of(host_id, cx) {
match panel.read(cx).connection_state() {
ConnectionState::Connected => has_running = true,
ConnectionState::Connecting => has_starting = true,
@@ -918,16 +914,16 @@ impl Workspace {
ConnectionState::Disconnected
};
self.store
.update(cx, |store, cx| store.set_state(session_id, state, cx));
.update(cx, |store, cx| store.set_state(host_id, state, cx));
}
fn on_connect_session(
fn on_connect_host(
&mut self,
action: &ConnectSession,
action: &ConnectHost,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.connect_session(action.0, window, cx);
self.connect_host(action.0, window, cx);
}
fn on_connect_group(
@@ -936,19 +932,14 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
let session_ids = self.store.read(cx).sessions_under(action.0);
for session_id in session_ids {
self.connect_session(session_id, window, cx);
let host_ids = self.store.read(cx).hosts_under(action.0);
for host_id in host_ids {
self.connect_host(host_id, window, cx);
}
}
fn connect_session(
&mut self,
session_id: SessionId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.store.read(cx).session(session_id).is_none() {
fn connect_host(&mut self, host_id: HostId, window: &mut Window, cx: &mut Context<Self>) {
if self.store.read(cx).host(host_id).is_none() {
return;
}
let terminal_id = RemoteTerminalId(self.next_remote_terminal_id);
@@ -957,13 +948,13 @@ impl Workspace {
&self.store,
self.remote_terminal_provider.clone(),
terminal_id,
session_id,
host_id,
window,
cx,
);
self._subscriptions.push(subscription);
self.terminals.insert(terminal_id, panel.clone());
self.refresh_session_connection_state(session_id, cx);
self.refresh_host_connection_state(host_id, cx);
self.dock_area.update(cx, |area, cx| {
area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
@@ -975,15 +966,15 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
let session_id = action.0;
if self.store.read(cx).session(session_id).is_none() {
let host_id = action.0;
if self.store.read(cx).host(host_id).is_none() {
return;
}
// Every request opens a tab of its own, as connecting does for
// terminals: two directories of one host side by side.
let id = ExplorerId(self.next_explorer_id);
self.next_explorer_id += 1;
let (panel, subscription) = new_explorer_panel(self, id, session_id, window, cx);
let (panel, subscription) = new_explorer_panel(self, id, host_id, window, cx);
self._subscriptions.push(subscription);
self.explorers.insert(id, panel.clone());
self.dock_area.update(cx, |area, cx| {
@@ -1193,7 +1184,7 @@ impl Workspace {
});
}
/// Close a session's terminal tab. Goes through the dock area rather
/// Close a host's terminal tab. Goes through the dock area rather
/// than the tab group: the group refuses to close the last tab of the
/// center, and here every tab is closable.
fn on_close_terminal(
@@ -1421,17 +1412,17 @@ impl Workspace {
}
}
fn on_copy_session_host(
fn on_copy_host_address(
&mut self,
action: &CopySessionHost,
action: &CopyHostAddress,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(host) = self
.store
.read(cx)
.session(action.0)
.map(|session| session.host.clone())
.host(action.0)
.map(|host| host.address.clone())
else {
return;
};
@@ -1439,17 +1430,17 @@ impl Workspace {
window.push_notification(Notification::success(format!("已复制 {host}")), cx);
}
fn on_copy_session_id(
fn on_copy_host_id(
&mut self,
action: &CopySessionId,
action: &CopyHostId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(public_id) = self
.store
.read(cx)
.session(action.0)
.map(|session| session.public_id.clone())
.host(action.0)
.map(|host| host.public_id.clone())
else {
return;
};
@@ -1554,20 +1545,20 @@ impl Workspace {
let id = action.0;
self.cancel_prompts_for_terminal(id, window, cx);
if let Some(terminal) = self.terminals.get(&id).cloned() {
let session_id = terminal.read(cx).session_id();
let host_id = terminal.read(cx).host_id();
terminal.update(cx, |terminal, cx| terminal.disconnect(cx));
self.refresh_session_connection_state(session_id, cx);
self.refresh_host_connection_state(host_id, cx);
}
}
fn on_disconnect_session(
fn on_disconnect_host(
&mut self,
action: &DisconnectSession,
action: &DisconnectHost,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = action.0;
self.cancel_prompts_for_session(id, window, cx);
self.cancel_prompts_for_host(id, window, cx);
self.store.update(cx, |store, cx| {
store.set_state(id, ConnectionState::Disconnected, cx);
});
@@ -1589,14 +1580,14 @@ impl Workspace {
let id = action.0;
self.cancel_prompts_for_terminal(id, window, cx);
if let Some(terminal) = self.terminals.get(&id).cloned() {
let session_id = terminal.read(cx).session_id();
let host_id = terminal.read(cx).host_id();
terminal.update(cx, |terminal, cx| terminal.reconnect(window, cx));
self.refresh_session_connection_state(session_id, cx);
self.refresh_host_connection_state(host_id, cx);
}
}
fn on_new_session(&mut self, _: &NewSession, window: &mut Window, cx: &mut Context<Self>) {
open_session_dialog(
fn on_new_host(&mut self, _: &NewHost, window: &mut Window, cx: &mut Context<Self>) {
open_host_dialog(
None,
None,
self.store.clone(),
@@ -1606,14 +1597,9 @@ impl Workspace {
);
}
fn on_edit_session(
&mut self,
action: &EditSession,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.store.read(cx).session(action.0).is_some() {
open_session_dialog(
fn on_edit_host(&mut self, action: &EditHost, window: &mut Window, cx: &mut Context<Self>) {
if self.store.read(cx).host(action.0).is_some() {
open_host_dialog(
Some(action.0),
None,
self.store.clone(),
@@ -1624,9 +1610,9 @@ impl Workspace {
}
}
fn on_duplicate_session(
fn on_duplicate_host(
&mut self,
action: &DuplicateSession,
action: &DuplicateHost,
_: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1634,35 +1620,24 @@ impl Workspace {
.store
.update(cx, |store, cx| store.duplicate(action.0, cx));
if let Some(copy) = copy {
self.session_panel.update(cx, |panel, cx| {
panel.select_node(SessionNode::Session(copy), cx)
});
self.host_panel
.update(cx, |panel, cx| panel.select_node(HostNode::Host(copy), cx));
}
}
fn on_move_session_node(
&mut self,
action: &MoveSessionNode,
_: &mut Window,
cx: &mut Context<Self>,
) {
fn on_move_host_node(&mut self, action: &MoveHostNode, _: &mut Window, cx: &mut Context<Self>) {
let moved = self.store.update(cx, |store, cx| {
store.move_node(action.source, action.destination, cx)
});
if moved {
self.session_panel
self.host_panel
.update(cx, |panel, cx| panel.reveal_node(action.source, cx));
}
}
fn on_delete_session(
&mut self,
action: &DeleteSession,
window: &mut Window,
cx: &mut Context<Self>,
) {
fn on_delete_host(&mut self, action: &DeleteHost, window: &mut Window, cx: &mut Context<Self>) {
let id = action.0;
let Some(session) = self.store.read(cx).session(id).cloned() else {
let Some(host) = self.store.read(cx).host(id).cloned() else {
return;
};
let workspace = cx.entity().downgrade();
@@ -1671,13 +1646,13 @@ impl Workspace {
forwards: store.forwards_of(id).count(),
jump_users: store.jump_users(&[id]),
};
confirm_delete_session(
&session,
confirm_delete_host(
&host,
(self.has_tabs(id, cx), self.transfers_of(id, cx)),
dependents,
Rc::new(move |window, cx| {
workspace
.update(cx, |this, cx| this.remove_session(id, window, cx))
.update(cx, |this, cx| this.remove_host(id, window, cx))
.ok();
}),
window,
@@ -1685,18 +1660,18 @@ impl Workspace {
);
}
fn remove_session(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
self.close_session_tabs(id, window, cx);
fn remove_host(&mut self, id: HostId, window: &mut Window, cx: &mut Context<Self>) {
self.close_host_tabs(id, window, cx);
self.store.update(cx, |store, cx| {
store.remove(id, cx);
});
}
/// Close whatever a session has open in the center, leaving the store
/// alone. Deleting a session and deleting the group around it both need
/// this, the latter for every session in the subtree.
fn close_session_tabs(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
self.cancel_prompts_for_session(id, window, cx);
/// Close whatever a host has open in the center, leaving the store
/// alone. Deleting a host and deleting the group around it both need
/// this, the latter for every host in the subtree.
fn close_host_tabs(&mut self, id: HostId, window: &mut Window, cx: &mut Context<Self>) {
self.cancel_prompts_for_host(id, window, cx);
let terminals: Vec<_> = self.terminals_of(id, cx).cloned().collect();
for terminal in terminals {
self.dock_area
@@ -1709,13 +1684,13 @@ impl Workspace {
}
}
fn on_new_session_in_group(
fn on_new_host_in_group(
&mut self,
action: &NewSessionInGroup,
action: &NewHostInGroup,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_session_dialog(
open_host_dialog(
None,
Some(action.0),
self.store.clone(),
@@ -1750,7 +1725,7 @@ impl Workspace {
fn set_all_groups_expanded(&mut self, expanded: bool, cx: &mut Context<Self>) {
self.store
.update(cx, |store, cx| store.set_all_groups_expanded(expanded, cx));
self.session_panel
self.host_panel
.update(cx, |panel, cx| panel.set_all_groups_expanded(expanded, cx));
}
@@ -1789,7 +1764,7 @@ impl Workspace {
};
let name = group.name.to_string();
let subgroups = store.descendant_groups(id).len();
let doomed = store.sessions_under(id);
let doomed = store.hosts_under(id);
let closes_tabs = doomed.iter().any(|id| self.has_tabs(*id, cx));
let transfers = doomed.iter().map(|id| self.transfers_of(*id, cx)).sum();
let dependents = Dependents {
@@ -1814,19 +1789,19 @@ impl Workspace {
}
/// The store cascades the delete; the workspace only has to close the
/// tabs of the sessions that went with the group.
/// tabs of the hosts that went with the group.
fn remove_group(&mut self, id: GroupId, window: &mut Window, cx: &mut Context<Self>) {
let removed = self
.store
.update(cx, |store, cx| store.remove_group(id, cx));
for session in removed {
self.close_session_tabs(session, window, cx);
for host in removed {
self.close_host_tabs(host, window, cx);
}
}
fn on_toggle_session_panel(
fn on_toggle_host_panel(
&mut self,
_: &ToggleSessionPanel,
_: &ToggleHostPanel,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1895,17 +1870,17 @@ impl Workspace {
}
fn new_terminal_panel(
store: &Entity<SessionStore>,
store: &Entity<HostStore>,
remote_provider: SharedRemoteTerminalTransportProvider,
terminal_id: RemoteTerminalId,
session_id: SessionId,
host_id: HostId,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> (Entity<TerminalPanel>, Subscription) {
let panel = cx.new(|cx| {
TerminalPanel::new(
terminal_id,
session_id,
host_id,
store.clone(),
remote_provider,
window,
@@ -1919,36 +1894,36 @@ fn new_terminal_panel(
TerminalPanelEvent::Activated(terminal_id, _) => {
this.active_tab = Some(CenterTab::Terminal(*terminal_id))
}
TerminalPanelEvent::Closed(terminal_id, session_id) => {
TerminalPanelEvent::Closed(terminal_id, host_id) => {
this.cancel_prompts_for_terminal(*terminal_id, window, cx);
this.terminals.remove(terminal_id);
if this.active_tab == Some(CenterTab::Terminal(*terminal_id)) {
this.active_tab = None;
}
this.refresh_session_connection_state(*session_id, cx);
if !this.has_tabs(*session_id, cx)
&& this.store.read(cx).active().map(|session| session.id) == Some(*session_id)
this.refresh_host_connection_state(*host_id, cx);
if !this.has_tabs(*host_id, cx)
&& this.store.read(cx).active().map(|host| host.id) == Some(*host_id)
{
this.store
.update(cx, |store, cx| store.set_active(None, cx));
}
}
TerminalPanelEvent::StatusChanged(_, session_id) => {
this.refresh_session_connection_state(*session_id, cx);
TerminalPanelEvent::StatusChanged(_, host_id) => {
this.refresh_host_connection_state(*host_id, cx);
cx.notify();
}
TerminalPanelEvent::PromptRequested(terminal_id, session_id, prompt) => this
TerminalPanelEvent::PromptRequested(terminal_id, host_id, prompt) => this
.enqueue_prompt(
PromptOwner::Terminal(*terminal_id),
*session_id,
*host_id,
prompt.clone(),
window,
cx,
),
TerminalPanelEvent::HostOsDetected(session_id, os) => {
let (session_id, os) = (*session_id, *os);
TerminalPanelEvent::HostOsDetected(host_id, os) => {
let (host_id, os) = (*host_id, *os);
this.store
.update(cx, |store, cx| store.set_host_os(session_id, Some(os), cx));
.update(cx, |store, cx| store.set_host_os(host_id, Some(os), cx));
}
},
);
@@ -2012,14 +1987,14 @@ impl Render for AuthenticationPromptForm {
fn new_explorer_panel(
workspace: &Workspace,
id: ExplorerId,
session_id: SessionId,
host_id: HostId,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> (Entity<ExplorerPanel>, Subscription) {
let panel = cx.new(|cx| {
ExplorerPanel::new(
id,
session_id,
host_id,
workspace.store.clone(),
workspace.sftp_provider.clone(),
workspace.local_directory_provider.clone(),
@@ -2033,14 +2008,14 @@ fn new_explorer_panel(
window,
|this, _, event: &ExplorerPanelEvent, window, cx| match event {
ExplorerPanelEvent::Activated(id) => this.active_tab = Some(CenterTab::Explorer(*id)),
ExplorerPanelEvent::Closed(id, session_id) => {
ExplorerPanelEvent::Closed(id, host_id) => {
this.explorers.remove(id);
if this.active_tab == Some(CenterTab::Explorer(*id)) {
this.active_tab = None;
}
this.refresh_session_connection_state(*session_id, cx);
this.refresh_host_connection_state(*host_id, cx);
}
ExplorerPanelEvent::StateChanged(id, session_id) => {
ExplorerPanelEvent::StateChanged(id, host_id) => {
if let Some(panel) = this.explorers.get(id) {
let generation = panel.read(cx).generation();
this.prompt_queue.retain(|(owner,_,_)| !matches!(owner,PromptOwner::Sftp(s,g) if s == id && *g != generation));
@@ -2048,16 +2023,16 @@ fn new_explorer_panel(
this.cancel_prompts_for_owner(owner,window,cx);
}
}
this.refresh_session_connection_state(*session_id, cx);
this.refresh_host_connection_state(*host_id, cx);
},
ExplorerPanelEvent::StatusChanged(id) => {
if this.active_tab == Some(CenterTab::Explorer(*id)) {
cx.notify();
}
}
ExplorerPanelEvent::PromptRequested(id, session_id, generation, prompt) => this.enqueue_prompt(
ExplorerPanelEvent::PromptRequested(id, host_id, generation, prompt) => this.enqueue_prompt(
PromptOwner::Sftp(*id, *generation),
*session_id,
*host_id,
prompt.clone(),
window,
cx,
@@ -2106,24 +2081,24 @@ impl Render for Workspace {
.terminals
.get(&id)
.map(|panel| panel.read(cx).status(cx));
WorkspaceStatus::Session(active, terminal)
WorkspaceStatus::Host(active, terminal)
}
Some(CenterTab::LocalTerminal(id)) => self
.local_terminals
.get(&id)
.map(|panel| WorkspaceStatus::Local(panel.read(cx).status(cx)))
.unwrap_or_else(|| WorkspaceStatus::Session(active, None)),
// An SFTP tab tells its own connection, not the session's, and
.unwrap_or_else(|| WorkspaceStatus::Host(active, None)),
// An SFTP tab tells its own connection, not the host's, and
// what went wrong in it.
Some(CenterTab::Explorer(id)) => match self.explorers.get(&id) {
Some(panel) => {
let panel = panel.read(cx);
let session = self.store.read(cx).session(panel.session_id()).cloned();
WorkspaceStatus::Explorer(session, panel.status(cx))
let host = self.store.read(cx).host(panel.host_id()).cloned();
WorkspaceStatus::Explorer(host, panel.status(cx))
}
None => WorkspaceStatus::Session(active, None),
None => WorkspaceStatus::Host(active, None),
},
_ => WorkspaceStatus::Session(active, None),
_ => WorkspaceStatus::Host(active, None),
};
let sidebar = self.sidebar_showing(cx);
let running_forwards = self.forwards.read(cx).active_count();
@@ -2132,22 +2107,22 @@ impl Render for Workspace {
.id("workspace")
.key_context("Workspace")
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_new_session))
.on_action(cx.listener(Self::on_new_host))
.on_action(cx.listener(Self::on_new_local_terminal))
.on_action(cx.listener(Self::on_edit_session))
.on_action(cx.listener(Self::on_duplicate_session))
.on_action(cx.listener(Self::on_move_session_node))
.on_action(cx.listener(Self::on_delete_session))
.on_action(cx.listener(Self::on_new_session_in_group))
.on_action(cx.listener(Self::on_edit_host))
.on_action(cx.listener(Self::on_duplicate_host))
.on_action(cx.listener(Self::on_move_host_node))
.on_action(cx.listener(Self::on_delete_host))
.on_action(cx.listener(Self::on_new_host_in_group))
.on_action(cx.listener(Self::on_new_group))
.on_action(cx.listener(Self::on_expand_all_groups))
.on_action(cx.listener(Self::on_collapse_all_groups))
.on_action(cx.listener(Self::on_new_child_group))
.on_action(cx.listener(Self::on_rename_group))
.on_action(cx.listener(Self::on_delete_group))
.on_action(cx.listener(Self::on_connect_session))
.on_action(cx.listener(Self::on_connect_host))
.on_action(cx.listener(Self::on_connect_group))
.on_action(cx.listener(Self::on_disconnect_session))
.on_action(cx.listener(Self::on_disconnect_host))
.on_action(cx.listener(Self::on_reconnect_terminal))
.on_action(cx.listener(Self::on_disconnect_terminal))
.on_action(cx.listener(Self::on_open_explorer))
@@ -2174,8 +2149,8 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_close_tabs))
.on_action(cx.listener(Self::on_rename_terminal))
.on_action(cx.listener(Self::on_rename_explorer))
.on_action(cx.listener(Self::on_copy_session_host))
.on_action(cx.listener(Self::on_copy_session_id))
.on_action(cx.listener(Self::on_copy_host_address))
.on_action(cx.listener(Self::on_copy_host_id))
.on_action(cx.listener(Self::on_restart_local_terminal))
.on_action(cx.listener(Self::on_copy_terminal))
.on_action(cx.listener(Self::on_paste_terminal))
@@ -2184,8 +2159,8 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_find_previous_in_terminal))
.on_action(cx.listener(Self::on_dismiss_terminal_find))
.on_action(cx.listener(Self::on_clear_terminal))
.on_action(cx.listener(Self::on_toggle_session_panel))
.on_action(cx.listener(Self::on_show_sessions))
.on_action(cx.listener(Self::on_toggle_host_panel))
.on_action(cx.listener(Self::on_show_hosts))
.on_action(cx.listener(Self::on_show_forwards))
.on_action(cx.listener(Self::on_new_forward))
.on_action(cx.listener(Self::on_edit_forward))
+8 -12
View File
@@ -12,9 +12,9 @@ use std::{
use shellrs::app::cli_endpoint;
use shellrs::cli::{
CliBackend, CliError, CliServer, CliTarget, SessionInfo, TransferCounters, TransferSummary,
CliBackend, CliError, CliServer, CliTarget, HostInfo, TransferCounters, TransferSummary,
};
use shellrs::session::{AuthKind, Session, SessionDraft, SessionId, SessionLogin};
use shellrs::host::{AuthKind, Host, HostDraft, HostId, HostLogin};
use shellrs::ssh::ExecStream;
/// More than a pipe's buffer, so the app finishes writing before the
@@ -102,23 +102,19 @@ fn the_command_finds_the_app_through_its_data_directory() {
let data_dir = tempfile::tempdir().unwrap();
let backend = Arc::new(FakeBackend::default());
let server = CliServer::start(cli_endpoint(data_dir.path()), backend.clone()).unwrap();
let web = Session::new(
SessionId(1),
SessionDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Password, None),
let web = Host::new(
HostId(1),
HostDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Password, None),
);
server.set_targets(vec![CliTarget::new(
&web,
SessionLogin::of(&web, None),
None,
)]);
server.set_targets(vec![CliTarget::new(&web, HostLogin::of(&web, None), None)]);
server.set_enabled(true);
let id = web.public_id.to_string();
// A pipe for stdout means JSON.
let output = shellrs(data_dir.path(), &["list"], b"");
assert_eq!(output.status.code(), Some(0));
let sessions: Vec<SessionInfo> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(sessions[0].id, id);
let hosts: Vec<HostInfo> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(hosts[0].id, id);
// What PowerShell pipes in: a BOM and CRLF line ends.
let output = shellrs(
+533 -592
View File
File diff suppressed because it is too large. Load diff