From ef83812f45adcd53c238ef4f6336762fe1cd69e2 Mon Sep 17 00:00:00 2001 From: Xuz Date: Thu, 1 Oct 2026 14:04:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A8=E7=BB=93=E6=9E=84=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=89=8D=E9=87=8D=E6=9E=84=EF=BC=8C=E3=80=8C=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E3=80=8D=E5=9C=A8=E4=BB=A3=E7=A0=81=E5=92=8C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E9=87=8C=E7=BB=9F=E4=B8=80=E6=94=B9=E5=90=8D=E4=B8=BA?= =?UTF-8?q?=20Host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库升到第 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 同步更新 --- AGENTS.md | 42 +- CLAUDE.md | 94 +- README.md | 4 +- src/app/actions.rs | 64 +- src/app/assets.rs | 6 +- src/app/mod.rs | 24 +- src/app/paths.rs | 2 +- src/cli/SKILL.md | 32 +- src/cli/client.rs | 31 +- src/cli/mod.rs | 33 +- src/cli/protocol.rs | 28 +- src/cli/server.rs | 78 +- src/cli/tests.rs | 63 +- src/connection.rs | 12 +- src/credential/credential_dialog.rs | 18 +- src/credential/credential_panel.rs | 16 +- src/credential/mod.rs | 2 +- src/explorer/explorer_panel.rs | 86 +- src/explorer/file_pane.rs | 18 +- src/explorer/model.rs | 2 +- src/explorer/open_directory_dialog.rs | 14 +- src/explorer/pane_operations.rs | 2 +- src/explorer/transfer_dialog.rs | 2 +- src/forward/diagram.rs | 2 +- src/forward/forward_dialog.rs | 61 +- src/forward/forward_panel.rs | 34 +- src/forward/manager.rs | 59 +- src/forward/mod.rs | 2 +- src/forward/protocol_tests.rs | 32 +- src/forward/transport.rs | 6 +- src/forward/worker.rs | 6 +- src/{session => host}/credential.rs | 2 +- src/host/database.rs | 1654 +++++++++++++ src/{session => host}/forward.rs | 38 +- src/{session => host}/group_dialog.rs | 24 +- .../session_dialog.rs => host/host_dialog.rs} | 196 +- .../session_panel.rs => host/host_panel.rs} | 263 +- src/{session => host}/login.rs | 74 +- src/host/mod.rs | 28 + src/{session => host}/model.rs | 132 +- src/{session => host}/outline.rs | 142 +- src/{session => host}/private_key.rs | 0 src/{session => host}/secret_fields.rs | 0 src/{session => host}/store.rs | 926 ++++---- src/lib.rs | 6 +- src/main.rs | 16 +- src/secrets/memory.rs | 2 +- src/secrets/mod.rs | 2 +- src/session/database.rs | 2112 ----------------- src/session/mod.rs | 30 - src/settings/model.rs | 2 +- src/settings/settings_panel.rs | 2 +- src/settings/store.rs | 2 +- src/sftp/protocol_tests.rs | 10 +- src/sftp/transport.rs | 4 +- src/sftp/worker.rs | 6 +- src/shared/closable_tab.rs | 8 +- src/shared/host_mark.rs | 10 +- src/ssh/connection.rs | 26 +- src/ssh/exec.rs | 2 +- src/ssh/probe.rs | 4 +- src/ssh/proxy.rs | 2 +- src/ssh/route_tests.rs | 8 +- src/ssh/tester.rs | 4 +- src/ssh/transport.rs | 136 +- src/terminal/engine.rs | 2 +- src/terminal/terminal_panel.rs | 100 +- src/terminal/transport.rs | 10 +- src/workspace/credentials.rs | 6 +- src/workspace/forwards.rs | 20 +- src/workspace/mod.rs | 4 +- .../{recent_sessions.rs => recent_hosts.rs} | 64 +- src/workspace/sidebar.rs | 28 +- src/workspace/status_bar.rs | 33 +- src/workspace/title_bar.rs | 20 +- src/workspace/updates.rs | 2 +- src/workspace/workspace_view.rs | 379 ++- tests/cli_bin.rs | 20 +- tests/ui.rs | 1125 +++++---- 79 files changed, 3955 insertions(+), 4606 deletions(-) rename src/{session => host}/credential.rs (99%) create mode 100644 src/host/database.rs rename src/{session => host}/forward.rs (94%) rename src/{session => host}/group_dialog.rs (92%) rename src/{session/session_dialog.rs => host/host_dialog.rs} (90%) rename src/{session/session_panel.rs => host/host_panel.rs} (79%) rename src/{session => host}/login.rs (81%) create mode 100644 src/host/mod.rs rename src/{session => host}/model.rs (84%) rename src/{session => host}/outline.rs (65%) rename src/{session => host}/private_key.rs (100%) rename src/{session => host}/secret_fields.rs (100%) rename src/{session => host}/store.rs (72%) delete mode 100644 src/session/database.rs delete mode 100644 src/session/mod.rs rename src/workspace/{recent_sessions.rs => recent_hosts.rs} (84%) diff --git a/AGENTS.md b/AGENTS.md index 348cfb7..371c369 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.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`,`Session.os` 是 `Option`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`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`,`Host.os` 是 `Option`)、`database.rs`(`HostDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 +- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`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 校验写 `/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:`)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。 +- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:`)。`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` 的克隆,通过它的方法修改(方法内部会 `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` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。 - **Dock 面板**需实现 `EventEmitter + 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>`)。 +- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如已连接主机的 `Rc>`)。 - `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`:生产环境由 `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`:生产环境由 `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` 初始为已连接并已打开终端标签。 diff --git a/CLAUDE.md b/CLAUDE.md index 7f70789..cc02fa9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标,点开是更新对话框(更新内容、重启会中断什么、「重启更新」,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」看版本、手动检查、关掉「自动检查并下载更新」;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。 +已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标,点开是更新对话框(更新内容、重启会中断什么、「重启更新」,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」看版本、手动检查、关掉「自动检查并下载更新」;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。 做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/.md` 为准。 @@ -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`,`Session.os` 是 `Option`,`Session.credential` 是 `Option`,`Session.route` 是连接方式 `Route::{Direct, Jump(Vec>), 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:` / `s:`;`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 校验写 `/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 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`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`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 +- `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 校验写 `/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 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`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`,写 `/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`,写 `/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`**:主动「断开」靠丢掉最后一个引用来关连接,监听攥着强引用会让 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` 的克隆,通过它的方法修改(方法内部会 `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` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。 - **Dock 面板**需实现 `EventEmitter + 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>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。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>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。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>`)。 +- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如主机面板里各主机系统的 `Rc>`)。 - `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`:生产环境由 `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-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`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 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_session_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_sessions_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-session`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-session` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;会话对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("session-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `session-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `session-credential-summary`;连接方式在 `within("session-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `session-route-note`,跳板的链 `session-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 会话 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的会话放回去),代理的 `session-proxy-kind`(`Select`)/ `session-proxy-host` / `session-proxy-port` / `session-proxy-user` / `session-proxy-password`,最后的备注 `session-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `SessionLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的 `about-version` / `about-build` / `about-platform`(`aria_label` 是显示的文字)、状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、开关在 `group-1` › `item-1` › `check`;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`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 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的 `about-version` / `about-build` / `about-platform`(`aria_label` 是显示的文字)、状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、开关在 `group-1` › `item-1` › `check`;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/README.md b/README.md index 80d5820..5a8d986 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。 - **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。 -无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。旧版本里「自动」的主机升级后归为「密码」,自己选了私钥文件的主机升级后改用按「用户 + 私钥文件」自动建好的密钥凭据。 +无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。 主机对话框「分组」下面的「连接方式」决定怎么连到这台主机,终端、SFTP、端口转发、外部 CLI 和「测试连接」都照它走: @@ -117,7 +117,7 @@ shellrs download <远程路径> <本地路径> - `` 是 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 --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。 ## 标签页 diff --git a/src/app/actions.rs b/src/app/actions.rs index ccb3203..130497c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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, } diff --git a/src/app/assets.rs b/src/app/assets.rs index 08625b6..096f881 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -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] diff --git a/src/app/mod.rs b/src/app/mod.rs index fc5c9e2..fe286f3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { 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::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 { #[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 { ), 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 }, diff --git a/src/app/paths.rs b/src/app/paths.rs index f1204e3..0f90073 100644 --- a/src/app/paths.rs +++ b/src/app/paths.rs @@ -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 { let dir = data_dir(); diff --git a/src/cli/SKILL.md b/src/cli/SKILL.md index dc76057..91d356f 100644 --- a/src/cli/SKILL.md +++ b/src/cli/SKILL.md @@ -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 --help` for details. `list` prints JSON when piped or given `--json`. @@ -14,18 +14,18 @@ Run `shellrs --help` or `shellrs --help` for details. `list` prints JS ```bash shellrs list --query --json -shellrs exec "" -shellrs upload -shellrs download +shellrs exec "" +shellrs upload +shellrs download ``` -`` is the 16-character `id` from `shellrs list` (the same ID that ShellRS copies with 复制 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 ``. 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 --stdin <<'EOF' +shellrs exec --stdin <<'EOF' EOF ``` @@ -33,10 +33,10 @@ EOF ## File Transfer ```bash -shellrs upload ./dist /tmp/dist -shellrs upload ./app.tar.gz /tmp/release.tar.gz -shellrs download /var/log/nginx ./logs/nginx -shellrs download /tmp/app.tar.gz ./latest.tar.gz +shellrs upload ./dist /tmp/dist +shellrs upload ./app.tar.gz /tmp/release.tar.gz +shellrs download /var/log/nginx ./logs/nginx +shellrs download /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 @' -'@ | shellrs exec --stdin +'@ | shellrs exec --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. diff --git a/src/cli/client.rs b/src/cli/client.rs index 10ebee7..1375b0e 100644 --- a/src/cli/client.rs +++ b/src/cli/client.rs @@ -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()) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ebdd30b..d434c22 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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, /// 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 { 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 { } => { 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 { } => { console.json |= json; Request::Download { - session: id, + host: id, source: remote, destination: absolute(local, console)?, } diff --git a/src/cli/protocol.rs b/src/cli/protocol.rs index 1fe569c..9a1d572 100644 --- a/src/cli/protocol.rs +++ b/src/cli/protocol.rs @@ -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, }, 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, + Hosts { + hosts: Vec, }, /// 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, } -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(), } ); diff --git a/src/cli/server.rs b/src/cli/server.rs index f8f1c6c..a7851e2 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -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; } -/// 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) -> Self { + /// `group` is the host's full group path, if it has one. + pub fn new(host: &Host, login: HostLogin, group: Option) -> 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 { + /// Every host in `store`, with its group path. + pub fn all(store: &HostStore) -> Vec { 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 { .cloned() .ok_or_else(|| { CliError::new( - ErrorCode::SessionNotFound, + ErrorCode::HostNotFound, format!("没有 ID 为 {id} 的主机:请用 shellrs list 查看"), ) }) diff --git a/src/cli/tests.rs b/src/cli/tests.rs index f184344..ecb6b71 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -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 { 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, - 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 = serde_json::from_str(&stdout).unwrap(); - sessions - .into_iter() - .map(|session| session.name) - .collect::>() + let hosts: Vec = serde_json::from_str(&stdout).unwrap(); + hosts.into_iter().map(|host| host.name).collect::>() }; 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 = 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 = 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(), }, diff --git a/src/connection.rs b/src/connection.rs index b8d3b9f..02b57ab 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -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, @@ -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 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 diff --git a/src/credential/credential_dialog.rs b/src/credential/credential_dialog.rs index 14c6451..8066925 100644 --- a/src/credential/credential_dialog.rs +++ b/src/credential/credential_dialog.rs @@ -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, + store: Entity, editing: Option, kind: CredentialKind, name: Entity, @@ -119,7 +119,7 @@ pub struct CredentialForm { impl CredentialForm { pub fn new( dialog: CredentialDialog, - store: Entity, + store: Entity, window: &mut Window, cx: &mut Context, ) -> 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, + store: Entity, window: &mut Window, cx: &mut App, ) { diff --git a/src/credential/credential_panel.rs b/src/credential/credential_panel.rs index 0fbec6c..0eef8bd 100644 --- a/src/credential/credential_panel.rs +++ b/src/credential/credential_panel.rs @@ -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, + store: Entity, /// 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, + store: Entity, target: FocusHandle, window: &mut Window, cx: &mut Context, @@ -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() diff --git a/src/credential/mod.rs b/src/credential/mod.rs index f8f110b..705e3b4 100644 --- a/src/credential/mod.rs +++ b/src/credential/mod.rs @@ -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; diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index 805b20a..268b437 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -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, + pub(super) store: Entity, local: Entity, pub(super) remote: Entity, 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, + host_id: HostId, + store: Entity, 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) { 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) -> 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) -> Option> { - 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>, @@ -969,12 +969,12 @@ impl ExplorerPanel { fn tab_menu(&self, cx: &Context) -> 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( "重新连接", diff --git a/src/explorer/file_pane.rs b/src/explorer/file_pane.rs index 445b52a..0b464c9 100644 --- a/src/explorer/file_pane.rs +++ b/src/explorer/file_pane.rs @@ -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, + store: Entity, dispatch: FocusHandle, _subscriptions: Vec, load_task: Option>, @@ -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, + store: Entity, dispatch: FocusHandle, window: &mut Window, cx: &mut Context, @@ -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 { 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 { @@ -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| { diff --git a/src/explorer/model.rs b/src/explorer/model.rs index a05ac08..8b116f3 100644 --- a/src/explorer/model.rs +++ b/src/explorer/model.rs @@ -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); diff --git a/src/explorer/open_directory_dialog.rs b/src/explorer/open_directory_dialog.rs index 08758b4..44d71f8 100644 --- a/src/explorer/open_directory_dialog.rs +++ b/src/explorer/open_directory_dialog.rs @@ -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, + store: Entity, input: Entity, 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 { 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(), diff --git a/src/explorer/pane_operations.rs b/src/explorer/pane_operations.rs index 779ca19..f5188d5 100644 --- a/src/explorer/pane_operations.rs +++ b/src/explorer/pane_operations.rs @@ -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, }; diff --git a/src/explorer/transfer_dialog.rs b/src/explorer/transfer_dialog.rs index d7f4ffc..734a690 100644 --- a/src/explorer/transfer_dialog.rs +++ b/src/explorer/transfer_dialog.rs @@ -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, }; diff --git a/src/forward/diagram.rs b/src/forward/diagram.rs index c385fb7..5ea2ffc 100644 --- a/src/forward/diagram.rs +++ b/src/forward/diagram.rs @@ -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 diff --git a/src/forward/forward_dialog.rs b/src/forward/forward_dialog.rs index 586d5eb..97ef919 100644 --- a/src/forward/forward_dialog.rs +++ b/src/forward/forward_dialog.rs @@ -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, + store: Entity, editing: Option, /// 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, - session: Entity>>, - /// Parallel to the session select's rows. - session_ids: Vec, + host: Entity>>, + /// Parallel to the host select's rows. + host_ids: Vec, bind_host: Entity, bind_port: Entity, target_host: Entity, @@ -68,36 +67,31 @@ impl ForwardForm { pub fn new( editing: Option, editing_active: bool, - store: Entity, + store: Entity, window: &mut Window, cx: &mut Context, ) -> 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::>(), + hosts .iter() - .map(|session| session.id) - .collect::>(), - sessions - .iter() - .map(|session| { - SharedString::from(format!("{}({})", session.name, session.address())) - }) + .map(|host| SharedString::from(format!("{}({})", host.name, host.endpoint()))) .collect::>(), ) }; 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| 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 { - self.session + fn selected_host(&self, cx: &App) -> Option { + 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 { - let session = *self.session_ids.get(self.selected_session(cx)?)?; + let host = *self.host_ids.get(self.selected_host(cx)?)?; let endpoint = |host: &Entity, port: &Entity| { 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, editing_active: bool, - store: Entity, + store: Entity, window: &mut Window, cx: &mut App, ) { diff --git a/src/forward/forward_panel.rs b/src/forward/forward_panel.rs index 8ff8341..53dee84 100644 --- a/src/forward/forward_panel.rs +++ b/src/forward/forward_panel.rs @@ -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, + store: Entity, manager: Entity, /// 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, + store: Entity, manager: Entity, 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() diff --git a/src/forward/manager.rs b/src/forward/manager.rs index ab1aa5b..cf8123d 100644 --- a/src/forward/manager.rs +++ b/src/forward/manager.rs @@ -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, events: Receiver, status: ForwardStatus, @@ -85,10 +85,10 @@ struct Link { stopping: Option, } -/// 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, + store: Entity, provider: SharedForwardTransportProvider, links: HashMap, /// Why a rule's last run ended, until it starts again or is deleted. @@ -104,32 +104,29 @@ impl EventEmitter for ForwardManager {} impl ForwardManager { pub fn new( - store: Entity, + store: Entity, provider: SharedForwardTransportProvider, cx: &mut Context, ) -> 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) { 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; } diff --git a/src/forward/mod.rs b/src/forward/mod.rs index ad25932..9cb53b6 100644 --- a/src/forward/mod.rs +++ b/src/forward/mod.rs @@ -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; diff --git a/src/forward/protocol_tests.rs b/src/forward/protocol_tests.rs index 98c50a8..4079022 100644 --- a/src/forward/protocol_tests.rs +++ b/src/forward/protocol_tests.rs @@ -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>, + logged_in: Mutex>, } 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 diff --git a/src/forward/transport.rs b/src/forward/transport.rs index b8def6b..f3b79ee 100644 --- a/src/forward/transport.rs +++ b/src/forward/transport.rs @@ -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; + fn create(&self, rule: &ForwardRule, login: &HostLogin) -> Box; } pub type SharedForwardTransportProvider = Arc; diff --git a/src/forward/worker.rs b/src/forward/worker.rs index f257879..3bfbb7f 100644 --- a/src/forward/worker.rs +++ b/src/forward/worker.rs @@ -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 { + fn create(&self, rule: &ForwardRule, login: &HostLogin) -> Box { Box::new(SshForwardTransport { connector: self.connector.clone(), config: SshConnectionConfig::from(login), diff --git a/src/session/credential.rs b/src/host/credential.rs similarity index 99% rename from src/session/credential.rs rename to src/host/credential.rs index 79e3dd1..89946d9 100644 --- a/src/session/credential.rs +++ b/src/host/credential.rs @@ -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. diff --git a/src/host/database.rs b/src/host/database.rs new file mode 100644 index 0000000..f90b721 --- /dev/null +++ b/src/host/database.rs @@ -0,0 +1,1654 @@ +//! The SQLite file behind the host store. +//! +//! Everything here is synchronous and runs on the UI thread: the writes are +//! single rows triggered by a dialog the user just confirmed, not a stream. +//! The store keeps memory as the source of truth and mirrors each change +//! here, so a failed write costs the user persistence, never the edit. + +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; + +use rusqlite::{Connection, params}; + +use super::{ + AuthKind, BookmarkSide, Credential, CredentialDraft, CredentialId, CredentialKind, + ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GroupDraft, GroupId, Host, + HostDraft, HostGroup, HostId, HostOs, ProxyKind, ProxySettings, PublicId, Route, +}; + +/// SQLite's only integer type is `i64`, so the `u64` ids cross the boundary +/// as `i64`. They are counters that start at 1 and never come near the range +/// where the round trip would lose anything. +fn to_sql(id: u64) -> i64 { + id as i64 +} + +fn from_sql(id: i64) -> u64 { + id as u64 +} + +/// The schema's version, stored in `PRAGMA user_version`. A change to the +/// schema bumps it and gives `migrate` a step from the version before. +/// +/// 13 is the schema as it was rebuilt before the first release. Versions up +/// to 12 were development builds, and their files are refused. +const SCHEMA_VERSION: i64 = 13; + +/// The whole schema, as a new database gets it. +/// +/// Every table is `STRICT`: a value of the wrong type is refused rather than +/// stored. The constraints say what the store already keeps true, so a bug +/// or a hand edit that breaks it fails loudly instead of loading as nonsense. +/// +/// - **groups** nest through `parent_id`; deleting one cascades to its +/// subgroups and, through `hosts.group_id`, to their hosts. +/// - **credentials** come before the hosts that refer to them. Only a key +/// credential names a key file. Secrets are never here: a password +/// credential's password is in the keychain under `keychain_id`. +/// - **hosts** carry how they log in and how they are reached: +/// - `auth` is `password` or `no-password` for a login of their own, with +/// `username`, or `credential`, with `credential_id` and no user name of +/// their own (the credential's is used). Deleting a credential that is +/// still used is refused: the store first gives its hosts a login of +/// their own. +/// - `route` is `direct`, `jump` (the hops are in `host_jumps`) or +/// `proxy`, whose four columns are set exactly for a proxy route. +/// - `public_id` may be missing, which a row added by hand is until the +/// next open (see `fill_missing_public_ids`); the ones there are unique. +/// - `os` and `last_connected_at` are written by connections, not by the +/// host form. +/// - **host_jumps** lists a host's jump hosts in order. A deleted jump +/// host leaves its row behind with no `jump_id`, so the host fails to +/// connect rather than skip the hop. +/// - **bookmarks** are kept per host and per pane, and go with their +/// host, the way WinSCP drops a site's bookmarks with the site. +/// - **forwards** go with the host they go through. A dynamic forward has +/// no target; the other two kinds must have one. +/// +/// A column added later goes after the table's last column here and is +/// added to existing files with `ALTER TABLE ADD COLUMN`, which puts it in +/// the same place (`a_column_added_later_matches_the_schema`). +const SCHEMA: &str = "\ +CREATE TABLE groups ( + id INTEGER PRIMARY KEY, + parent_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK (name <> ''), + sort_order INTEGER NOT NULL DEFAULT 0, + expanded INTEGER NOT NULL DEFAULT 1 CHECK (expanded IN (0, 1)) +) STRICT; +CREATE INDEX groups_parent_id ON groups(parent_id); +CREATE TABLE credentials ( + id INTEGER PRIMARY KEY, + keychain_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL CHECK (name <> ''), + kind TEXT NOT NULL CHECK (kind IN ('password', 'key', 'agent')), + username TEXT NOT NULL, + key_path TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + CHECK ((kind = 'key') = (key_path IS NOT NULL)) +) STRICT; +CREATE TABLE hosts ( + id INTEGER PRIMARY KEY, + public_id TEXT UNIQUE, + group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL CHECK (name <> ''), + address TEXT NOT NULL CHECK (address <> ''), + port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535), + auth TEXT NOT NULL CHECK (auth IN ('password', 'no-password', 'credential')), + username TEXT, + credential_id INTEGER REFERENCES credentials(id), + route TEXT NOT NULL DEFAULT 'direct' CHECK (route IN ('direct', 'jump', 'proxy')), + proxy_kind TEXT CHECK (proxy_kind IN ('http', 'socks5')), + proxy_host TEXT, + proxy_port INTEGER CHECK (proxy_port BETWEEN 1 AND 65535), + proxy_username TEXT, + notes TEXT NOT NULL DEFAULT '', + os TEXT, + last_connected_at INTEGER, + CHECK ((auth = 'credential') = (credential_id IS NOT NULL)), + CHECK ((auth = 'credential') = (username IS NULL)), + CHECK ((route = 'proxy') = (proxy_kind IS NOT NULL)), + CHECK ((proxy_kind IS NULL) = (proxy_host IS NULL) + AND (proxy_kind IS NULL) = (proxy_port IS NULL)), + CHECK (proxy_kind IS NOT NULL OR proxy_username IS NULL) +) STRICT; +CREATE INDEX hosts_group_id ON hosts(group_id); +CREATE INDEX hosts_credential_id ON hosts(credential_id); +CREATE TABLE host_jumps ( + host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + position INTEGER NOT NULL CHECK (position >= 0), + jump_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL, + PRIMARY KEY (host_id, position), + CHECK (jump_id <> host_id) +) STRICT, WITHOUT ROWID; +CREATE INDEX host_jumps_jump_id ON host_jumps(jump_id); +CREATE TABLE bookmarks ( + host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + side TEXT NOT NULL CHECK (side IN ('local', 'remote')), + path TEXT NOT NULL, + sort_order INTEGER NOT NULL, + PRIMARY KEY (host_id, side, path) +) STRICT, WITHOUT ROWID; +CREATE TABLE forwards ( + id INTEGER PRIMARY KEY, + host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL CHECK (kind IN ('local', 'remote', 'dynamic')), + bind_host TEXT NOT NULL, + bind_port INTEGER NOT NULL CHECK (bind_port BETWEEN 1 AND 65535), + target_host TEXT, + target_port INTEGER, + auto_start INTEGER NOT NULL DEFAULT 0 CHECK (auto_start IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + CHECK ((kind = 'dynamic' AND target_host IS NULL AND target_port IS NULL) + OR (kind <> 'dynamic' AND target_host IS NOT NULL + AND target_port BETWEEN 1 AND 65535)) +) STRICT; +CREATE INDEX forwards_host_id ON forwards(host_id);"; + +/// One step from a version to the next: one transaction that ends by +/// recording the version it reached. +// No step exists yet; the first change to `SCHEMA` adds one. +#[allow(dead_code)] +enum Step { + /// Statements that make their own transaction. + Sql(&'static str), + /// A change SQL alone does not express well. + Code(fn(&Connection) -> rusqlite::Result<()>), +} + +/// The steps from each older version to the next, in order. A file older +/// than the first of them, or than `SCHEMA_VERSION` while there are none, +/// cannot be opened. +const STEPS: [(i64, Step); 0] = []; + +/// Everything one launch reads back from disk. +pub struct StoredData { + /// Groups in id order; `parent` and `sort_order` give the visible tree. + pub groups: Vec, + /// Hosts in id order, all of them disconnected. A host using a + /// credential has no user name of its own here; the store copies the + /// credential's in. + pub hosts: Vec, + /// Hosts that have ever connected, most recently connected first. + pub recent: Vec, + /// SFTP bookmarks in the order they were added. + pub bookmarks: Vec<(HostId, BookmarkSide, String)>, + /// Port-forwarding rules in the order the forward list shows them. + pub forwards: Vec, + /// Credentials in the order the credential list shows them. + pub credentials: Vec, +} + +pub struct HostDatabase { + connection: Connection, +} + +impl HostDatabase { + /// Open the database at `path`, creating it if it is not there yet. + pub fn open(path: &Path) -> rusqlite::Result { + Self::prepare(Connection::open(path)?) + } + + /// An anonymous database that lives only as long as this value. + #[cfg(test)] + pub fn in_memory() -> rusqlite::Result { + Self::prepare(Connection::open_in_memory()?) + } + + fn prepare(mut connection: Connection) -> rusqlite::Result { + // Cascading deletes are how removing a group removes its subtree, and + // SQLite only honours the foreign keys when asked, per connection. + connection.pragma_update(None, "foreign_keys", true)?; + connection.execute_batch("PRAGMA journal_mode = WAL;")?; + migrate(&connection)?; + fill_missing_public_ids(&mut connection)?; + Ok(Self { connection }) + } + + pub fn load(&self) -> rusqlite::Result { + let groups = self + .connection + .prepare("SELECT id, name, parent_id, sort_order, expanded FROM groups ORDER BY id")? + .query_map([], |row| { + let id: i64 = row.get(0)?; + let name: String = row.get(1)?; + let parent: Option = row.get(2)?; + let mut group = HostGroup::new( + GroupId(from_sql(id)), + GroupDraft::new(name, parent.map(|id| GroupId(from_sql(id)))), + ); + group.sort_order = row.get(3)?; + group.expanded = row.get(4)?; + Ok(group) + })? + .collect::>>()?; + + let mut hosts = self + .connection + .prepare( + "SELECT id, public_id, group_id, sort_order, name, address, port, auth, username, \ + credential_id, route, proxy_kind, proxy_host, proxy_port, proxy_username, \ + notes, os FROM hosts ORDER BY id", + )? + .query_map([], |row| { + let id: i64 = row.get(0)?; + let public_id: Option = row.get(1)?; + let group: Option = row.get(2)?; + let name: String = row.get(4)?; + let address: String = row.get(5)?; + let port: u16 = row.get(6)?; + let auth: String = row.get(7)?; + let user: Option = row.get(8)?; + let credential: Option = row.get(9)?; + let route: String = row.get(10)?; + let proxy_kind: Option = row.get(11)?; + let proxy_host: Option = row.get(12)?; + let proxy_port: Option = row.get(13)?; + let proxy_user: Option = row.get(14)?; + let notes: String = row.get(15)?; + let os: Option = row.get(16)?; + let mut host = Host::new( + HostId(from_sql(id)), + HostDraft::new( + name, + address, + port, + user.unwrap_or_default(), + AuthKind::from_stored(&auth), + group.map(|id| GroupId(from_sql(id))), + ) + .with_notes(notes), + ); + host.public_id = PublicId::from_stored(public_id.unwrap_or_default()); + host.sort_order = row.get(3)?; + host.credential = credential.map(|id| CredentialId(from_sql(id))); + // The table's CHECKs keep the proxy's columns whole. + host.route = match (route.as_str(), proxy_kind, proxy_host, proxy_port) { + ("proxy", Some(kind), Some(proxy_host), Some(port)) => { + ProxyKind::from_stored(&kind) + .map(|kind| { + Route::Proxy( + ProxySettings::new(kind, proxy_host, port) + .with_user(proxy_user.unwrap_or_default()), + ) + }) + .unwrap_or_default() + } + ("jump", ..) => Route::Jump(Vec::new()), + _ => Route::Direct, + }; + host.os = os.as_deref().and_then(HostOs::from_stored); + Ok(host) + })? + .collect::>>()?; + self.load_jumps(&mut hosts)?; + + let recent = self + .connection + .prepare( + "SELECT id FROM hosts WHERE last_connected_at IS NOT NULL \ + ORDER BY last_connected_at DESC, id DESC", + )? + .query_map([], |row| { + row.get::<_, i64>(0).map(|id| HostId(from_sql(id))) + })? + .collect::>>()?; + + let bookmarks = self + .connection + .prepare( + "SELECT host_id, side, path FROM bookmarks \ + ORDER BY sort_order, host_id, side, path", + )? + .query_map([], |row| { + let host: i64 = row.get(0)?; + let side: String = row.get(1)?; + Ok(( + HostId(from_sql(host)), + BookmarkSide::from_stored(&side), + row.get::<_, String>(2)?, + )) + })? + .filter_map(|row| match row { + Ok((host, Some(side), path)) => Some(Ok((host, side, path))), + Ok((_, None, _)) => None, + Err(error) => Some(Err(error)), + }) + .collect::>>()?; + + let forwards = self + .connection + .prepare( + "SELECT id, host_id, name, kind, bind_host, bind_port, target_host, \ + target_port, auto_start, sort_order FROM forwards ORDER BY sort_order, id", + )? + .query_map([], |row| { + let id: i64 = row.get(0)?; + let host: i64 = row.get(1)?; + let name: String = row.get(2)?; + let kind: String = row.get(3)?; + let bind_host: String = row.get(4)?; + let bind_port: u16 = row.get(5)?; + let target_host: Option = row.get(6)?; + let target_port: Option = row.get(7)?; + let auto_start: bool = row.get(8)?; + let sort_order: i64 = row.get(9)?; + // The table's CHECK admits only the kinds this build knows. + Ok(ForwardKind::from_stored(&kind).map(|kind| { + let target = target_host + .zip(target_port) + .map(|(host, port)| ForwardEndpoint::new(host, port)); + let draft = ForwardDraft::new( + kind, + HostId(from_sql(host)), + ForwardEndpoint::new(bind_host, bind_port), + target, + ) + .with_name(name) + .with_auto_start(auto_start); + let mut rule = ForwardRule::new(ForwardId(from_sql(id)), draft); + rule.sort_order = sort_order; + rule + })) + })? + .filter_map(Result::transpose) + .collect::>>()?; + + let credentials = self + .connection + .prepare( + "SELECT id, keychain_id, name, kind, username, key_path, sort_order \ + FROM credentials ORDER BY sort_order, id", + )? + .query_map([], |row| { + let id: i64 = row.get(0)?; + let keychain_id: String = row.get(1)?; + let name: String = row.get(2)?; + let kind: String = row.get(3)?; + let user: String = row.get(4)?; + let key_path: Option = row.get(5)?; + let sort_order: i64 = row.get(6)?; + // The table's CHECK admits only the kinds this build knows. + Ok(CredentialKind::from_stored(&kind).map(|kind| { + let mut draft = CredentialDraft::new(name, kind, user); + draft.key_path = key_path.map(Into::into); + let mut credential = Credential::new(CredentialId(from_sql(id)), draft); + credential.keychain_id = PublicId::from_stored(keychain_id); + credential.sort_order = sort_order; + credential + })) + })? + .filter_map(Result::transpose) + .collect::>>()?; + + Ok(StoredData { + groups, + hosts, + recent, + bookmarks, + forwards, + credentials, + }) + } + + /// Give each jump route among `hosts` its hops, in order. + fn load_jumps(&self, hosts: &mut [Host]) -> rusqlite::Result<()> { + let index: HashMap = hosts + .iter() + .enumerate() + .map(|(ix, host)| (host.id, ix)) + .collect(); + let hops = self + .connection + .prepare("SELECT host_id, jump_id FROM host_jumps ORDER BY host_id, position")? + .query_map([], |row| { + let host: i64 = row.get(0)?; + let jump: Option = row.get(1)?; + Ok((HostId(from_sql(host)), jump.map(|id| HostId(from_sql(id))))) + })? + .collect::>>()?; + for (host, hop) in hops { + if let Some(Route::Jump(hops)) = index.get(&host).map(|ix| &mut hosts[*ix].route) { + hops.push(hop); + } + } + Ok(()) + } + + pub fn insert_group(&self, group: &HostGroup) -> rusqlite::Result<()> { + self.connection.execute( + "INSERT INTO groups (id, name, parent_id, sort_order, expanded) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + to_sql(group.id.0), + group.name.as_ref(), + group.parent.map(|parent| to_sql(parent.0)), + group.sort_order, + group.expanded, + ], + )?; + Ok(()) + } + + pub fn update_group(&self, group: &HostGroup) -> rusqlite::Result<()> { + self.connection.execute( + "UPDATE groups SET name = ?2, parent_id = ?3, sort_order = ?4, expanded = ?5 WHERE id = ?1", + params![ + to_sql(group.id.0), + group.name.as_ref(), + group.parent.map(|parent| to_sql(parent.0)), + group.sort_order, + group.expanded, + ], + )?; + Ok(()) + } + + /// Save the host tree's expansion choice for one group. + pub fn set_group_expanded(&self, id: GroupId, expanded: bool) -> rusqlite::Result<()> { + self.connection.execute( + "UPDATE groups SET expanded = ?2 WHERE id = ?1", + params![to_sql(id.0), expanded], + )?; + Ok(()) + } + + /// Save an expand-all or collapse-all command in one database write. + pub fn set_all_groups_expanded(&self, expanded: bool) -> rusqlite::Result<()> { + self.connection + .execute("UPDATE groups SET expanded = ?1", params![expanded])?; + Ok(()) + } + + /// Deleting a group takes its subgroups and their hosts with it, via + /// the `ON DELETE CASCADE` on both foreign keys. + pub fn remove_group(&self, id: GroupId) -> rusqlite::Result<()> { + self.connection + .execute("DELETE FROM groups WHERE id = ?1", params![to_sql(id.0)])?; + Ok(()) + } + + /// Insert a host together with its jump hosts. + pub fn insert_host(&self, host: &Host) -> rusqlite::Result<()> { + let transaction = self.connection.unchecked_transaction()?; + let row = HostRow::of(host); + transaction.execute( + "INSERT INTO hosts (id, public_id, group_id, sort_order, name, address, port, auth, \ + username, credential_id, route, proxy_kind, proxy_host, proxy_port, \ + proxy_username, notes, os) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + params![ + to_sql(host.id.0), + host.public_id.as_str(), + host.group.map(|group| to_sql(group.0)), + host.sort_order, + host.name.as_ref(), + host.address.as_ref(), + host.port, + row.auth, + row.username, + row.credential, + row.route, + row.proxy_kind, + row.proxy_host, + row.proxy_port, + row.proxy_username, + host.notes.as_ref(), + host.os.map(HostOs::as_str), + ], + )?; + write_jumps(&transaction, host)?; + transaction.commit() + } + + /// Rewrite the fields the host form edits, jump hosts included. + /// `last_connected_at` and `os` are left alone: they are written by + /// `touch_connected` and `set_host_os`, and neither is part of the + /// host form. `public_id` never changes. + pub fn update_host(&self, host: &Host) -> rusqlite::Result<()> { + let transaction = self.connection.unchecked_transaction()?; + let row = HostRow::of(host); + transaction.execute( + "UPDATE hosts SET group_id = ?2, sort_order = ?3, name = ?4, address = ?5, \ + port = ?6, auth = ?7, username = ?8, credential_id = ?9, route = ?10, \ + proxy_kind = ?11, proxy_host = ?12, proxy_port = ?13, proxy_username = ?14, \ + notes = ?15 WHERE id = ?1", + params![ + to_sql(host.id.0), + host.group.map(|group| to_sql(group.0)), + host.sort_order, + host.name.as_ref(), + host.address.as_ref(), + host.port, + row.auth, + row.username, + row.credential, + row.route, + row.proxy_kind, + row.proxy_host, + row.proxy_port, + row.proxy_username, + host.notes.as_ref(), + ], + )?; + write_jumps(&transaction, host)?; + transaction.commit() + } + + /// Deleting a host takes its jump hosts, bookmarks and forwards with + /// it; the hosts that went through it keep its place, empty + /// (`ON DELETE SET NULL`). + pub fn remove_host(&self, id: HostId) -> rusqlite::Result<()> { + self.connection + .execute("DELETE FROM hosts WHERE id = ?1", params![to_sql(id.0)])?; + Ok(()) + } + + /// Save one drag as a unit, including parent changes and sibling order. + pub fn save_tree_order(&self, groups: &[HostGroup], hosts: &[Host]) -> rusqlite::Result<()> { + let transaction = self.connection.unchecked_transaction()?; + { + let mut update_group = transaction + .prepare("UPDATE groups SET parent_id = ?2, sort_order = ?3 WHERE id = ?1")?; + for group in groups { + update_group.execute(params![ + to_sql(group.id.0), + group.parent.map(|id| to_sql(id.0)), + group.sort_order, + ])?; + } + let mut update_host = transaction + .prepare("UPDATE hosts SET group_id = ?2, sort_order = ?3 WHERE id = ?1")?; + for host in hosts { + update_host.execute(params![ + to_sql(host.id.0), + host.group.map(|id| to_sql(id.0)), + host.sort_order, + ])?; + } + } + transaction.commit() + } + + /// Append a bookmark; adding one that exists is a no-op. + pub fn insert_bookmark( + &self, + host: HostId, + side: BookmarkSide, + path: &str, + ) -> rusqlite::Result<()> { + self.connection.execute( + "INSERT OR IGNORE INTO bookmarks (host_id, side, path, sort_order) \ + VALUES (?1, ?2, ?3, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM bookmarks))", + params![to_sql(host.0), side.as_str(), path], + )?; + Ok(()) + } + + pub fn remove_bookmark( + &self, + host: HostId, + side: BookmarkSide, + path: &str, + ) -> rusqlite::Result<()> { + self.connection.execute( + "DELETE FROM bookmarks WHERE host_id = ?1 AND side = ?2 AND path = ?3", + params![to_sql(host.0), side.as_str(), path], + )?; + Ok(()) + } + + /// Put one pane's bookmarks in the order of `paths`. Loading groups + /// bookmarks by pane, so only the order within this pane matters. + pub fn set_bookmark_order( + &self, + host: HostId, + side: BookmarkSide, + paths: &[String], + ) -> rusqlite::Result<()> { + let transaction = self.connection.unchecked_transaction()?; + { + let mut update = transaction.prepare( + "UPDATE bookmarks SET sort_order = ?4 \ + WHERE host_id = ?1 AND side = ?2 AND path = ?3", + )?; + for (order, path) in (0_i64..).zip(paths) { + update.execute(params![to_sql(host.0), side.as_str(), path, order])?; + } + } + transaction.commit() + } + + pub fn insert_forward(&self, rule: &ForwardRule) -> rusqlite::Result<()> { + self.connection.execute( + "INSERT INTO forwards (id, host_id, name, kind, bind_host, bind_port, \ + target_host, target_port, auto_start, sort_order) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + to_sql(rule.id.0), + to_sql(rule.host.0), + rule.name.as_ref(), + rule.kind.as_str(), + rule.bind.host.as_ref(), + rule.bind.port, + rule.target.as_ref().map(|target| target.host.as_ref()), + rule.target.as_ref().map(|target| target.port), + rule.auto_start, + rule.sort_order, + ], + )?; + Ok(()) + } + + pub fn update_forward(&self, rule: &ForwardRule) -> rusqlite::Result<()> { + self.connection.execute( + "UPDATE forwards SET host_id = ?2, name = ?3, kind = ?4, bind_host = ?5, \ + bind_port = ?6, target_host = ?7, target_port = ?8, auto_start = ?9, \ + sort_order = ?10 WHERE id = ?1", + params![ + to_sql(rule.id.0), + to_sql(rule.host.0), + rule.name.as_ref(), + rule.kind.as_str(), + rule.bind.host.as_ref(), + rule.bind.port, + rule.target.as_ref().map(|target| target.host.as_ref()), + rule.target.as_ref().map(|target| target.port), + rule.auto_start, + rule.sort_order, + ], + )?; + Ok(()) + } + + pub fn remove_forward(&self, id: ForwardId) -> rusqlite::Result<()> { + self.connection + .execute("DELETE FROM forwards WHERE id = ?1", params![to_sql(id.0)])?; + Ok(()) + } + + pub fn insert_credential(&self, credential: &Credential) -> rusqlite::Result<()> { + self.connection.execute( + "INSERT INTO credentials (id, keychain_id, name, kind, username, key_path, sort_order) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + to_sql(credential.id.0), + credential.keychain_id.as_str(), + credential.name.as_ref(), + credential.kind.as_str(), + credential.user.as_ref(), + credential.key_path.as_deref(), + credential.sort_order, + ], + )?; + Ok(()) + } + + /// Rewrite a credential. The hosts using it have no user name of + /// their own here, so they follow without being touched. + pub fn update_credential(&self, credential: &Credential) -> rusqlite::Result<()> { + self.connection.execute( + "UPDATE credentials SET name = ?2, kind = ?3, username = ?4, key_path = ?5, \ + sort_order = ?6 WHERE id = ?1", + params![ + to_sql(credential.id.0), + credential.name.as_ref(), + credential.kind.as_str(), + credential.user.as_ref(), + credential.key_path.as_deref(), + credential.sort_order, + ], + )?; + Ok(()) + } + + /// Delete a credential. The hosts using it go back to logging in on + /// their own with `auth`, as the credential's user, first: a credential + /// still in use cannot be deleted. + pub fn remove_credential(&self, id: CredentialId, auth: AuthKind) -> rusqlite::Result<()> { + let transaction = self.connection.unchecked_transaction()?; + transaction.execute( + "UPDATE hosts SET auth = ?2, credential_id = NULL, \ + username = (SELECT username FROM credentials WHERE id = ?1) \ + WHERE credential_id = ?1", + params![to_sql(id.0), auth.as_str()], + )?; + transaction.execute( + "DELETE FROM credentials WHERE id = ?1", + params![to_sql(id.0)], + )?; + transaction.commit() + } + + /// Record the operating system a probe found on the host. `None` clears + /// it, which is what a failed probe on a rebuilt host leaves behind. + pub fn set_host_os(&self, id: HostId, os: Option) -> rusqlite::Result<()> { + self.connection.execute( + "UPDATE hosts SET os = ?2 WHERE id = ?1", + params![to_sql(id.0), os.map(HostOs::as_str)], + )?; + Ok(()) + } + + /// Record that a host just connected, which is what orders the start + /// page's recent list across launches. + pub fn touch_connected(&self, id: HostId, at: u64) -> rusqlite::Result<()> { + self.connection.execute( + "UPDATE hosts SET last_connected_at = ?2 WHERE id = ?1", + params![to_sql(id.0), to_sql(at)], + )?; + Ok(()) + } +} + +/// The columns of a host's row that are spelled differently from the +/// model: its login and its route. +struct HostRow<'a> { + auth: &'static str, + username: Option<&'a str>, + credential: Option, + route: &'static str, + proxy_kind: Option<&'static str>, + proxy_host: Option<&'a str>, + proxy_port: Option, + proxy_username: Option<&'a str>, +} + +impl<'a> HostRow<'a> { + fn of(host: &'a Host) -> Self { + let proxy = match &host.route { + Route::Proxy(proxy) => Some(proxy), + _ => None, + }; + Self { + auth: match host.credential { + Some(_) => "credential", + None => host.auth.as_str(), + }, + // A host using a credential logs in as the credential's + // user; the copy the store keeps in memory is not stored. + username: match host.credential { + Some(_) => None, + None => Some(host.user.as_ref()), + }, + credential: host.credential.map(|id| to_sql(id.0)), + route: match host.route { + Route::Direct => "direct", + Route::Jump(_) => "jump", + Route::Proxy(_) => "proxy", + }, + proxy_kind: proxy.map(|proxy| proxy.kind.as_str()), + proxy_host: proxy.map(|proxy| proxy.host.as_ref()), + proxy_port: proxy.map(|proxy| proxy.port), + proxy_username: proxy.and_then(|proxy| proxy.user.as_deref()), + } + } +} + +/// Record `host`'s jump hosts, in place of whatever was recorded. +fn write_jumps(connection: &Connection, host: &Host) -> rusqlite::Result<()> { + let id = to_sql(host.id.0); + connection.execute("DELETE FROM host_jumps WHERE host_id = ?1", params![id])?; + if let Route::Jump(hops) = &host.route { + let mut insert = connection + .prepare("INSERT INTO host_jumps (host_id, position, jump_id) VALUES (?1, ?2, ?3)")?; + for (position, hop) in (0_i64..).zip(hops) { + insert.execute(params![id, position, hop.map(|hop| to_sql(hop.0))])?; + } + } + Ok(()) +} + +/// Bring the database to `SCHEMA_VERSION`. A new file has version 0 and gets +/// the whole schema; an older one is backed up, then taken forward one +/// version at a time. Each step is one transaction, so a file is never left +/// between versions. +/// +/// A file from a newer ShellRS is refused and left as it is: this version +/// does not know what the newer tables mean, and writing to them could lose +/// what the newer version saved. That happens after going back to an older +/// version by hand, the one way an update can be undone. A file older than +/// any step reaches back to is refused the same way. +fn migrate(connection: &Connection) -> rusqlite::Result<()> { + let mut version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + if version == 0 { + return connection.execute_batch(&format!( + "BEGIN;\n{SCHEMA}\nPRAGMA user_version = {SCHEMA_VERSION};\nCOMMIT;" + )); + } + if version > SCHEMA_VERSION { + return Err(refusal(format!( + "数据库来自更新版本的 ShellRS(结构版本 {version},本版本支持到 \ + {SCHEMA_VERSION}),请安装最新版本" + ))); + } + let oldest = STEPS.first().map_or(SCHEMA_VERSION, |(from, _)| *from); + if version < oldest { + return Err(refusal(format!( + "数据库来自 ShellRS 的早期开发版本(结构版本 {version}),无法升级" + ))); + } + if version < SCHEMA_VERSION { + back_up(connection, version); + } + for (from, step) in STEPS { + if version == from { + match step { + Step::Sql(sql) => connection.execute_batch(sql)?, + Step::Code(change) => change(connection)?, + } + version = from + 1; + } + } + Ok(()) +} + +/// An error that refuses a file without touching it. +fn refusal(message: String) -> rusqlite::Error { + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR), + Some(message), + ) +} + +/// Copy the database to `.v.bak` before migrating it, so the +/// version that wrote it can still open its data. `VACUUM INTO` writes a +/// consistent copy; copying the file would miss what is still in the WAL. +/// A failed backup does not stop the migration: the data is still there, +/// only the way back is not. +fn back_up(connection: &Connection, version: i64) { + let Some(path) = connection.path().filter(|path| !path.is_empty()) else { + return; + }; + let backup = format!("{path}.v{version}.bak"); + let _ = std::fs::remove_file(&backup); + if let Err(error) = connection.execute("VACUUM INTO ?1", [&backup]) { + eprintln!("shellrs: 迁移前无法备份数据库:{error}"); + } +} + +/// Give every host without a [`PublicId`] one, which is any row added by +/// hand with `sqlite3`. Runs at every open; once every host has an id it +/// is one query that finds nothing. +fn fill_missing_public_ids(connection: &mut Connection) -> rusqlite::Result<()> { + let missing = connection + .prepare("SELECT id FROM hosts WHERE public_id IS NULL")? + .query_map([], |row| row.get::<_, i64>(0))? + .collect::>>()?; + if missing.is_empty() { + return Ok(()); + } + let mut taken = connection + .prepare("SELECT public_id FROM hosts WHERE public_id IS NOT NULL")? + .query_map([], |row| row.get::<_, String>(0).map(PublicId::from_stored))? + .collect::>>()?; + let transaction = connection.transaction()?; + { + let mut update = transaction.prepare("UPDATE hosts SET public_id = ?2 WHERE id = ?1")?; + for id in missing { + let public_id = PublicId::generate_unused(|candidate| taken.contains(candidate)); + update.execute(params![id, public_id.as_str()])?; + taken.insert(public_id); + } + } + transaction.commit() +} + +/// Seconds since the Unix epoch, for `touch_connected`. +pub fn now_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn group(id: u64, name: &str, parent: Option) -> HostGroup { + HostGroup::new(GroupId(id), GroupDraft::new(name, parent.map(GroupId))) + } + + fn host(id: u64, name: &str, group: Option) -> Host { + Host::new( + HostId(id), + HostDraft::new( + name, + "10.0.0.1", + 22, + "root", + AuthKind::Password, + group.map(GroupId), + ), + ) + } + + #[test] + fn a_fresh_database_is_empty_and_at_the_current_version() { + let db = HostDatabase::in_memory().unwrap(); + let version: i64 = db + .connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + let data = db.load().unwrap(); + assert!(data.groups.is_empty()); + assert!(data.hosts.is_empty()); + assert!(data.recent.is_empty()); + assert!(data.forwards.is_empty()); + } + + #[test] + fn a_database_from_a_newer_shellrs_is_refused_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + drop(HostDatabase::open(&path).unwrap()); + let newer = SCHEMA_VERSION + 1; + Connection::open(&path) + .unwrap() + .pragma_update(None, "user_version", newer) + .unwrap(); + + let Err(error) = HostDatabase::open(&path) else { + panic!("a newer database opened"); + }; + assert!(error.to_string().contains("更新版本的 ShellRS"), "{error}"); + let version: i64 = Connection::open(&path) + .unwrap() + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, newer); + assert!(!dir.path().join(format!("shellrs.db.v{newer}.bak")).exists()); + } + + #[test] + fn a_new_or_current_database_needs_no_backup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + drop(HostDatabase::open(&path).unwrap()); + drop(HostDatabase::open(&path).unwrap()); + let files: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.ends_with(".bak")) + .collect(); + assert!(files.is_empty(), "{files:?}"); + } + + /// Every table and index as SQLite recorded its definition, with the + /// layout of the text taken out: a column added with `ALTER TABLE ADD + /// COLUMN` is spelled `x, y …` where the schema writes `x,\n y …`. + fn schema_of(connection: &Connection) -> Vec<(String, String)> { + connection + .prepare("SELECT name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY name") + .unwrap() + .query_map([], |row| { + Ok((row.get(0)?, normalized_sql(&row.get::<_, String>(1)?))) + }) + .unwrap() + .collect::>>() + .unwrap() + } + + fn normalized_sql(sql: &str) -> String { + sql.split_whitespace() + .collect::>() + .join(" ") + .replace(" ,", ",") + .replace("( ", "(") + .replace(" )", ")") + } + + #[test] + fn a_column_added_later_matches_the_schema() { + // What a later version does to a file it finds: the new column goes + // where the schema puts it, after the last column, before the + // table's own constraints. + let old = Connection::open_in_memory().unwrap(); + old.execute_batch( + "CREATE TABLE t ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + CHECK (name <> '') +) STRICT; +ALTER TABLE t ADD COLUMN notes TEXT NOT NULL DEFAULT '';", + ) + .unwrap(); + let new = Connection::open_in_memory().unwrap(); + new.execute_batch( + "CREATE TABLE t ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + CHECK (name <> '') +) STRICT;", + ) + .unwrap(); + assert_eq!(schema_of(&old), schema_of(&new)); + } + + #[test] + fn a_database_from_an_early_development_build_is_refused_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + Connection::open(&path) + .unwrap() + .execute_batch( + "CREATE TABLE sessions (id INTEGER PRIMARY KEY); PRAGMA user_version = 12;", + ) + .unwrap(); + let Err(error) = HostDatabase::open(&path) else { + panic!("a version 12 database opened"); + }; + assert!(error.to_string().contains("早期开发版本"), "{error}"); + let version: i64 = Connection::open(&path) + .unwrap() + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 12); + assert!(!dir.path().join("shellrs.db.v12.bak").exists()); + } + + #[test] + fn notes_round_trip_and_go_with_their_host() { + let db = HostDatabase::in_memory().unwrap(); + let mut noted = host(1, "web", None); + noted.notes = "机房 A,到期 2027-03\n负责人:张三".into(); + db.insert_host(¬ed).unwrap(); + db.insert_host(&host(2, "db", None)).unwrap(); + let notes = |db: &HostDatabase| -> Vec { + db.load() + .unwrap() + .hosts + .into_iter() + .map(|host| host.notes.to_string()) + .collect() + }; + assert_eq!( + notes(&db), + [ + "机房 A,到期 2027-03\n负责人:张三".to_string(), + String::new() + ] + ); + + db.update_host(&host(1, "web", None)).unwrap(); + assert_eq!(notes(&db), [String::new(), String::new()]); + } + + fn routed(mut host: Host, route: Route) -> Host { + host.route = route; + host + } + + #[test] + fn routes_round_trip_and_a_deleted_jump_host_leaves_its_place() { + let db = HostDatabase::in_memory().unwrap(); + let proxy = + Route::Proxy(ProxySettings::new(ProxyKind::Socks5, "127.0.0.1", 7890).with_user("me")); + let hosts = [ + host(1, "阿里云99", None), + host(2, "禅道", None), + routed( + host(3, "db", None), + Route::Jump(vec![Some(HostId(1)), Some(HostId(2))]), + ), + routed(host(4, "abroad", None), proxy.clone()), + routed( + host(5, "http", None), + Route::Proxy(ProxySettings::new(ProxyKind::Http, "proxy.test", 8080)), + ), + ]; + for host in &hosts { + db.insert_host(host).unwrap(); + } + let routes = |db: &HostDatabase| -> Vec { + db.load() + .unwrap() + .hosts + .into_iter() + .map(|host| host.route) + .collect() + }; + assert_eq!( + routes(&db), + hosts.iter().map(|s| s.route.clone()).collect::>() + ); + + // Updating rewrites the route, and the order of the hops with it. + db.update_host(&routed( + host(3, "db", None), + Route::Jump(vec![Some(HostId(2)), Some(HostId(1))]), + )) + .unwrap(); + db.update_host(&host(4, "abroad", None)).unwrap(); + assert_eq!( + routes(&db)[2..4], + [ + Route::Jump(vec![Some(HostId(2)), Some(HostId(1))]), + Route::Direct + ] + ); + + // The jump host goes; its place stays, empty. Its own route goes + // with a host that is deleted. + db.remove_host(HostId(1)).unwrap(); + db.remove_host(HostId(5)).unwrap(); + assert_eq!( + routes(&db), + [ + Route::Direct, + Route::Jump(vec![Some(HostId(2)), None]), + Route::Direct + ] + ); + // A route that is no longer a proxy keeps none of the proxy's columns. + let proxies: i64 = db + .connection + .query_row( + "SELECT COUNT(*) FROM hosts WHERE proxy_host IS NOT NULL", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(proxies, 0); + } + + #[test] + fn the_database_refuses_a_host_that_jumps_through_itself() { + let db = HostDatabase::in_memory().unwrap(); + let looped = routed(host(1, "web", None), Route::Jump(vec![Some(HostId(1))])); + assert!(db.insert_host(&looped).is_err()); + // The transaction took the host with it. + assert!(db.load().unwrap().hosts.is_empty()); + } + + fn credential(id: u64, name: &str, kind: CredentialKind) -> Credential { + let draft = CredentialDraft::new(name, kind, "deploy"); + let draft = match kind { + CredentialKind::Key => draft.with_key_path("/tmp/id_deploy"), + _ => draft, + }; + Credential::new(CredentialId(id), draft) + } + + fn using(mut host: Host, credential: &Credential) -> Host { + host.credential = Some(credential.id); + host.user = credential.user.clone(); + host.auth = AuthKind::Password; + host + } + + #[test] + fn credentials_round_trip_and_hosts_keep_their_link() { + let db = HostDatabase::in_memory().unwrap(); + let mut agent = credential(1, "agent", CredentialKind::Agent); + agent.sort_order = 1; + db.insert_credential(&agent).unwrap(); + let key = credential(2, "部署", CredentialKind::Key); + db.insert_credential(&key).unwrap(); + let web = using(host(1, "web", None), &key); + db.insert_host(&web).unwrap(); + db.insert_host(&host(2, "db", None)).unwrap(); + + let data = db.load().unwrap(); + // `sort_order` first, then id. + assert_eq!(data.credentials, [key.clone(), agent.clone()]); + assert_eq!(data.hosts[0].credential, Some(key.id)); + assert_eq!(data.hosts[1].credential, None); + + // Moving a host to another credential replaces its link; leaving it + // takes the link away. + db.update_host(&using(web.clone(), &agent)).unwrap(); + assert_eq!(db.load().unwrap().hosts[0].credential, Some(agent.id)); + let mut manual = web.clone(); + manual.credential = None; + db.update_host(&manual).unwrap(); + assert_eq!(db.load().unwrap().hosts[0].credential, None); + + // A host using a credential keeps no user name of its own: the + // credential's is the only copy on disk. + db.update_host(&web).unwrap(); + let mut renamed = key.clone(); + renamed.name = "生产部署".into(); + renamed.user = "admin".into(); + db.update_credential(&renamed).unwrap(); + let data = db.load().unwrap(); + assert_eq!(data.credentials[0], renamed); + assert_eq!(data.hosts[0].user.as_ref(), ""); + assert_eq!(data.hosts[1].user.as_ref(), "root"); + + // A credential still in use cannot simply go. + assert!( + db.connection + .execute("DELETE FROM credentials WHERE id = ?1", [to_sql(key.id.0)]) + .is_err() + ); + } + + #[test] + fn removing_a_credential_leaves_its_hosts_on_their_own_and_drops_the_link() { + let db = HostDatabase::in_memory().unwrap(); + let key = credential(1, "部署", CredentialKind::Key); + db.insert_credential(&key).unwrap(); + db.insert_host(&using(host(1, "web", None), &key)).unwrap(); + db.insert_host(&host(2, "db", None)).unwrap(); + + db.remove_credential(key.id, AuthKind::NoPassword).unwrap(); + let data = db.load().unwrap(); + assert!(data.credentials.is_empty()); + assert_eq!(data.hosts[0].credential, None); + assert_eq!(data.hosts[0].auth, AuthKind::NoPassword); + assert_eq!(data.hosts[0].user.as_ref(), "deploy"); + // A host that never used it is untouched. + assert_eq!(data.hosts[1].auth, AuthKind::Password); + } + + #[test] + fn the_database_refuses_a_credential_whose_key_file_does_not_fit_its_kind() { + let db = HostDatabase::in_memory().unwrap(); + let mut key_without_file = credential(1, "部署", CredentialKind::Key); + key_without_file.key_path = None; + assert!(db.insert_credential(&key_without_file).is_err()); + + let mut password_with_file = credential(2, "运维", CredentialKind::Password); + password_with_file.key_path = Some("/tmp/id".into()); + assert!(db.insert_credential(&password_with_file).is_err()); + + // A host cannot use a credential that is not there. + let mut orphan = host(1, "web", None); + orphan.credential = Some(CredentialId(99)); + assert!(db.insert_host(&orphan).is_err()); + // And the failed insert left no half of the host behind. + assert!(db.load().unwrap().hosts.is_empty()); + } + + fn forward(id: u64, host: u64, kind: ForwardKind, port: u16) -> ForwardRule { + let target = kind + .has_target() + .then(|| ForwardEndpoint::new("db.internal", 3306)); + ForwardRule::new( + ForwardId(id), + ForwardDraft::new( + kind, + HostId(host), + ForwardEndpoint::new("127.0.0.1", port), + target, + ), + ) + } + + #[test] + fn forwards_round_trip_in_list_order_and_go_with_their_host() { + let db = HostDatabase::in_memory().unwrap(); + db.insert_group(&group(1, "生产", None)).unwrap(); + db.insert_host(&host(1, "web", Some(1))).unwrap(); + db.insert_host(&host(2, "db", None)).unwrap(); + + let mut local = forward(1, 1, ForwardKind::Local, 8080); + local.name = "数据库".into(); + local.auto_start = true; + local.sort_order = 1; + db.insert_forward(&local).unwrap(); + let socks = forward(2, 2, ForwardKind::Dynamic, 1080); + db.insert_forward(&socks).unwrap(); + let remote = forward(3, 1, ForwardKind::Remote, 9000); + db.insert_forward(&remote).unwrap(); + + // `sort_order` first, then id: the dynamic and remote rules are at 0. + assert_eq!( + db.load().unwrap().forwards, + [socks.clone(), remote.clone(), local.clone()] + ); + + let mut edited = local.clone(); + edited.kind = ForwardKind::Dynamic; + edited.target = None; + edited.host = HostId(2); + edited.bind = ForwardEndpoint::new("0.0.0.0", 1081); + edited.auto_start = false; + db.update_forward(&edited).unwrap(); + assert_eq!(db.load().unwrap().forwards[2], edited); + + db.remove_forward(ForwardId(2)).unwrap(); + assert_eq!(db.load().unwrap().forwards, [remote, edited.clone()]); + + // Deleting a group takes its hosts, and they take their forwards. + db.remove_group(GroupId(1)).unwrap(); + assert_eq!(db.load().unwrap().forwards, [edited]); + db.remove_host(HostId(2)).unwrap(); + assert!(db.load().unwrap().forwards.is_empty()); + } + + #[test] + fn the_database_refuses_a_forward_whose_target_does_not_fit_its_kind() { + let db = HostDatabase::in_memory().unwrap(); + db.insert_host(&host(1, "web", None)).unwrap(); + + let mut dynamic_with_target = forward(1, 1, ForwardKind::Dynamic, 1080); + dynamic_with_target.target = Some(ForwardEndpoint::new("db", 3306)); + assert!(db.insert_forward(&dynamic_with_target).is_err()); + + let mut local_without_target = forward(2, 1, ForwardKind::Local, 8080); + local_without_target.target = None; + assert!(db.insert_forward(&local_without_target).is_err()); + + // A rule must go through a host that exists. + assert!( + db.insert_forward(&forward(3, 99, ForwardKind::Local, 8080)) + .is_err() + ); + } + + #[test] + fn round_trips_nested_groups_and_hosts() { + let db = HostDatabase::in_memory().unwrap(); + db.insert_group(&group(1, "生产", None)).unwrap(); + db.insert_group(&group(2, "数据库", Some(1))).unwrap(); + db.insert_host(&host(1, "web-01", Some(1))).unwrap(); + db.insert_host(&host(2, "db-01", Some(2))).unwrap(); + // A host at the root of the tree, outside every group. + db.insert_host(&host(3, "jump", None)).unwrap(); + + let data = db.load().unwrap(); + assert_eq!(data.groups.len(), 2); + assert_eq!(data.groups[1].parent, Some(GroupId(1))); + let names: Vec<_> = data.hosts.iter().map(|s| s.name.as_ref()).collect(); + assert_eq!(names, ["web-01", "db-01", "jump"]); + assert_eq!(data.hosts[2].group, None); + assert_eq!(data.hosts[0].auth, AuthKind::Password); + // Connection state is runtime only: everything loads disconnected. + assert!(data.hosts.iter().all(|s| !s.state.is_connected())); + } + + #[test] + fn updating_rewrites_the_editable_fields() { + let db = HostDatabase::in_memory().unwrap(); + db.insert_group(&group(1, "生产", None)).unwrap(); + db.insert_group(&group(2, "测试", None)).unwrap(); + db.insert_host(&host(1, "web-01", Some(1))).unwrap(); + + let mut moved = host(1, "web-01", Some(2)); + moved.port = 2222; + moved.auth = AuthKind::NoPassword; + db.update_host(&moved).unwrap(); + db.update_group(&group(2, "预发", None)).unwrap(); + + let data = db.load().unwrap(); + assert_eq!(data.hosts[0].port, 2222); + assert_eq!(data.hosts[0].auth, AuthKind::NoPassword); + assert_eq!(data.hosts[0].group, Some(GroupId(2))); + assert_eq!(data.groups[1].name.as_ref(), "预发"); + } + + #[test] + fn removing_a_group_cascades_to_subgroups_and_their_hosts() { + let db = HostDatabase::in_memory().unwrap(); + db.insert_group(&group(1, "生产", None)).unwrap(); + db.insert_group(&group(2, "数据库", Some(1))).unwrap(); + db.insert_group(&group(3, "测试", None)).unwrap(); + db.insert_host(&host(1, "web-01", Some(1))).unwrap(); + db.insert_host(&host(2, "db-01", Some(2))).unwrap(); + db.insert_host(&host(3, "qa", Some(3))).unwrap(); + db.insert_host(&host(4, "jump", None)).unwrap(); + + db.remove_group(GroupId(1)).unwrap(); + + let data = db.load().unwrap(); + let groups: Vec<_> = data.groups.iter().map(|g| g.name.as_ref()).collect(); + assert_eq!(groups, ["测试"]); + let hosts: Vec<_> = data.hosts.iter().map(|s| s.name.as_ref()).collect(); + assert_eq!(hosts, ["qa", "jump"]); + } + + #[test] + fn recent_lists_the_most_recently_connected_first() { + let db = HostDatabase::in_memory().unwrap(); + for id in 1..=3 { + db.insert_host(&host(id, &format!("s{id}"), None)).unwrap(); + } + db.touch_connected(HostId(1), 100).unwrap(); + db.touch_connected(HostId(3), 300).unwrap(); + // Host 2 never connected and stays out of the list. + assert_eq!( + db.load().unwrap().recent, + [HostId(3), HostId(1)], + "newest connection first" + ); + + db.touch_connected(HostId(1), 400).unwrap(); + assert_eq!(db.load().unwrap().recent, [HostId(1), HostId(3)]); + } + + #[test] + fn reopening_the_same_file_keeps_the_data() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + { + let db = HostDatabase::open(&path).unwrap(); + db.insert_group(&group(1, "生产", None)).unwrap(); + db.insert_host(&host(1, "web-01", Some(1))).unwrap(); + db.touch_connected(HostId(1), 42).unwrap(); + } + let db = HostDatabase::open(&path).unwrap(); + let data = db.load().unwrap(); + assert_eq!(data.groups.len(), 1); + assert_eq!(data.hosts[0].name.as_ref(), "web-01"); + assert_eq!(data.recent, [HostId(1)]); + } + + #[test] + fn tree_order_and_parent_survive_reopening() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + { + let db = HostDatabase::open(&path).unwrap(); + db.insert_group(&group(1, "生产", None)).unwrap(); + db.insert_group(&group(2, "测试", None)).unwrap(); + db.insert_host(&host(1, "web", Some(1))).unwrap(); + db.insert_host(&host(2, "db", Some(1))).unwrap(); + let mut data = db.load().unwrap(); + data.groups[1].parent = Some(GroupId(1)); + data.groups[1].sort_order = 0; + data.hosts[0].group = Some(GroupId(2)); + data.hosts[1].sort_order = 0; + db.save_tree_order(&data.groups, &data.hosts).unwrap(); + } + let data = HostDatabase::open(&path).unwrap().load().unwrap(); + assert_eq!(data.groups[1].parent, Some(GroupId(1))); + assert_eq!(data.hosts[0].group, Some(GroupId(2))); + assert_eq!(data.hosts[1].sort_order, 0); + } + + #[test] + fn groups_start_expanded_and_then_keep_the_saved_choice() { + let database = HostDatabase::in_memory().unwrap(); + database + .connection + .execute("INSERT INTO groups (id, name) VALUES (1, '生产')", []) + .unwrap(); + + let mut group = database.load().unwrap().groups.remove(0); + assert!(group.expanded); + database.set_group_expanded(group.id, false).unwrap(); + group.name = "生产环境".into(); + group.expanded = false; + database.update_group(&group).unwrap(); + let saved = database.load().unwrap().groups.remove(0); + assert_eq!(saved.name.as_ref(), "生产环境"); + assert!(!saved.expanded); + } + + #[test] + fn a_public_id_is_saved_and_kept_through_updates() { + let db = HostDatabase::in_memory().unwrap(); + let host = host(1, "web-01", None); + db.insert_host(&host).unwrap(); + assert_eq!(db.load().unwrap().hosts[0].public_id, host.public_id); + + let mut renamed = host.clone(); + renamed.name = "web".into(); + renamed.public_id = PublicId::generate(); + db.update_host(&renamed).unwrap(); + assert_eq!(db.load().unwrap().hosts[0].public_id, host.public_id); + + // Two hosts can never share one. + let mut twin = Host::new(HostId(2), host.draft()); + twin.public_id = host.public_id.clone(); + assert!(db.insert_host(&twin).is_err()); + } + + #[test] + fn hosts_added_by_hand_get_distinct_public_ids_that_stay() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + HostDatabase::open(&path) + .unwrap() + .connection + .execute( + "INSERT INTO hosts (id, name, address, port, username, auth) \ + VALUES (1, 'web', 'example.test', 22, 'root', 'password'), \ + (2, 'db', 'example.test', 22, 'root', 'password')", + [], + ) + .unwrap(); + + let ids = || -> Vec { + let data = HostDatabase::open(&path).unwrap().load().unwrap(); + data.hosts.into_iter().map(|s| s.public_id).collect() + }; + let first = ids(); + assert_eq!(first.len(), 2); + assert_ne!(first[0], first[1]); + assert!(first.iter().all(|id| id.as_str().len() == 16)); + // Assigned once, not again at every open. + assert_eq!(ids(), first); + } + + #[test] + fn the_detected_operating_system_is_read_back() { + let db = HostDatabase::in_memory().unwrap(); + let host = Host::new( + HostId(1), + HostDraft::new("web", "10.0.0.1", 22, "root", AuthKind::Password, None), + ); + db.insert_host(&host).unwrap(); + assert_eq!(db.load().unwrap().hosts[0].os, None); + + db.set_host_os(host.id, Some(HostOs::Ubuntu)).unwrap(); + assert_eq!(db.load().unwrap().hosts[0].os, Some(HostOs::Ubuntu)); + + // Renaming must not disturb it: the form does not own this column. + let mut renamed = host.clone(); + renamed.name = "web-01".into(); + db.update_host(&renamed).unwrap(); + assert_eq!(db.load().unwrap().hosts[0].os, Some(HostOs::Ubuntu)); + + db.set_host_os(host.id, None).unwrap(); + assert_eq!(db.load().unwrap().hosts[0].os, None); + } + + #[test] + fn bookmarks_keep_their_order_and_go_with_their_host() { + let db = HostDatabase::in_memory().unwrap(); + db.insert_host(&host(1, "web", None)).unwrap(); + db.insert_host(&host(2, "db", None)).unwrap(); + db.insert_bookmark(HostId(1), BookmarkSide::Remote, "/var/log") + .unwrap(); + db.insert_bookmark(HostId(1), BookmarkSide::Local, "/Users/me") + .unwrap(); + db.insert_bookmark(HostId(1), BookmarkSide::Remote, "/etc") + .unwrap(); + // Adding the same path twice keeps one bookmark in its first place. + db.insert_bookmark(HostId(1), BookmarkSide::Remote, "/var/log") + .unwrap(); + db.insert_bookmark(HostId(2), BookmarkSide::Remote, "/srv") + .unwrap(); + assert_eq!( + db.load().unwrap().bookmarks, + [ + (HostId(1), BookmarkSide::Remote, "/var/log".to_string()), + (HostId(1), BookmarkSide::Local, "/Users/me".to_string()), + (HostId(1), BookmarkSide::Remote, "/etc".to_string()), + (HostId(2), BookmarkSide::Remote, "/srv".to_string()), + ] + ); + // Reordering one pane leaves the other panes' order alone, and a + // bookmark added afterwards still goes last. + db.set_bookmark_order( + HostId(1), + BookmarkSide::Remote, + &["/etc".into(), "/var/log".into()], + ) + .unwrap(); + db.insert_bookmark(HostId(1), BookmarkSide::Remote, "/opt") + .unwrap(); + let remote: Vec<_> = db + .load() + .unwrap() + .bookmarks + .into_iter() + .filter(|(host, side, _)| *host == HostId(1) && *side == BookmarkSide::Remote) + .map(|(_, _, path)| path) + .collect(); + assert_eq!(remote, ["/etc", "/var/log", "/opt"]); + db.remove_bookmark(HostId(1), BookmarkSide::Remote, "/opt") + .unwrap(); + db.remove_bookmark(HostId(1), BookmarkSide::Remote, "/etc") + .unwrap(); + db.remove_host(HostId(2)).unwrap(); + assert_eq!( + db.load().unwrap().bookmarks, + [ + (HostId(1), BookmarkSide::Remote, "/var/log".to_string()), + (HostId(1), BookmarkSide::Local, "/Users/me".to_string()), + ] + ); + } + + #[test] + fn schema_never_contains_secret_columns() { + let db = HostDatabase::in_memory().unwrap(); + let tables = db + .connection + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap(); + for table in [ + "groups", + "hosts", + "host_jumps", + "bookmarks", + "forwards", + "credentials", + ] { + assert!(tables.contains(&table.to_string()), "{table}"); + } + let mut columns = Vec::new(); + for table in tables { + let mut statement = db + .connection + .prepare(&format!("PRAGMA table_info({table})")) + .unwrap(); + columns.extend( + statement + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::>>() + .unwrap(), + ); + } + assert!(columns.contains(&"key_path".to_string())); + assert!(!columns.iter().any(|column| { + column.contains("password") || column.contains("passphrase") || column.contains("otp") + })); + } +} diff --git a/src/session/forward.rs b/src/host/forward.rs similarity index 94% rename from src/session/forward.rs rename to src/host/forward.rs index 1badea4..9691ea6 100644 --- a/src/session/forward.rs +++ b/src/host/forward.rs @@ -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, ) -> 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, 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", "数据")); diff --git a/src/session/group_dialog.rs b/src/host/group_dialog.rs similarity index 92% rename from src/session/group_dialog.rs rename to src/host/group_dialog.rs index c8ebaf9..8b3fc40 100644 --- a/src/session/group_dialog.rs +++ b/src/host/group_dialog.rs @@ -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, + store: Entity, editing: Option, name: Entity, parent: Entity>>, @@ -30,7 +30,7 @@ impl GroupForm { pub fn new( editing: Option, default_parent: Option, - store: Entity, + store: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -151,7 +151,7 @@ impl Render for GroupForm { pub fn open_group_dialog( editing: Option, default_parent: Option, - store: Entity, + store: Entity, 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 { - let contents = match (sessions, subgroups) { +fn describe_contents(hosts: usize, subgroups: usize, closes_tabs: bool) -> Option { + 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 标签会一并关闭。"); diff --git a/src/session/session_dialog.rs b/src/host/host_dialog.rs similarity index 90% rename from src/session/session_dialog.rs rename to src/host/host_dialog.rs index a2b8669..212e863 100644 --- a/src/session/session_dialog.rs +++ b/src/host/host_dialog.rs @@ -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>; -/// 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, - editing: Option, +pub struct HostForm { + store: Entity, + editing: Option, name: Entity, - host: Entity, + address: Entity, port: Entity, source: AuthSource, user: Entity, @@ -188,7 +187,7 @@ pub struct SessionForm { group_ids: Vec>, route: RouteChoice, /// The jump hosts, in order; `None` is one that has been deleted. - hops: Vec>, + hops: Vec>, /// 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, @@ -208,11 +207,11 @@ pub struct SessionForm { _subscriptions: Vec, } -impl SessionForm { +impl HostForm { pub fn new( - editing: Option, + editing: Option, preselect_group: Option, - store: Entity, + store: Entity, tester: SharedConnectionTester, window: &mut Window, cx: &mut Context, @@ -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::>(), ) }; - // 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> = vec![None]; let mut group_names: Vec = 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) { + fn add_hop(&mut self, id: HostId, window: &mut Window, cx: &mut Context) { 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) -> 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) -> impl IntoElement { let theme = cx.theme(); let (muted, danger) = (theme.muted_foreground, theme.danger); - let name_of = |hop: &Option| { + let name_of = |hop: &Option| { 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]) -> Vec { +fn available_jump_hosts(hosts: &[JumpHost], hops: &[Option]) -> Vec { hosts .iter() .filter(|host| !hops.contains(&Some(host.id))) @@ -1092,7 +1084,7 @@ fn available_jump_hosts(hosts: &[JumpHost], hops: &[Option]) -> Vec) -> 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); -/// 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, +pub fn open_host_dialog( + editing: Option, preselect_group: Option, - store: Entity, + store: Entity, 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 台主机把它用作跳板主机,删除后要重新选择跳板主机才能连接。" ) diff --git a/src/session/session_panel.rs b/src/host/host_panel.rs similarity index 79% rename from src/session/session_panel.rs rename to src/host/host_panel.rs index d933fe9..39c50a0 100644 --- a/src/session/session_panel.rs +++ b/src/host/host_panel.rs @@ -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, +/// Owns the tree and search state; the host data lives in the shared +/// `HostStore`, which this panel observes. +pub struct HostPanel { + store: Entity, tree_state: Entity, search: Entity, query: String, expanded: HashSet, /// 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, - known_sessions: HashSet, + seen_hosts: HashSet, /// 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>>, - drop_target: Rc>>, - /// Session rows' tooltips, which open beside the row rather than under + right_clicked: Rc>>, + drop_target: Rc>>, + /// Host rows' tooltips, which open beside the row rather than under /// the pointer so they never cover the rows below. row_tooltip: Entity, /// The row the tooltip was last opened for. See `render_row`. - row_tooltip_owner: Rc>>, + row_tooltip_owner: Rc>>, focus_handle: FocusHandle, _subscriptions: Vec, } -impl SessionPanel { - pub fn new(store: Entity, window: &mut Window, cx: &mut Context) -> Self { - let (expanded, known_groups, known_sessions, items) = { +impl HostPanel { + pub fn new(store: Entity, window: &mut Window, cx: &mut Context) -> Self { + let (expanded, known_groups, seen_hosts, items) = { let read = store.read(cx); let expanded: HashSet = 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) { 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) -> Option { - let (created, ancestors, groups, sessions) = { + fn take_created_node(&mut self, cx: &mut Context) -> Option { + 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) { 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) { + pub fn select_node(&mut self, node: HostNode, cx: &mut Context) { 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) { + pub fn reveal_node(&mut self, node: HostNode, cx: &mut Context) { 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 { + pub fn selected_node(&self, cx: &App) -> Option { 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) { 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, ) { 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 { +fn groups_above(store: &HostStore, node: HostNode) -> Vec { 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 { .unwrap_or_default() } -impl EventEmitter for SessionPanel {} +impl EventEmitter 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) -> impl IntoElement { h_flex() .gap_1() @@ -364,10 +357,10 @@ impl Panel for SessionPanel { fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context) -> Option> { // 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) -> impl IntoElement { // Plain snapshots for the row renderer: render callbacks must not // read entities. - let host_os: Rc> = Rc::new( + let host_os: Rc> = 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> = Rc::new( + let addresses: Rc> = 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>> = 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, _, _| { + view.on_drag_move(move |_: &DragMoveEvent, _, _| { 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>>, - right_clicked: Rc>>, - drop_target: Rc>>, + right_clicked: Rc>>, + drop_target: Rc>>, can_reorder: bool, - /// `user@host:port` of every session, for the row tooltips. - addresses: Rc>, + /// `user@host:port` of every host, for the row tooltips. + addresses: Rc>, tooltip: Entity, - tooltip_owner: Rc>>, + tooltip_owner: Rc>>, } -/// 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>, ) -> HashMap { 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, - host_os: &HashMap, + host_os: &HashMap, 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, _, cx| { + .on_drag_move(move |event: &DragMoveEvent, _, 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::(move |style, drag, _, cx| { + .drag_over::(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) -> 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>, ) -> 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, menu: PopupMenu) -> PopupMenu { +fn build_context_menu(node: Option, 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, 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, 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), diff --git a/src/session/login.rs b/src/host/login.rs similarity index 81% rename from src/session/login.rs rename to src/host/login.rs index c9e506d..d4c85e3 100644 --- a/src/session/login.rs +++ b/src/host/login.rs @@ -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, + login: Box, }, /// 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, @@ -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); } } diff --git a/src/host/mod.rs b/src/host/mod.rs new file mode 100644 index 0000000..6c97ee0 --- /dev/null +++ b/src/host/mod.rs @@ -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}; diff --git a/src/session/model.rs b/src/host/model.rs similarity index 84% rename from src/session/model.rs rename to src/host/model.rs index a1475f9..87e159a 100644 --- a/src/session/model.rs +++ b/src/host/model.rs @@ -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>), + Jump(Vec>), /// 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 + '_ { + pub fn jump_hosts(&self) -> impl Iterator + '_ { 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, /// 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, } -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::().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, /// 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, - host: impl Into, + address: impl Into, port: u16, user: impl Into, 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); } } diff --git a/src/session/outline.rs b/src/host/outline.rs similarity index 65% rename from src/session/outline.rs rename to src/host/outline.rs index e4698d7..e882ef1 100644 --- a/src/session/outline.rs +++ b/src/host/outline.rs @@ -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 { + pub fn host_id(self) -> Option { match self { - SessionNode::Session(id) => Some(id), - SessionNode::Group(_) => None, + HostNode::Host(id) => Some(id), + HostNode::Group(_) => None, } } pub fn group_id(self) -> Option { 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, ) -> Vec { 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) -> Vec<&SessionGroup> { +fn child_groups(groups: &[HostGroup], parent: Option) -> Vec<&HostGroup> { let mut children: Vec<_> = groups .iter() .filter(|group| group.parent == parent) @@ -98,8 +98,8 @@ fn child_groups(groups: &[SessionGroup], parent: Option) -> Vec<&Sessio fn items_under( parent: Option, depth: usize, - groups: &[SessionGroup], - sessions: &[Session], + groups: &[HostGroup], + hosts: &[Host], query: &str, filtering: bool, expanded: &HashSet, @@ -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, 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 { + fn all_groups(store: &HostStore) -> HashSet { 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"]); } } diff --git a/src/session/private_key.rs b/src/host/private_key.rs similarity index 100% rename from src/session/private_key.rs rename to src/host/private_key.rs diff --git a/src/session/secret_fields.rs b/src/host/secret_fields.rs similarity index 100% rename from src/session/secret_fields.rs rename to src/host/secret_fields.rs diff --git a/src/session/store.rs b/src/host/store.rs similarity index 72% rename from src/session/store.rs rename to src/host/store.rs index aa7cf63..945d580 100644 --- a/src/session/store.rs +++ b/src/host/store.rs @@ -11,20 +11,22 @@ use super::database::now_seconds; use super::private_key::{is_kept_in, write_key_file}; use super::{ AuthKind, BookmarkSide, ConnectionState, Credential, CredentialDraft, CredentialId, - CredentialKind, ForwardDraft, ForwardId, ForwardRule, GroupDraft, GroupId, HostOs, JumpLogin, - LoginRoute, NodeDrop, ProxyLogin, PublicId, Route, Session, SessionDatabase, SessionDraft, - SessionGroup, SessionId, SessionLogin, SessionNode, StoredData, + CredentialKind, ForwardDraft, ForwardId, ForwardRule, GroupDraft, GroupId, Host, HostDatabase, + HostDraft, HostGroup, HostId, HostLogin, HostNode, HostOs, JumpLogin, LoginRoute, NodeDrop, + ProxyLogin, PublicId, Route, StoredData, }; -/// The single source of truth for sessions, groups, the rules that belong -/// to sessions (bookmarks, port forwards) and the credentials sessions log in +/// The single source of truth for hosts, groups, the rules that belong +/// to hosts (bookmarks, port forwards) and the credentials hosts log in /// with. Created once by the workspace and shared with every panel and /// dialog; consumers observe it. /// -/// A session using a credential keeps a copy of the credential's user name, -/// so everything that shows or keys by `user@host:port` reads the session +/// A host using a credential keeps a copy of the credential's user name, +/// so everything that shows or keys by `user@host:port` reads the host /// alone. The store keeps the copy in step: it takes it from the credential -/// when a session is saved and rewrites it when the credential changes. +/// when a host is saved or loaded and rewrites it when the credential +/// changes. The copy lives in memory only; the database keeps the user name +/// on the credential alone. /// /// Mutators that take a `Context` notify observers and mirror the change into /// the database; the `*_unnotified` variants are pure in-memory and exist for @@ -32,22 +34,22 @@ use super::{ /// notification. /// /// Memory is authoritative. A failed write costs persistence, never the edit: -/// the change stands and a `SessionStoreEvent::PersistFailed` goes out so the +/// the change stands and a `HostStoreEvent::PersistFailed` goes out so the /// workspace can tell the user. /// /// Passwords and private-key passphrases never reach the database. They go to /// the system keychain through `secrets`, on a background thread, and report /// failures through the same event. -pub struct SessionStore { - groups: Vec, - sessions: Vec, - next_session_id: u64, +pub struct HostStore { + groups: Vec, + hosts: Vec, + next_host_id: u64, next_group_id: u64, - active: Option, - /// Sessions in the order they last connected, most recent first. - recent: Vec, - /// SFTP bookmarks per session and pane, in the order they were added. - bookmarks: HashMap<(SessionId, BookmarkSide), Vec>, + active: Option, + /// Hosts in the order they last connected, most recent first. + recent: Vec, + /// SFTP bookmarks per host and pane, in the order they were added. + bookmarks: HashMap<(HostId, BookmarkSide), Vec>, /// Port-forwarding rules, in the order the forward list shows them. forwards: Vec, next_forward_id: u64, @@ -55,7 +57,7 @@ pub struct SessionStore { credentials: Vec, next_credential_id: u64, /// `None` for a memory-only store, as used by tests. - database: Option, + database: Option, /// Where passwords go. Defaults to a store that keeps nothing, so unit /// tests never touch the machine's keychain. secrets: SharedSecretStore, @@ -65,32 +67,32 @@ pub struct SessionStore { key_dir: Option, } -/// How many sessions the start page lists as recently connected. +/// How many hosts the start page lists as recently connected. const MAX_RECENT: usize = 10; /// What the store tells the workspace about, beyond plain change notification. #[derive(Clone, Debug, PartialEq, Eq)] -pub enum SessionStoreEvent { +pub enum HostStoreEvent { /// A change was applied in memory but could not be written to disk. PersistFailed(SharedString), - /// A live terminal must reconnect because the way the session logs in + /// A live terminal must reconnect because the way the host logs in /// changed: its endpoint, its own authentication, the credential it /// uses, or the way it is reached, a jump host's login included. - ConnectionSettingsChanged(SessionId), + ConnectionSettingsChanged(HostId), /// A forwarding rule now listens or connects somewhere else, so a /// running forward has to restart to follow it. ForwardSettingsChanged(ForwardId), } -impl EventEmitter for SessionStore {} +impl EventEmitter for HostStore {} -impl SessionStore { +impl HostStore { /// An empty memory-only store. pub fn empty() -> Self { Self { groups: Vec::new(), - sessions: Vec::new(), - next_session_id: 1, + hosts: Vec::new(), + next_host_id: 1, next_group_id: 1, active: None, recent: Vec::new(), @@ -121,10 +123,10 @@ impl SessionStore { /// Read everything back from `database` and keep writing to it. Ids carry /// over from disk, so the allocators resume past the largest stored id. - pub fn load(database: SessionDatabase) -> rusqlite::Result { + pub fn load(database: HostDatabase) -> rusqlite::Result { let StoredData { groups, - mut sessions, + mut hosts, mut recent, bookmarks: stored_bookmarks, forwards, @@ -132,28 +134,26 @@ impl SessionStore { } = database.load()?; recent.truncate(MAX_RECENT); let mut bookmarks: HashMap<_, Vec> = HashMap::new(); - for (session, side, path) in stored_bookmarks { - bookmarks.entry((session, side)).or_default().push(path); + for (host, side, path) in stored_bookmarks { + bookmarks.entry((host, side)).or_default().push(path); } - // A write that failed after the fact can leave a host's copy of its - // credential's user name behind, or a link to a credential this - // build could not read. Memory is what counts, so it is put right - // here and reaches the file with the host's next save. - for session in &mut sessions { - let draft = normalized(&credentials, session.draft()); - session.user = draft.user; - session.auth = draft.auth; - session.credential = draft.credential; + // A host using a credential has no user name of its own on disk: + // memory keeps a copy of the credential's, taken here. + for host in &mut hosts { + let draft = normalized(&credentials, host.draft()); + host.user = draft.user; + host.auth = draft.auth; + host.credential = draft.credential; } Ok(Self { next_group_id: groups.iter().map(|group| group.id.0).max().unwrap_or(0) + 1, - next_session_id: sessions.iter().map(|s| s.id.0).max().unwrap_or(0) + 1, + next_host_id: hosts.iter().map(|s| s.id.0).max().unwrap_or(0) + 1, next_forward_id: forwards.iter().map(|rule| rule.id.0).max().unwrap_or(0) + 1, next_credential_id: credentials.iter().map(|c| c.id.0).max().unwrap_or(0) + 1, forwards, credentials, groups, - sessions, + hosts, active: None, recent, bookmarks, @@ -172,7 +172,7 @@ impl SessionStore { let staging = store.insert_group_unnotified(GroupDraft::new("测试", None)); let development = store.insert_group_unnotified(GroupDraft::new("开发", None)); let drafts = [ - SessionDraft::new( + HostDraft::new( "web-01", "10.0.1.12", 22, @@ -180,7 +180,7 @@ impl SessionStore { AuthKind::Password, Some(production), ), - SessionDraft::new( + HostDraft::new( "web-02", "10.0.1.13", 22, @@ -188,7 +188,7 @@ impl SessionStore { AuthKind::Password, Some(production), ), - SessionDraft::new( + HostDraft::new( "db-01", "10.0.2.5", 22, @@ -196,7 +196,7 @@ impl SessionStore { AuthKind::Password, Some(production), ), - SessionDraft::new( + HostDraft::new( "staging-api", "10.0.9.20", 2222, @@ -204,7 +204,7 @@ impl SessionStore { AuthKind::Password, Some(staging), ), - SessionDraft::new( + HostDraft::new( "qa-runner", "10.0.9.31", 22, @@ -212,7 +212,7 @@ impl SessionStore { AuthKind::Password, Some(staging), ), - SessionDraft::new( + HostDraft::new( "dev-box", "192.168.1.20", 22, @@ -225,26 +225,26 @@ impl SessionStore { store.insert_unnotified(draft); } for name in ["web-01", "staging-api"] { - if let Some(id) = store.sessions.iter().find(|s| s.name == name).map(|s| s.id) { + if let Some(id) = store.hosts.iter().find(|s| s.name == name).map(|s| s.id) { store.set_state_unnotified(id, ConnectionState::Connected); } } store } - pub fn groups(&self) -> &[SessionGroup] { + pub fn groups(&self) -> &[HostGroup] { &self.groups } - pub fn sessions(&self) -> &[Session] { - &self.sessions + pub fn hosts(&self) -> &[Host] { + &self.hosts } - pub fn session(&self, id: SessionId) -> Option<&Session> { - self.sessions.iter().find(|s| s.id == id) + pub fn host(&self, id: HostId) -> Option<&Host> { + self.hosts.iter().find(|s| s.id == id) } - pub fn group(&self, id: GroupId) -> Option<&SessionGroup> { + pub fn group(&self, id: GroupId) -> Option<&HostGroup> { self.groups.iter().find(|g| g.id == id) } @@ -262,15 +262,15 @@ impl SessionStore { found } - /// Every session inside `id` or any group below it. What deleting the + /// Every host inside `id` or any group below it. What deleting the /// group takes with it. - pub fn sessions_under(&self, id: GroupId) -> Vec { + pub fn hosts_under(&self, id: GroupId) -> Vec { let mut doomed = self.descendant_groups(id); doomed.push(id); - self.sessions + self.hosts .iter() - .filter(|session| session.group.is_some_and(|group| doomed.contains(&group))) - .map(|session| session.id) + .filter(|host| host.group.is_some_and(|group| doomed.contains(&group))) + .map(|host| host.id) .collect() } @@ -311,59 +311,59 @@ impl SessionStore { parts } - /// The session whose tab is currently displayed, if any. - pub fn active(&self) -> Option<&Session> { - self.active.and_then(|id| self.session(id)) + /// The host whose tab is currently displayed, if any. + pub fn active(&self) -> Option<&Host> { + self.active.and_then(|id| self.host(id)) } - /// Sessions in the order they last connected, most recent first. A - /// session joins (or moves to the front) each time it becomes connected. - pub fn recent_sessions(&self) -> impl Iterator { - self.recent.iter().filter_map(|id| self.session(*id)) + /// Hosts in the order they last connected, most recent first. A + /// host joins (or moves to the front) each time it becomes connected. + pub fn recent_hosts(&self) -> impl Iterator { + self.recent.iter().filter_map(|id| self.host(*id)) } - pub fn insert(&mut self, draft: SessionDraft, cx: &mut Context) -> SessionId { + pub fn insert(&mut self, draft: HostDraft, cx: &mut Context) -> HostId { let id = self.insert_unnotified(draft); - if let Some(session) = self.session(id) { - self.persist("新建主机", cx, |db| db.insert_session(session)); + if let Some(host) = self.host(id) { + self.persist("新建主机", cx, |db| db.insert_host(host)); } cx.notify(); id } - pub fn insert_unnotified(&mut self, draft: SessionDraft) -> SessionId { + pub fn insert_unnotified(&mut self, draft: HostDraft) -> HostId { let mut draft = normalized(&self.credentials, draft); draft.route = self.normalized_route(draft.route, None); - let sort_order = self.last_session_order(draft.group, None); - let id = SessionId(self.next_session_id); - self.next_session_id += 1; - let mut session = Session::new(id, draft); - session.public_id = self.unused_public_id(); - session.sort_order = sort_order; - self.sessions.push(session); + let sort_order = self.last_host_order(draft.group, None); + let id = HostId(self.next_host_id); + self.next_host_id += 1; + let mut host = Host::new(id, draft); + host.public_id = self.unused_public_id(); + host.sort_order = sort_order; + self.hosts.push(host); id } - /// A [`PublicId`] no session in the store has yet. + /// A [`PublicId`] no host in the store has yet. fn unused_public_id(&self) -> PublicId { - PublicId::generate_unused(|id| self.sessions.iter().any(|s| &s.public_id == id)) + PublicId::generate_unused(|id| self.hosts.iter().any(|s| &s.public_id == id)) } - /// Replace the editable fields of a session; connection state is kept. - /// The session reconnects when its login changed, and so does every - /// session using it as a jump host. - pub fn update(&mut self, id: SessionId, draft: SessionDraft, cx: &mut Context) -> bool { + /// Replace the editable fields of a host; connection state is kept. + /// The host reconnects when its login changed, and so does every + /// host using it as a jump host. + pub fn update(&mut self, id: HostId, draft: HostDraft, cx: &mut Context) -> bool { let logins = self.logins(); let previous = self.login(id); let updated = self.update_unnotified(id, draft); if updated { - if let Some(session) = self.session(id) { - self.persist("保存主机", cx, |db| db.update_session(session)); + if let Some(host) = self.host(id) { + self.persist("保存主机", cx, |db| db.update_host(host)); } if let Some(previous) = previous { - // The session no longer reads the password it did, say + // The host no longer reads the password it did, say // because it moved to another endpoint, so that keychain - // entry is an orphan unless another session still uses it. + // entry is an orphan unless another host still uses it. self.forget_passwords_of(&previous, cx); } self.emit_changed_logins(logins, cx); @@ -372,25 +372,25 @@ impl SessionStore { updated } - pub fn update_unnotified(&mut self, id: SessionId, draft: SessionDraft) -> bool { + pub fn update_unnotified(&mut self, id: HostId, draft: HostDraft) -> bool { let mut draft = normalized(&self.credentials, draft); draft.route = self.normalized_route(draft.route, Some(id)); - let new_order = self.last_session_order(draft.group, Some(id)); - let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) else { + let new_order = self.last_host_order(draft.group, Some(id)); + let Some(host) = self.hosts.iter_mut().find(|s| s.id == id) else { return false; }; // The form owns none of these, so rebuilding from the draft must not // drop them. - let state = session.state; - let os = session.os; - let sort_order = session.sort_order; - let old_group = session.group; - let public_id = session.public_id.clone(); - *session = Session::new(id, draft); - session.public_id = public_id; - session.state = state; - session.os = os; - session.sort_order = if session.group == old_group { + let state = host.state; + let os = host.os; + let sort_order = host.sort_order; + let old_group = host.group; + let public_id = host.public_id.clone(); + *host = Host::new(id, draft); + host.public_id = public_id; + host.state = state; + host.os = os; + host.sort_order = if host.group == old_group { sort_order } else { new_order @@ -398,14 +398,14 @@ impl SessionStore { true } - /// Delete a session. The sessions that went through it keep its place in + /// Delete a host. The hosts that went through it keep its place in /// their jump hosts, empty, and are not reconnected: deleting a host does /// not drop a working connection behind it. - pub fn remove(&mut self, id: SessionId, cx: &mut Context) -> bool { + pub fn remove(&mut self, id: HostId, cx: &mut Context) -> bool { let login = self.login(id); let removed = self.remove_unnotified(id); if removed { - self.persist("删除主机", cx, |db| db.remove_session(id)); + self.persist("删除主机", cx, |db| db.remove_host(id)); if let Some(login) = login { self.forget_passwords_of(&login, cx); } @@ -414,12 +414,12 @@ impl SessionStore { removed } - pub fn remove_unnotified(&mut self, id: SessionId) -> bool { - let before = self.sessions.len(); - self.sessions.retain(|s| s.id != id); + pub fn remove_unnotified(&mut self, id: HostId) -> bool { + let before = self.hosts.len(); + self.hosts.retain(|s| s.id != id); // As the database's `ON DELETE SET NULL` does. - for session in &mut self.sessions { - if let Route::Jump(hops) = &mut session.route { + for host in &mut self.hosts { + if let Route::Jump(hops) = &mut host.route { for hop in hops.iter_mut().filter(|hop| **hop == Some(id)) { *hop = None; } @@ -427,46 +427,46 @@ impl SessionStore { } self.recent.retain(|recent| *recent != id); // The database drops both through `ON DELETE CASCADE`. - self.bookmarks.retain(|(session, _), _| *session != id); - self.forwards.retain(|rule| rule.session != id); + self.bookmarks.retain(|(host, _), _| *host != id); + self.forwards.retain(|rule| rule.host != id); if self.active == Some(id) { self.active = None; } - self.sessions.len() != before + self.hosts.len() != before } - /// Copy a session as ` 副本`, placed right after the original. - pub fn duplicate(&mut self, id: SessionId, cx: &mut Context) -> Option { + /// Copy a host as ` 副本`, placed right after the original. + pub fn duplicate(&mut self, id: HostId, cx: &mut Context) -> Option { let copy = self.duplicate_unnotified(id)?; - if let Some(session) = self.session(copy) { - self.persist("复制主机", cx, |db| db.insert_session(session)); + if let Some(host) = self.host(copy) { + self.persist("复制主机", cx, |db| db.insert_host(host)); self.persist("保存主机顺序", cx, |db| { - db.save_tree_order(&self.groups, &self.sessions) + db.save_tree_order(&self.groups, &self.hosts) }); } cx.notify(); Some(copy) } - pub fn duplicate_unnotified(&mut self, id: SessionId) -> Option { - let ix = self.sessions.iter().position(|s| s.id == id)?; - let mut draft = self.sessions[ix].draft(); + pub fn duplicate_unnotified(&mut self, id: HostId) -> Option { + let ix = self.hosts.iter().position(|s| s.id == id)?; + let mut draft = self.hosts[ix].draft(); draft.name = format!("{} 副本", draft.name).into(); // Same host, so the copy already knows what it will find there. - let os = self.sessions[ix].os; - let copy_id = SessionId(self.next_session_id); - self.next_session_id += 1; - let mut copy = Session::new(copy_id, draft); - // A copy is another session, so a third party must not confuse the two. + let os = self.hosts[ix].os; + let copy_id = HostId(self.next_host_id); + self.next_host_id += 1; + let mut copy = Host::new(copy_id, draft); + // A copy is another host, so a third party must not confuse the two. copy.public_id = self.unused_public_id(); copy.os = os; - copy.sort_order = self.sessions[ix].sort_order + 1; - for session in &mut self.sessions { - if session.group == copy.group && session.sort_order >= copy.sort_order { - session.sort_order += 1; + copy.sort_order = self.hosts[ix].sort_order + 1; + for host in &mut self.hosts { + if host.group == copy.group && host.sort_order >= copy.sort_order { + host.sort_order += 1; } } - self.sessions.insert(ix + 1, copy); + self.hosts.insert(ix + 1, copy); Some(copy_id) } @@ -483,7 +483,7 @@ impl SessionStore { let sort_order = self.last_group_order(draft.parent, None); let id = GroupId(self.next_group_id); self.next_group_id += 1; - let mut group = SessionGroup::new(id, draft); + let mut group = HostGroup::new(id, draft); group.sort_order = sort_order; self.groups.push(group); id @@ -520,7 +520,7 @@ impl SessionStore { let old_parent = group.parent; let old_order = group.sort_order; let expanded = group.expanded; - *group = SessionGroup::new(id, draft); + *group = HostGroup::new(id, draft); group.expanded = expanded; group.sort_order = if group.parent == old_parent { old_order @@ -530,8 +530,8 @@ impl SessionStore { true } - /// A session's SFTP bookmarks for one pane, oldest first. - pub fn bookmarks(&self, id: SessionId, side: BookmarkSide) -> &[String] { + /// A host's SFTP bookmarks for one pane, oldest first. + pub fn bookmarks(&self, id: HostId, side: BookmarkSide) -> &[String] { self.bookmarks .get(&(id, side)) .map(Vec::as_slice) @@ -540,7 +540,7 @@ impl SessionStore { pub fn add_bookmark( &mut self, - id: SessionId, + id: HostId, side: BookmarkSide, path: &str, cx: &mut Context, @@ -553,13 +553,8 @@ impl SessionStore { true } - pub fn add_bookmark_unnotified( - &mut self, - id: SessionId, - side: BookmarkSide, - path: &str, - ) -> bool { - if self.session(id).is_none() || path.is_empty() { + pub fn add_bookmark_unnotified(&mut self, id: HostId, side: BookmarkSide, path: &str) -> bool { + if self.host(id).is_none() || path.is_empty() { return false; } let list = self.bookmarks.entry((id, side)).or_default(); @@ -572,7 +567,7 @@ impl SessionStore { pub fn remove_bookmark( &mut self, - id: SessionId, + id: HostId, side: BookmarkSide, path: &str, cx: &mut Context, @@ -587,7 +582,7 @@ impl SessionStore { pub fn remove_bookmark_unnotified( &mut self, - id: SessionId, + id: HostId, side: BookmarkSide, path: &str, ) -> bool { @@ -603,7 +598,7 @@ impl SessionStore { /// 下移). pub fn move_bookmark( &mut self, - id: SessionId, + id: HostId, side: BookmarkSide, path: &str, to: usize, @@ -621,7 +616,7 @@ impl SessionStore { pub fn move_bookmark_unnotified( &mut self, - id: SessionId, + id: HostId, side: BookmarkSide, path: &str, to: usize, @@ -650,15 +645,13 @@ impl SessionStore { self.forwards.iter().find(|rule| rule.id == id) } - /// The rules that go through `session`: what deleting it takes along. - pub fn forwards_of(&self, session: SessionId) -> impl Iterator { - self.forwards - .iter() - .filter(move |rule| rule.session == session) + /// The rules that go through `host`: what deleting it takes along. + pub fn forwards_of(&self, host: HostId) -> impl Iterator { + self.forwards.iter().filter(move |rule| rule.host == host) } /// Add a rule at the end of the list. `None` when the draft names a - /// session that is not there. + /// host that is not there. pub fn insert_forward( &mut self, draft: ForwardDraft, @@ -673,7 +666,7 @@ impl SessionStore { } pub fn insert_forward_unnotified(&mut self, draft: ForwardDraft) -> Option { - self.session(draft.session)?; + self.host(draft.host)?; let sort_order = self .forwards .iter() @@ -706,14 +699,14 @@ impl SessionStore { self.persist("保存端口转发", cx, |db| db.update_forward(rule)); } if needs_restart { - cx.emit(SessionStoreEvent::ForwardSettingsChanged(id)); + cx.emit(HostStoreEvent::ForwardSettingsChanged(id)); } cx.notify(); true } pub fn update_forward_unnotified(&mut self, id: ForwardId, draft: ForwardDraft) -> bool { - if self.session(draft.session).is_none() { + if self.host(draft.host).is_none() { return false; } let Some(rule) = self.forwards.iter_mut().find(|rule| rule.id == id) else { @@ -751,30 +744,27 @@ impl SessionStore { .find(|credential| credential.id == id) } - /// The sessions that log in with `credential`. - pub fn sessions_using(&self, credential: CredentialId) -> impl Iterator { - self.sessions + /// The hosts that log in with `credential`. + pub fn hosts_using(&self, credential: CredentialId) -> impl Iterator { + self.hosts .iter() - .filter(move |session| session.credential == Some(credential)) + .filter(move |host| host.credential == Some(credential)) } - /// How a session logs in, with its credential and its jump hosts' logins + /// How a host logs in, with its credential and its jump hosts' logins /// looked up. What the terminal, SFTP, forward and CLI workers are given. - pub fn login(&self, id: SessionId) -> Option { - self.session(id).map(|session| self.login_of(session)) + pub fn login(&self, id: HostId) -> Option { + self.host(id).map(|host| self.login_of(host)) } - pub fn login_of(&self, session: &Session) -> SessionLogin { - self.direct_login(session) - .with_route(self.route_login(&session.route)) + pub fn login_of(&self, host: &Host) -> HostLogin { + self.direct_login(host) + .with_route(self.route_login(&host.route)) } - /// How `session` logs in once the connection has reached it. - fn direct_login(&self, session: &Session) -> SessionLogin { - SessionLogin::of( - session, - session.credential.and_then(|id| self.credential(id)), - ) + /// How `host` logs in once the connection has reached it. + fn direct_login(&self, host: &Host) -> HostLogin { + HostLogin::of(host, host.credential.and_then(|id| self.credential(id))) } /// `route` with every jump host's login looked up. A jump host is @@ -784,10 +774,10 @@ impl SessionStore { Route::Direct => LoginRoute::Direct, Route::Jump(hops) => LoginRoute::Jump( hops.iter() - .map(|hop| match hop.and_then(|id| self.session(id)) { - Some(session) => JumpLogin::Host { - name: session.name.to_string(), - login: Box::new(self.direct_login(session)), + .map(|hop| match hop.and_then(|id| self.host(id)) { + Some(host) => JumpLogin::Host { + name: host.name.to_string(), + login: Box::new(self.direct_login(host)), }, None => JumpLogin::Deleted, }) @@ -797,32 +787,31 @@ impl SessionStore { } } - /// How many sessions other than `ids` go through one of them as a jump + /// How many hosts other than `ids` go through one of them as a jump /// host: what deleting `ids` leaves without a way through. - pub fn jump_users(&self, ids: &[SessionId]) -> usize { - self.sessions + pub fn jump_users(&self, ids: &[HostId]) -> usize { + self.hosts .iter() - .filter(|session| { - !ids.contains(&session.id) - && session.route.jump_hosts().any(|hop| ids.contains(&hop)) + .filter(|host| { + !ids.contains(&host.id) && host.route.jump_hosts().any(|hop| ids.contains(&hop)) }) .count() } - /// Every session's login, to tell afterwards whose changed. - fn logins(&self) -> Vec<(SessionId, SessionLogin)> { - self.sessions + /// Every host's login, to tell afterwards whose changed. + fn logins(&self) -> Vec<(HostId, HostLogin)> { + self.hosts .iter() - .map(|session| (session.id, self.login_of(session))) + .map(|host| (host.id, self.login_of(host))) .collect() } - /// Tell the workspace about every session that logs in differently than + /// Tell the workspace about every host that logs in differently than /// it did in `before`, so its connections reconnect. - fn emit_changed_logins(&self, before: Vec<(SessionId, SessionLogin)>, cx: &mut Context) { + fn emit_changed_logins(&self, before: Vec<(HostId, HostLogin)>, cx: &mut Context) { for (id, login) in before { if self.login(id).is_some_and(|now| now != login) { - cx.emit(SessionStoreEvent::ConnectionSettingsChanged(id)); + cx.emit(HostStoreEvent::ConnectionSettingsChanged(id)); } } } @@ -862,7 +851,7 @@ impl SessionStore { id } - /// Replace the editable fields of a credential. Every session using it + /// Replace the editable fields of a credential. Every host using it /// takes the new user name, and those whose login changed reconnect. pub fn update_credential( &mut self, @@ -894,11 +883,8 @@ impl SessionStore { { self.forget_key_file(path, cx); } - for (session, before) in &logins { - if self - .session(*session) - .is_some_and(|s| s.credential == Some(id)) - { + for (host, before) in &logins { + if self.host(*host).is_some_and(|s| s.credential == Some(id)) { self.forget_passwords_of(before, cx); } } @@ -925,22 +911,18 @@ impl SessionStore { credential.keychain_id = keychain_id; credential.sort_order = sort_order; let user = credential.user.clone(); - for session in &mut self.sessions { - if session.credential == Some(id) { - session.user = user.clone(); + for host in &mut self.hosts { + if host.credential == Some(id) { + host.user = user.clone(); } } true } - /// Delete a credential. The sessions using it log in on their own again, + /// Delete a credential. The hosts using it log in on their own again, /// automatically and as the same user, and are returned. They are not /// reconnected: deleting a credential does not drop a working connection. - pub fn remove_credential( - &mut self, - id: CredentialId, - cx: &mut Context, - ) -> Vec { + pub fn remove_credential(&mut self, id: CredentialId, cx: &mut Context) -> Vec { let Some(credential) = self.credential(id).cloned() else { return Vec::new(); }; @@ -957,7 +939,7 @@ impl SessionStore { released } - pub fn remove_credential_unnotified(&mut self, id: CredentialId) -> Vec { + pub fn remove_credential_unnotified(&mut self, id: CredentialId) -> Vec { let Some(index) = self .credentials .iter() @@ -967,11 +949,11 @@ impl SessionStore { }; let auth = self.credentials.remove(index).kind.without_credential(); let mut released = Vec::new(); - for session in &mut self.sessions { - if session.credential == Some(id) { - session.credential = None; - session.auth = auth; - released.push(session.id); + for host in &mut self.hosts { + if host.credential == Some(id) { + host.credential = None; + host.auth = auth; + released.push(host.id); } } released @@ -1015,23 +997,18 @@ impl SessionStore { /// Move a group or host in the tree and persist its new location and /// sibling order in one transaction. - pub fn move_node( - &mut self, - source: SessionNode, - drop: NodeDrop, - cx: &mut Context, - ) -> bool { + pub fn move_node(&mut self, source: HostNode, drop: NodeDrop, cx: &mut Context) -> bool { if !self.move_node_unnotified(source, drop) { return false; } self.persist("调整主机顺序", cx, |db| { - db.save_tree_order(&self.groups, &self.sessions) + db.save_tree_order(&self.groups, &self.hosts) }); cx.notify(); true } - pub fn move_node_unnotified(&mut self, source: SessionNode, drop: NodeDrop) -> bool { + pub fn move_node_unnotified(&mut self, source: HostNode, drop: NodeDrop) -> bool { let (parent, target, after) = match drop { NodeDrop::Before(target) => (self.node_parent(target), Some(target), false), NodeDrop::After(target) => (self.node_parent(target), Some(target), true), @@ -1048,7 +1025,7 @@ impl SessionStore { return false; } match source { - SessionNode::Group(id) => { + HostNode::Group(id) => { if self.group(id).is_none() { return false; } @@ -1058,8 +1035,8 @@ impl SessionStore { return false; } let target = match target { - Some(SessionNode::Group(target)) => Some(target), - Some(SessionNode::Session(_)) => return false, + Some(HostNode::Group(target)) => Some(target), + Some(HostNode::Host(_)) => return false, None => None, }; let siblings = self @@ -1080,17 +1057,17 @@ impl SessionStore { } } } - SessionNode::Session(id) => { - if self.session(id).is_none() { + HostNode::Host(id) => { + if self.host(id).is_none() { return false; } let target = match target { - Some(SessionNode::Session(target)) => Some(target), - Some(SessionNode::Group(_)) => return false, + Some(HostNode::Host(target)) => Some(target), + Some(HostNode::Group(_)) => return false, None => None, }; let siblings = self - .sessions + .hosts .iter() .filter(|s| s.group == parent) .map(|s| (s.sort_order, s.id)) @@ -1098,12 +1075,12 @@ impl SessionStore { let Some(ids) = reordered(siblings, id, target, after) else { return false; }; - for session in &mut self.sessions { - if session.id == id { - session.group = parent; + for host in &mut self.hosts { + if host.id == id { + host.group = parent; } - if let Some(order) = ids.iter().position(|sibling| *sibling == session.id) { - session.sort_order = order as i64; + if let Some(order) = ids.iter().position(|sibling| *sibling == host.id) { + host.sort_order = order as i64; } } } @@ -1111,21 +1088,21 @@ impl SessionStore { true } - fn node_parent(&self, node: SessionNode) -> Option> { + fn node_parent(&self, node: HostNode) -> Option> { match node { - SessionNode::Group(id) => self.group(id).map(|g| g.parent), - SessionNode::Session(id) => self.session(id).map(|s| s.group), + HostNode::Group(id) => self.group(id).map(|g| g.parent), + HostNode::Host(id) => self.host(id).map(|s| s.group), } } - /// Delete a group with its subgroups and every session inside them. - /// Returns the sessions that went with it, so the workspace can close + /// Delete a group with its subgroups and every host inside them. + /// Returns the hosts that went with it, so the workspace can close /// their tabs. - pub fn remove_group(&mut self, id: GroupId, cx: &mut Context) -> Vec { + pub fn remove_group(&mut self, id: GroupId, cx: &mut Context) -> Vec { if self.group(id).is_none() { return Vec::new(); } - let passwords = self.passwords_of(&self.sessions_under(id)); + let passwords = self.passwords_of(&self.hosts_under(id)); let removed = self.remove_group_unnotified(id); // One delete mirrors the whole subtree: both foreign keys cascade. self.persist("删除分组", cx, |db| db.remove_group(id)); @@ -1136,21 +1113,21 @@ impl SessionStore { removed } - pub fn remove_group_unnotified(&mut self, id: GroupId) -> Vec { + pub fn remove_group_unnotified(&mut self, id: GroupId) -> Vec { if self.group(id).is_none() { return Vec::new(); } - let removed = self.sessions_under(id); + let removed = self.hosts_under(id); let mut doomed = self.descendant_groups(id); doomed.push(id); self.groups.retain(|group| !doomed.contains(&group.id)); - for session in &removed { - self.remove_unnotified(*session); + for host in &removed { + self.remove_unnotified(*host); } removed } - pub fn set_state(&mut self, id: SessionId, state: ConnectionState, cx: &mut Context) { + pub fn set_state(&mut self, id: HostId, state: ConnectionState, cx: &mut Context) { if !self.set_state_unnotified(id, state) { return; } @@ -1163,15 +1140,15 @@ impl SessionStore { } /// Returns whether the state changed. Becoming connected also moves the - /// session to the front of the recent list. - pub fn set_state_unnotified(&mut self, id: SessionId, state: ConnectionState) -> bool { - let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) else { + /// host to the front of the recent list. + pub fn set_state_unnotified(&mut self, id: HostId, state: ConnectionState) -> bool { + let Some(host) = self.hosts.iter_mut().find(|s| s.id == id) else { return false; }; - if session.state == state { + if host.state == state { return false; } - session.state = state; + host.state = state; if state.is_connected() { self.recent.retain(|recent| *recent != id); self.recent.insert(0, id); @@ -1182,7 +1159,7 @@ impl SessionStore { /// Record what a connection found on the host. Called after every /// successful connection, so a rebuilt machine corrects itself. - pub fn set_host_os(&mut self, id: SessionId, os: Option, cx: &mut Context) { + pub fn set_host_os(&mut self, id: HostId, os: Option, cx: &mut Context) { if !self.set_host_os_unnotified(id, os) { return; } @@ -1191,25 +1168,25 @@ impl SessionStore { } /// Returns whether the recorded system changed. - pub fn set_host_os_unnotified(&mut self, id: SessionId, os: Option) -> bool { - let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) else { + pub fn set_host_os_unnotified(&mut self, id: HostId, os: Option) -> bool { + let Some(host) = self.hosts.iter_mut().find(|s| s.id == id) else { return false; }; - if session.os == os { + if host.os == os { return false; } - session.os = os; + host.os = os; true } - pub fn set_active(&mut self, id: Option, cx: &mut Context) { + pub fn set_active(&mut self, id: Option, cx: &mut Context) { if self.active != id { self.active = id; cx.notify(); } } - /// Where passwords are read from. The session form uses this to pre-fill + /// Where passwords are read from. The host form uses this to pre-fill /// its field; the workspace hands it to the SSH provider. pub fn secrets(&self) -> SharedSecretStore { self.secrets.clone() @@ -1274,7 +1251,7 @@ impl SessionStore { if let Err(error) = std::fs::remove_file(path) && error.kind() != io::ErrorKind::NotFound { - cx.emit(SessionStoreEvent::PersistFailed( + cx.emit(HostStoreEvent::PersistFailed( format!("ShellRS 保存的私钥未能删除:{error}").into(), )); } @@ -1317,7 +1294,7 @@ impl SessionStore { .await; if let Err(error) = result { this.update(cx, |_, cx| { - cx.emit(SessionStoreEvent::PersistFailed( + cx.emit(HostStoreEvent::PersistFailed( format!("{failure}:{error}").into(), )); }) @@ -1327,9 +1304,9 @@ impl SessionStore { .detach(); } - /// The distinct password entries these sessions log in with, their + /// The distinct password entries these hosts log in with, their /// proxies' included. - fn passwords_of(&self, ids: &[SessionId]) -> Vec { + fn passwords_of(&self, ids: &[HostId]) -> Vec { let mut passwords: Vec = Vec::new(); for login in ids.iter().filter_map(|id| self.login(*id)) { for secret in [Some(login.password), login.route.proxy_password()] @@ -1345,17 +1322,17 @@ impl SessionStore { } /// Forget the endpoint and proxy passwords `login` used, those that no - /// session uses any more. - fn forget_passwords_of(&mut self, login: &SessionLogin, cx: &mut Context) { + /// host uses any more. + fn forget_passwords_of(&mut self, login: &HostLogin, cx: &mut Context) { self.forget_password(login.password.clone(), cx); if let Some(proxy) = login.route.proxy_password() { self.forget_password(proxy, cx); } } - /// Delete an endpoint's or a proxy's saved password once no session logs + /// Delete an endpoint's or a proxy's saved password once no host logs /// in with it. A credential's password is left alone: it goes with the - /// credential, not with the sessions using it. + /// credential, not with the hosts using it. fn forget_password(&mut self, secret: SecretRef, cx: &mut Context) { if matches!(secret, SecretRef::Password { .. } | SecretRef::Proxy { .. }) && !self.password_in_use(&secret) @@ -1364,12 +1341,12 @@ impl SessionStore { } } - /// Whether any session still logs in with this password entry, or goes + /// Whether any host still logs in with this password entry, or goes /// through a proxy with it. Entries are shared, so one may only be - /// cleaned up once the last session using it is gone. + /// cleaned up once the last host using it is gone. fn password_in_use(&self, secret: &SecretRef) -> bool { - self.sessions.iter().any(|session| { - let login = self.login_of(session); + self.hosts.iter().any(|host| { + let login = self.login_of(host); login.password == *secret || login.route.proxy_password().as_ref() == Some(secret) }) } @@ -1380,21 +1357,21 @@ impl SessionStore { &self, action: &str, cx: &mut Context, - write: impl FnOnce(&SessionDatabase) -> rusqlite::Result<()>, + write: impl FnOnce(&HostDatabase) -> rusqlite::Result<()>, ) { if let Some(database) = &self.database && let Err(error) = write(database) { - cx.emit(SessionStoreEvent::PersistFailed( + cx.emit(HostStoreEvent::PersistFailed( format!("{action}未能保存到本地数据库:{error}").into(), )); } } - /// The sort order that puts a session last in `group`, among the - /// sessions other than `except`. - fn last_session_order(&self, group: Option, except: Option) -> i64 { - self.sessions + /// The sort order that puts a host last in `group`, among the + /// hosts other than `except`. + fn last_host_order(&self, group: Option, except: Option) -> i64 { + self.hosts .iter() .filter(|s| Some(s.id) != except && s.group == group) .map(|s| s.sort_order) @@ -1444,17 +1421,17 @@ fn reordered( Some(ids) } -impl SessionStore { - /// `route` as the store keeps it for session `own` (`None` while it is - /// being created): no session jumps through itself, a jump host that is +impl HostStore { + /// `route` as the store keeps it for host `own` (`None` while it is + /// being created): no host jumps through itself, a jump host that is /// not there is a deleted one, and a jump with no hosts is no jump. - fn normalized_route(&self, route: Route, own: Option) -> Route { + fn normalized_route(&self, route: Route, own: Option) -> Route { match route { Route::Jump(hops) => { let hops: Vec<_> = hops .into_iter() .filter(|hop| hop.is_none() || *hop != own) - .map(|hop| hop.filter(|id| self.session(*id).is_some())) + .map(|hop| hop.filter(|id| self.host(*id).is_some())) .collect(); if hops.is_empty() { Route::Direct @@ -1467,10 +1444,10 @@ impl SessionStore { } } -/// `draft` as the store keeps it. A session using a credential logs in as +/// `draft` as the store keeps it. A host using a credential logs in as /// the credential's user and keeps nothing of its own login; one naming a /// credential that is not there logs in on its own. -fn normalized(credentials: &[Credential], mut draft: SessionDraft) -> SessionDraft { +fn normalized(credentials: &[Credential], mut draft: HostDraft) -> HostDraft { let credential = draft .credential .and_then(|id| credentials.iter().find(|credential| credential.id == id)); @@ -1489,13 +1466,13 @@ mod tests { use super::super::{ForwardEndpoint, ForwardKind, LoginMethod, ProxyKind, ProxySettings}; use super::*; - fn draft(name: &str, group: Option) -> SessionDraft { - SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Password, group) + fn draft(name: &str, group: Option) -> HostDraft { + HostDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Password, group) } #[test] - fn bookmarks_are_per_pane_and_leave_with_their_session() { - let mut store = SessionStore::empty(); + fn bookmarks_are_per_pane_and_leave_with_their_host() { + let mut store = HostStore::empty(); let group = store.insert_group_unnotified(GroupDraft::new("生产", None)); let web = store.insert_unnotified(draft("web", Some(group))); let db = store.insert_unnotified(draft("db", None)); @@ -1503,7 +1480,7 @@ mod tests { assert!(!store.add_bookmark_unnotified(web, BookmarkSide::Remote, "/var/log")); assert!(store.add_bookmark_unnotified(web, BookmarkSide::Local, "/tmp")); assert!(store.add_bookmark_unnotified(db, BookmarkSide::Remote, "/srv")); - assert!(!store.add_bookmark_unnotified(SessionId(99), BookmarkSide::Remote, "/x")); + assert!(!store.add_bookmark_unnotified(HostId(99), BookmarkSide::Remote, "/x")); assert_eq!(store.bookmarks(web, BookmarkSide::Remote), ["/var/log"]); assert_eq!(store.bookmarks(web, BookmarkSide::Local), ["/tmp"]); assert!(store.remove_bookmark_unnotified(db, BookmarkSide::Remote, "/srv")); @@ -1526,25 +1503,25 @@ mod tests { assert!(store.bookmarks(web, BookmarkSide::Local).is_empty()); } - fn forward_draft(session: SessionId, port: u16) -> ForwardDraft { + fn forward_draft(host: HostId, port: u16) -> ForwardDraft { ForwardDraft::new( ForwardKind::Local, - session, + host, ForwardEndpoint::new("127.0.0.1", port), Some(ForwardEndpoint::new("db.internal", 3306)), ) } #[test] - fn forwards_keep_their_order_and_leave_with_their_session() { - let mut store = SessionStore::empty(); + fn forwards_keep_their_order_and_leave_with_their_host() { + let mut store = HostStore::empty(); let group = store.insert_group_unnotified(GroupDraft::new("生产", None)); let web = store.insert_unnotified(draft("web", Some(group))); let db = store.insert_unnotified(draft("db", None)); assert_eq!( - store.insert_forward_unnotified(forward_draft(SessionId(99), 1)), + store.insert_forward_unnotified(forward_draft(HostId(99), 1)), None, - "a rule needs a session to go through" + "a rule needs a host to go through" ); let first = store .insert_forward_unnotified(forward_draft(web, 8080)) @@ -1566,16 +1543,16 @@ mod tests { ); assert_eq!(store.forwards_of(web).count(), 2); - // Editing keeps the rule's place; it cannot move to a missing session. + // Editing keeps the rule's place; it cannot move to a missing host. assert!(store.update_forward_unnotified(second, forward_draft(web, 9000))); assert_eq!(store.forward(second).unwrap().bind.port, 9000); assert_eq!(store.forward(second).unwrap().sort_order, 1); - assert!(!store.update_forward_unnotified(second, forward_draft(SessionId(99), 1))); + assert!(!store.update_forward_unnotified(second, forward_draft(HostId(99), 1))); assert!(!store.update_forward_unnotified(ForwardId(99), forward_draft(web, 1))); assert!(store.remove_forward_unnotified(first)); assert!(!store.remove_forward_unnotified(first)); - // A copy of a session starts without forwards, like bookmarks. + // A copy of a host starts without forwards, like bookmarks. let copy = store.duplicate_unnotified(web).unwrap(); assert_eq!(store.forwards_of(copy).count(), 0); @@ -1591,26 +1568,26 @@ mod tests { #[test] fn load_resumes_the_forward_id_sequence() { - let database = SessionDatabase::in_memory().unwrap(); + let database = HostDatabase::in_memory().unwrap(); database - .insert_session(&Session::new(SessionId(1), draft("web", None))) + .insert_host(&Host::new(HostId(1), draft("web", None))) .unwrap(); database .insert_forward(&ForwardRule::new( ForwardId(5), - forward_draft(SessionId(1), 8080), + forward_draft(HostId(1), 8080), )) .unwrap(); - let mut store = SessionStore::load(database).unwrap(); + let mut store = HostStore::load(database).unwrap(); assert_eq!(store.forwards().len(), 1); assert_eq!( - store.insert_forward_unnotified(forward_draft(SessionId(1), 8081)), + store.insert_forward_unnotified(forward_draft(HostId(1), 8081)), Some(ForwardId(6)) ); } - fn password_credential(store: &mut SessionStore, user: &str) -> CredentialId { + fn password_credential(store: &mut HostStore, user: &str) -> CredentialId { store.insert_credential_unnotified(CredentialDraft::new( "运维", CredentialKind::Password, @@ -1620,38 +1597,38 @@ mod tests { #[test] fn a_host_using_a_credential_logs_in_as_its_user() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let credential = password_credential(&mut store, "deploy"); let mut form = draft("web", None).with_credential(credential); // Whatever the form still held of its own login is dropped. form.auth = AuthKind::NoPassword; let web = store.insert_unnotified(form); - let session = store.session(web).unwrap(); - assert_eq!(session.credential, Some(credential)); - assert_eq!(session.user.as_ref(), "deploy"); - assert_eq!(session.auth, AuthKind::Password); + let host = store.host(web).unwrap(); + assert_eq!(host.credential, Some(credential)); + assert_eq!(host.user.as_ref(), "deploy"); + assert_eq!(host.auth, AuthKind::Password); let login = store.login(web).unwrap(); assert_eq!(login.user, "deploy"); assert_eq!( login.password, store.credential(credential).unwrap().password_secret() ); - assert_eq!(store.sessions_using(credential).count(), 1); + assert_eq!(store.hosts_using(credential).count(), 1); } #[test] fn a_draft_naming_a_missing_credential_is_saved_as_manual() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let web = store.insert_unnotified(draft("web", None).with_credential(CredentialId(9))); - let session = store.session(web).unwrap(); - assert_eq!(session.credential, None); - assert_eq!(session.user.as_ref(), "root"); + let host = store.host(web).unwrap(); + assert_eq!(host.credential, None); + assert_eq!(host.user.as_ref(), "root"); } #[test] fn renaming_a_credentials_user_renames_every_host_using_it() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let credential = password_credential(&mut store, "deploy"); let web = store.insert_unnotified(draft("web", None).with_credential(credential)); let db = store.insert_unnotified(draft("db", None)); @@ -1659,9 +1636,9 @@ mod tests { let mut edited = store.credential(credential).unwrap().draft(); edited.user = "admin".into(); assert!(store.update_credential_unnotified(credential, edited)); - assert_eq!(store.session(web).unwrap().user.as_ref(), "admin"); - assert_eq!(store.session(web).unwrap().address(), "admin@10.0.0.1:22"); - assert_eq!(store.session(db).unwrap().user.as_ref(), "root"); + assert_eq!(store.host(web).unwrap().user.as_ref(), "admin"); + assert_eq!(store.host(web).unwrap().endpoint(), "admin@10.0.0.1:22"); + assert_eq!(store.host(db).unwrap().user.as_ref(), "root"); assert!(!store.update_credential_unnotified( CredentialId(9), CredentialDraft::new("x", CredentialKind::Agent, "x") @@ -1670,7 +1647,7 @@ mod tests { #[test] fn changing_only_a_credentials_name_changes_no_login() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let credential = password_credential(&mut store, "deploy"); let web = store.insert_unnotified(draft("web", None).with_credential(credential)); let before = store.login(web); @@ -1689,7 +1666,7 @@ mod tests { #[test] fn deleting_a_credential_leaves_its_hosts_on_their_own_with_the_user() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let credential = store.insert_credential_unnotified( CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path("/tmp/id"), ); @@ -1698,42 +1675,42 @@ mod tests { assert_eq!(store.remove_credential_unnotified(credential), [web]); assert!(store.credentials().is_empty()); - let session = store.session(web).unwrap(); - assert_eq!(session.credential, None); + let host = store.host(web).unwrap(); + assert_eq!(host.credential, None); // Without its key, it tries the agent and the default keys. - assert_eq!(session.auth, AuthKind::NoPassword); - assert_eq!(session.user.as_ref(), "deploy"); - assert_eq!(store.session(db).unwrap().user.as_ref(), "root"); + assert_eq!(host.auth, AuthKind::NoPassword); + assert_eq!(host.user.as_ref(), "deploy"); + assert_eq!(store.host(db).unwrap().user.as_ref(), "root"); assert!(store.remove_credential_unnotified(credential).is_empty()); // Without its password, it asks for one. let credential = password_credential(&mut store, "deploy"); let api = store.insert_unnotified(draft("api", None).with_credential(credential)); store.remove_credential_unnotified(credential); - assert_eq!(store.session(api).unwrap().auth, AuthKind::Password); + assert_eq!(store.host(api).unwrap().auth, AuthKind::Password); } #[test] fn a_copy_of_a_host_uses_the_same_credential() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let credential = password_credential(&mut store, "deploy"); let web = store.insert_unnotified(draft("web", None).with_credential(credential)); let copy = store.duplicate_unnotified(web).unwrap(); - assert_eq!(store.session(copy).unwrap().credential, Some(credential)); - assert_eq!(store.sessions_using(credential).count(), 2); + assert_eq!(store.host(copy).unwrap().credential, Some(credential)); + assert_eq!(store.hosts_using(credential).count(), 2); } #[test] fn a_host_with_a_password_credential_does_not_keep_the_endpoint_password_in_use() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let credential = password_credential(&mut store, "root"); let web = store.insert_unnotified(draft("web", None)); - let endpoint = store.session(web).unwrap().password_secret(); + let endpoint = store.host(web).unwrap().password_secret(); assert!(store.password_in_use(&endpoint)); store.update_unnotified(web, draft("web", None).with_credential(credential)); // Same user, host and port, but the password comes from elsewhere. - assert_eq!(store.session(web).unwrap().password_secret(), endpoint); + assert_eq!(store.host(web).unwrap().password_secret(), endpoint); assert!(!store.password_in_use(&endpoint)); let secret = store.credential(credential).unwrap().password_secret(); assert!(store.password_in_use(&secret)); @@ -1741,21 +1718,21 @@ mod tests { #[test] fn load_resumes_the_credential_id_sequence_and_repairs_usernames() { - let database = SessionDatabase::in_memory().unwrap(); + let database = HostDatabase::in_memory().unwrap(); let credential = Credential::new( CredentialId(4), CredentialDraft::new("运维", CredentialKind::Agent, "deploy"), ); database.insert_credential(&credential).unwrap(); - let mut stale = Session::new(SessionId(1), draft("web", None)); + let mut stale = Host::new(HostId(1), draft("web", None)); stale.credential = Some(credential.id); stale.auth = AuthKind::NoPassword; - database.insert_session(&stale).unwrap(); + database.insert_host(&stale).unwrap(); - let mut store = SessionStore::load(database).unwrap(); - let session = store.session(SessionId(1)).unwrap(); - assert_eq!(session.user.as_ref(), "deploy"); - assert_eq!(session.auth, AuthKind::Password); + let mut store = HostStore::load(database).unwrap(); + let host = store.host(HostId(1)).unwrap(); + assert_eq!(host.user.as_ref(), "deploy"); + assert_eq!(host.auth, AuthKind::Password); assert_eq!( store.credential(CredentialId(4)).unwrap().keychain_id, credential.keychain_id @@ -1770,7 +1747,7 @@ mod tests { ); } - fn key_credential(store: &mut SessionStore, path: &str) -> CredentialId { + fn key_credential(store: &mut HostStore, path: &str) -> CredentialId { store.insert_credential_unnotified( CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path(path), ) @@ -1784,7 +1761,7 @@ mod tests { fn a_pasted_key_gets_a_file_of_its_own_and_keeps_it_when_replaced() { let data = tempfile::tempdir().unwrap(); let keys = data.path().join("keys"); - let mut store = SessionStore::empty().with_key_dir(&keys); + let mut store = HostStore::empty().with_key_dir(&keys); // A credential being created: a new file in the key directory. let path = store.save_private_key(None, "one").unwrap(); @@ -1823,7 +1800,7 @@ mod tests { #[test] fn without_a_key_directory_no_key_is_saved() { - assert!(SessionStore::empty().save_private_key(None, "one").is_err()); + assert!(HostStore::empty().save_private_key(None, "one").is_err()); } #[gpui_kit::test] @@ -1833,7 +1810,7 @@ mod tests { let data = tempfile::tempdir().unwrap(); let secrets = Arc::new(crate::secrets::InMemorySecretStore::default()); - let mut store = SessionStore::empty() + let mut store = HostStore::empty() .with_secrets(secrets.clone()) .with_key_dir(data.path().join("keys")); let kept = store.save_private_key(None, "kept").unwrap(); @@ -1872,36 +1849,36 @@ mod tests { #[test] fn the_detected_system_survives_an_edit() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let id = store.insert_unnotified(draft("web", None)); assert!(store.set_host_os_unnotified(id, Some(HostOs::Debian))); assert!(!store.set_host_os_unnotified(id, Some(HostOs::Debian))); store.update_unnotified(id, draft("web-renamed", None)); - assert_eq!(store.session(id).unwrap().os, Some(HostOs::Debian)); + assert_eq!(store.host(id).unwrap().os, Some(HostOs::Debian)); } #[test] fn a_copy_inherits_the_detected_system() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let id = store.insert_unnotified(draft("web", None)); store.set_host_os_unnotified(id, Some(HostOs::Alpine)); let copy = store.duplicate_unnotified(id).unwrap(); - assert_eq!(store.session(copy).unwrap().os, Some(HostOs::Alpine)); + assert_eq!(store.host(copy).unwrap().os, Some(HostOs::Alpine)); } #[test] - fn probing_an_unknown_session_changes_nothing() { - let mut store = SessionStore::empty(); - assert!(!store.set_host_os_unnotified(SessionId(99), Some(HostOs::Linux))); + fn probing_an_unknown_host_changes_nothing() { + let mut store = HostStore::empty(); + assert!(!store.set_host_os_unnotified(HostId(99), Some(HostOs::Linux))); } #[test] - fn an_endpoint_is_in_use_while_a_session_still_logs_into_it() { - let mut store = SessionStore::empty(); + fn an_endpoint_is_in_use_while_a_host_still_logs_into_it() { + let mut store = HostStore::empty(); let id = store.insert_unnotified(draft("web", None)); - let endpoint = store.session(id).unwrap().password_secret(); + let endpoint = store.host(id).unwrap().password_secret(); assert!(store.password_in_use(&endpoint)); store.remove_unnotified(id); @@ -1909,12 +1886,12 @@ mod tests { } #[test] - fn two_sessions_on_one_account_share_an_endpoint() { - let mut store = SessionStore::empty(); + fn two_hosts_on_one_account_share_an_endpoint() { + let mut store = HostStore::empty(); let first = store.insert_unnotified(draft("web-01", None)); let second = store.insert_unnotified(draft("web-02", None)); - let endpoint = store.session(first).unwrap().password_secret(); - assert_eq!(endpoint, store.session(second).unwrap().password_secret()); + let endpoint = store.host(first).unwrap().password_secret(); + assert_eq!(endpoint, store.host(second).unwrap().password_secret()); store.remove_unnotified(first); assert!(store.password_in_use(&endpoint)); @@ -1922,21 +1899,21 @@ mod tests { #[test] fn a_different_port_is_a_different_endpoint() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let id = store.insert_unnotified(draft("web", None)); - let endpoint = store.session(id).unwrap().password_secret(); - let moved = SessionDraft::new("web", "10.0.0.1", 2222, "root", AuthKind::Password, None); + let endpoint = store.host(id).unwrap().password_secret(); + let moved = HostDraft::new("web", "10.0.0.1", 2222, "root", AuthKind::Password, None); store.update_unnotified(id, moved); assert!(!store.password_in_use(&endpoint)); } #[test] fn endpoints_of_a_group_are_deduplicated() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let group = store.insert_group_unnotified(GroupDraft::new("生产", None)); store.insert_unnotified(draft("web-01", Some(group))); store.insert_unnotified(draft("web-02", Some(group))); - let other = SessionDraft::new( + let other = HostDraft::new( "db", "10.0.0.2", 22, @@ -1953,25 +1930,25 @@ mod tests { ); } - let passwords = store.passwords_of(&store.sessions_under(group)); + let passwords = store.passwords_of(&store.hosts_under(group)); assert_eq!(passwords.len(), 3); assert!(passwords.contains(&SecretRef::proxy("me", "127.0.0.1", 7890))); } - fn jump_through(hops: &[SessionId]) -> Route { + fn jump_through(hops: &[HostId]) -> Route { Route::Jump(hops.iter().copied().map(Some).collect()) } #[test] fn a_host_behind_jump_hosts_logs_in_through_their_own_logins() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let credential = store.insert_credential_unnotified(CredentialDraft::new( "跳板账号", CredentialKind::Agent, "jumper", )); let first = store.insert_unnotified( - SessionDraft::new( + HostDraft::new( "阿里云99", "120.25.220.186", 22, @@ -1983,7 +1960,7 @@ mod tests { ); // Its own route plays no part when another host jumps through it. let second = store.insert_unnotified( - SessionDraft::new( + HostDraft::new( "禅道", "8.138.95.125", 22, @@ -2023,7 +2000,7 @@ mod tests { #[test] fn a_route_never_jumps_through_its_own_host_or_one_that_is_not_there() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let jump = store.insert_unnotified(draft("jump", None)); let id = store.insert_unnotified(draft("web", None)); store.update_unnotified( @@ -2031,11 +2008,11 @@ mod tests { draft("web", None).with_route(Route::Jump(vec![ Some(id), Some(jump), - Some(SessionId(99)), + Some(HostId(99)), ])), ); assert_eq!( - store.session(id).unwrap().route, + store.host(id).unwrap().route, Route::Jump(vec![Some(jump), None]) ); @@ -2044,12 +2021,12 @@ mod tests { id, draft("web", None).with_route(Route::Jump(vec![Some(id)])), ); - assert_eq!(store.session(id).unwrap().route, Route::Direct); + assert_eq!(store.host(id).unwrap().route, Route::Direct); } #[test] fn deleting_a_jump_host_leaves_its_place_and_is_counted() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let group = store.insert_group_unnotified(GroupDraft::new("跳板", None)); let jump = store.insert_unnotified(draft("jump", Some(group))); let inside = @@ -2060,14 +2037,11 @@ mod tests { assert_eq!(store.jump_users(&[jump]), 2); // Deleting the group takes `inside` too: only `outside` is left // without a way through. - assert_eq!(store.jump_users(&store.sessions_under(group)), 1); + assert_eq!(store.jump_users(&store.hosts_under(group)), 1); assert_eq!(store.jump_users(&[direct]), 0); store.remove_unnotified(jump); - assert_eq!( - store.session(outside).unwrap().route, - Route::Jump(vec![None]) - ); + assert_eq!(store.host(outside).unwrap().route, Route::Jump(vec![None])); assert_eq!( store.login(outside).unwrap().route, LoginRoute::Jump(vec![JumpLogin::Deleted]) @@ -2077,24 +2051,21 @@ mod tests { #[test] fn a_copy_takes_the_same_way_there() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let jump = store.insert_unnotified(draft("jump", None)); let id = store.insert_unnotified(draft("db", None).with_route(jump_through(&[jump]))); let copy = store.duplicate_unnotified(id).unwrap(); - assert_eq!(store.session(copy).unwrap().route, jump_through(&[jump])); + assert_eq!(store.host(copy).unwrap().route, jump_through(&[jump])); } #[test] fn notes_stay_through_an_edit_and_go_with_a_copy() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let id = store.insert_unnotified(draft("db", None).with_notes("只读副本")); store.update_unnotified(id, draft("db-01", None).with_notes("只读副本,勿写")); - assert_eq!(store.session(id).unwrap().notes.as_ref(), "只读副本,勿写"); + assert_eq!(store.host(id).unwrap().notes.as_ref(), "只读副本,勿写"); let copy = store.duplicate_unnotified(id).unwrap(); - assert_eq!( - store.session(copy).unwrap().notes.as_ref(), - "只读副本,勿写" - ); + assert_eq!(store.host(copy).unwrap().notes.as_ref(), "只读副本,勿写"); } #[gpui_kit::test] @@ -2104,17 +2075,17 @@ mod tests { use gpui_kit::AppContext as _; use std::{cell::RefCell, rc::Rc}; - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let jump = store.insert_unnotified(draft("jump", None)); let behind = store.insert_unnotified( - SessionDraft::new("db", "10.0.0.9", 22, "root", AuthKind::Password, None) + HostDraft::new("db", "10.0.0.9", 22, "root", AuthKind::Password, None) .with_route(jump_through(&[jump])), ); let store = cx.new(|_| store); let events = Rc::new(RefCell::new(Vec::new())); let _subscription = cx.update(|cx| { let events = events.clone(); - cx.subscribe(&store, move |_, event: &SessionStoreEvent, _| { + cx.subscribe(&store, move |_, event: &HostStoreEvent, _| { events.borrow_mut().push(event.clone()); }) }); @@ -2123,14 +2094,14 @@ mod tests { cx.run_until_parked(); assert!(events.borrow().is_empty(), "{:?}", events.borrow()); - let moved = SessionDraft::new("跳板", "10.0.0.2", 22, "root", AuthKind::Password, None); + let moved = HostDraft::new("跳板", "10.0.0.2", 22, "root", AuthKind::Password, None); store.update(cx, |store, cx| store.update(jump, moved, cx)); cx.run_until_parked(); assert_eq!( *events.borrow(), [ - SessionStoreEvent::ConnectionSettingsChanged(jump), - SessionStoreEvent::ConnectionSettingsChanged(behind), + HostStoreEvent::ConnectionSettingsChanged(jump), + HostStoreEvent::ConnectionSettingsChanged(behind), ] ); @@ -2147,7 +2118,7 @@ mod tests { use gpui_kit::AppContext as _; let secrets = Arc::new(crate::secrets::InMemorySecretStore::default()); - let mut store = SessionStore::empty().with_secrets(secrets.clone()); + let mut store = HostStore::empty().with_secrets(secrets.clone()); let proxy = Route::Proxy(ProxySettings::new(ProxyKind::Http, "proxy.test", 8080).with_user("me")); let one = store.insert_unnotified(draft("one", None).with_route(proxy.clone())); @@ -2166,12 +2137,12 @@ mod tests { } #[test] - fn seed_has_three_groups_and_two_connected_sessions() { - let store = SessionStore::seed(); + fn seed_has_three_groups_and_two_connected_hosts() { + let store = HostStore::seed(); assert_eq!(store.groups().len(), 3); - assert_eq!(store.sessions().len(), 6); + assert_eq!(store.hosts().len(), 6); let connected: Vec<_> = store - .sessions() + .hosts() .iter() .filter(|s| s.state.is_connected()) .map(|s| s.name.as_ref()) @@ -2180,80 +2151,80 @@ mod tests { } #[test] - fn groups_and_sessions_have_independent_id_sequences() { - let mut store = SessionStore::seed(); + fn groups_and_hosts_have_independent_id_sequences() { + let mut store = HostStore::seed(); let group_ids: Vec<_> = store.groups().iter().map(|g| g.id).collect(); assert_eq!(group_ids, [GroupId(1), GroupId(2), GroupId(3)]); - assert_eq!(store.sessions()[0].id, SessionId(1)); - assert_eq!(store.sessions()[5].id, SessionId(6)); + assert_eq!(store.hosts()[0].id, HostId(1)); + assert_eq!(store.hosts()[5].id, HostId(6)); assert_eq!( store.insert_group_unnotified(GroupDraft::new("预发", None)), GroupId(4) ); - assert_eq!(store.insert_unnotified(draft("new", None)), SessionId(7)); + assert_eq!(store.insert_unnotified(draft("new", None)), HostId(7)); } #[test] fn insert_assigns_increasing_ids_and_keeps_order() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let a = store.insert_unnotified(draft("a", Some(GroupId(1)))); let b = store.insert_unnotified(draft("b", Some(GroupId(1)))); assert!(a < b); - assert_eq!(store.sessions()[1].id, b); + assert_eq!(store.hosts()[1].id, b); } #[test] fn duplicate_places_a_copy_after_the_original() { - let mut store = SessionStore::seed(); - let web01 = store.sessions()[0].id; - let mut draft = store.session(web01).unwrap().draft(); + let mut store = HostStore::seed(); + let web01 = store.hosts()[0].id; + let mut draft = store.host(web01).unwrap().draft(); draft.auth = AuthKind::NoPassword; assert!(store.update_unnotified(web01, draft)); let copy = store.duplicate_unnotified(web01).unwrap(); - assert_eq!(store.sessions()[1].id, copy); - assert_eq!(store.sessions()[1].name.as_ref(), "web-01 副本"); - assert_eq!(store.sessions()[1].auth, AuthKind::NoPassword); - assert_eq!(store.sessions()[1].state, ConnectionState::Disconnected); + assert_eq!(store.hosts()[1].id, copy); + assert_eq!(store.hosts()[1].name.as_ref(), "web-01 副本"); + assert_eq!(store.hosts()[1].auth, AuthKind::NoPassword); + assert_eq!(store.hosts()[1].state, ConnectionState::Disconnected); assert_ne!( - store.sessions()[1].public_id, - store.session(web01).unwrap().public_id + store.hosts()[1].public_id, + store.host(web01).unwrap().public_id ); } #[test] fn a_public_id_survives_editing_and_is_never_shared() { - let mut store = SessionStore::seed(); - let web01 = store.sessions()[0].id; - let before = store.session(web01).unwrap().public_id.clone(); - let mut draft = store.session(web01).unwrap().draft(); + let mut store = HostStore::seed(); + let web01 = store.hosts()[0].id; + let before = store.host(web01).unwrap().public_id.clone(); + let mut draft = store.host(web01).unwrap().draft(); draft.name = "web".into(); - draft.host = "10.0.1.99".into(); + draft.address = "10.0.1.99".into(); draft.group = None; assert!(store.update_unnotified(web01, draft)); - assert_eq!(store.session(web01).unwrap().public_id, before); + assert_eq!(store.host(web01).unwrap().public_id, before); let ids: std::collections::HashSet<_> = - store.sessions().iter().map(|s| &s.public_id).collect(); - assert_eq!(ids.len(), store.sessions().len()); + store.hosts().iter().map(|s| &s.public_id).collect(); + assert_eq!(ids.len(), store.hosts().len()); } #[test] fn update_keeps_connection_state() { - let mut store = SessionStore::seed(); - let web01 = store.sessions()[0].id; - let mut draft = store.session(web01).unwrap().draft(); + let mut store = HostStore::seed(); + let web01 = store.hosts()[0].id; + let mut draft = store.host(web01).unwrap().draft(); draft.port = 2200; assert!(store.update_unnotified(web01, draft)); - let session = store.session(web01).unwrap(); - assert_eq!(session.port, 2200); - assert_eq!(session.state, ConnectionState::Connected); - assert!(!store.update_unnotified(SessionId(999), session.draft())); + let host = store.host(web01).unwrap(); + assert_eq!(host.port, 2200); + assert_eq!(host.state, ConnectionState::Connected); + assert!(!store.update_unnotified(HostId(999), host.draft())); } #[test] - fn remove_clears_active_session() { - let mut store = SessionStore::seed(); - let web01 = store.sessions()[0].id; + fn remove_clears_active_host() { + let mut store = HostStore::seed(); + let web01 = store.hosts()[0].id; store.active = Some(web01); assert!(store.remove_unnotified(web01)); assert!(store.active().is_none()); @@ -2261,50 +2232,50 @@ mod tests { } #[test] - fn seed_lists_connected_sessions_as_recent_newest_first() { - let store = SessionStore::seed(); - let recent: Vec<_> = store.recent_sessions().map(|s| s.name.as_ref()).collect(); + fn seed_lists_connected_hosts_as_recent_newest_first() { + let store = HostStore::seed(); + let recent: Vec<_> = store.recent_hosts().map(|s| s.name.as_ref()).collect(); assert_eq!(recent, ["staging-api", "web-01"]); } #[test] - fn connecting_moves_a_session_to_the_front_of_recent() { - let mut store = SessionStore::seed(); - let web01 = store.sessions()[0].id; - let db01 = store.sessions()[2].id; + fn connecting_moves_a_host_to_the_front_of_recent() { + let mut store = HostStore::seed(); + let web01 = store.hosts()[0].id; + let db01 = store.hosts()[2].id; assert!(store.set_state_unnotified(db01, ConnectionState::Connected)); // Already connected: nothing changes, nothing moves. assert!(!store.set_state_unnotified(db01, ConnectionState::Connected)); assert!(store.set_state_unnotified(web01, ConnectionState::Disconnected)); assert!(store.set_state_unnotified(web01, ConnectionState::Connected)); - let recent: Vec<_> = store.recent_sessions().map(|s| s.name.as_ref()).collect(); + let recent: Vec<_> = store.recent_hosts().map(|s| s.name.as_ref()).collect(); assert_eq!(recent, ["web-01", "db-01", "staging-api"]); } #[test] - fn remove_drops_the_session_from_recent() { - let mut store = SessionStore::seed(); - let web01 = store.sessions()[0].id; + fn remove_drops_the_host_from_recent() { + let mut store = HostStore::seed(); + let web01 = store.hosts()[0].id; assert!(store.remove_unnotified(web01)); - let recent: Vec<_> = store.recent_sessions().map(|s| s.name.as_ref()).collect(); + let recent: Vec<_> = store.recent_hosts().map(|s| s.name.as_ref()).collect(); assert_eq!(recent, ["staging-api"]); } #[test] fn recent_keeps_only_the_newest_entries() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); for ix in 0..(MAX_RECENT + 2) { let id = store.insert_unnotified(draft(&format!("s{ix}"), None)); store.set_state_unnotified(id, ConnectionState::Connected); } - assert_eq!(store.recent_sessions().count(), MAX_RECENT); - let newest = store.recent_sessions().next().unwrap(); + assert_eq!(store.recent_hosts().count(), MAX_RECENT); + let newest = store.recent_hosts().next().unwrap(); assert_eq!(newest.name.as_ref(), "s11"); } #[test] fn nested_groups_report_descendants_and_path() { - 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))); let replicas = store.insert_group_unnotified(GroupDraft::new("只读副本", Some(databases))); @@ -2320,8 +2291,8 @@ mod tests { } #[test] - fn removing_a_group_takes_its_subtree_and_its_sessions() { - let mut store = SessionStore::empty(); + fn removing_a_group_takes_its_subtree_and_its_hosts() { + let mut store = HostStore::empty(); let production = store.insert_group_unnotified(GroupDraft::new("生产", None)); let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production))); let staging = store.insert_group_unnotified(GroupDraft::new("测试", None)); @@ -2339,12 +2310,12 @@ mod tests { store.groups().iter().map(|g| g.id).collect::>(), [staging] ); - // The root-level session and the untouched group's session survive. + // The root-level host and the untouched group's host survive. assert_eq!( - store.sessions().iter().map(|s| s.id).collect::>(), + store.hosts().iter().map(|s| s.id).collect::>(), [qa, jump] ); - assert_eq!(store.recent_sessions().count(), 0); + assert_eq!(store.recent_hosts().count(), 0); assert!(store.active().is_none()); // Removing it a second time finds nothing left to do. assert!(store.remove_group_unnotified(production).is_empty()); @@ -2352,7 +2323,7 @@ mod tests { #[test] fn a_group_cannot_be_moved_into_its_own_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))); let replicas = store.insert_group_unnotified(GroupDraft::new("只读副本", Some(databases))); @@ -2373,7 +2344,7 @@ mod tests { #[test] fn renaming_a_group_preserves_its_expansion_choice() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let id = store.insert_group_unnotified(GroupDraft::new("生产", None)); store .groups @@ -2388,78 +2359,69 @@ mod tests { #[test] fn dragging_reorders_peers_and_moves_hosts_between_groups() { - let mut store = SessionStore::seed(); - let web = SessionNode::Session(SessionId(1)); - let db = SessionNode::Session(SessionId(3)); + let mut store = HostStore::seed(); + let web = HostNode::Host(HostId(1)); + let db = HostNode::Host(HostId(3)); assert!(store.move_node_unnotified(db, NodeDrop::Before(web))); let mut production: Vec<_> = store - .sessions() + .hosts() .iter() - .filter(|session| session.group == Some(GroupId(1))) + .filter(|host| host.group == Some(GroupId(1))) .collect(); - production.sort_by_key(|session| session.sort_order); + production.sort_by_key(|host| host.sort_order); assert_eq!( - production - .iter() - .map(|session| session.id) - .collect::>(), - [SessionId(3), SessionId(1), SessionId(2)] + production.iter().map(|host| host.id).collect::>(), + [HostId(3), HostId(1), HostId(2)] ); assert!(!store.move_node_unnotified(db, NodeDrop::Before(web))); assert!(store.move_node_unnotified(web, NodeDrop::Into(GroupId(2)))); - assert_eq!(store.session(SessionId(1)).unwrap().group, Some(GroupId(2))); + assert_eq!(store.host(HostId(1)).unwrap().group, Some(GroupId(2))); assert!(store.move_node_unnotified(web, NodeDrop::Root)); - assert_eq!(store.session(SessionId(1)).unwrap().group, None); + assert_eq!(store.host(HostId(1)).unwrap().group, None); } #[test] fn dragging_a_group_refuses_its_descendants() { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let parent = store.insert_group_unnotified(GroupDraft::new("parent", None)); let child = store.insert_group_unnotified(GroupDraft::new("child", Some(parent))); let peer = store.insert_group_unnotified(GroupDraft::new("peer", None)); - assert!(!store.move_node_unnotified(SessionNode::Group(parent), NodeDrop::Into(child))); - assert!(!store.move_node_unnotified(SessionNode::Group(parent), NodeDrop::Into(parent))); + assert!(!store.move_node_unnotified(HostNode::Group(parent), NodeDrop::Into(child))); + assert!(!store.move_node_unnotified(HostNode::Group(parent), NodeDrop::Into(parent))); assert!(store.move_node_unnotified( - SessionNode::Group(peer), - NodeDrop::Before(SessionNode::Group(parent)) + HostNode::Group(peer), + NodeDrop::Before(HostNode::Group(parent)) )); assert!(store.group(peer).unwrap().sort_order < store.group(parent).unwrap().sort_order); } #[test] fn load_resumes_the_id_sequences_and_the_recent_order() { - let database = SessionDatabase::in_memory().unwrap(); + let database = HostDatabase::in_memory().unwrap(); database - .insert_group(&SessionGroup::new( - GroupId(4), - GroupDraft::new("生产", None), - )) + .insert_group(&HostGroup::new(GroupId(4), GroupDraft::new("生产", None))) .unwrap(); database - .insert_session(&Session::new( - SessionId(7), - draft("web-01", Some(GroupId(4))), - )) + .insert_host(&Host::new(HostId(7), draft("web-01", Some(GroupId(4))))) .unwrap(); database - .insert_session(&Session::new(SessionId(9), draft("db-01", None))) + .insert_host(&Host::new(HostId(9), draft("db-01", None))) .unwrap(); - database.touch_connected(SessionId(9), 100).unwrap(); + database.touch_connected(HostId(9), 100).unwrap(); - let mut store = SessionStore::load(database).unwrap(); + let mut store = HostStore::load(database).unwrap(); assert_eq!(store.groups().len(), 1); assert_eq!( - store.recent_sessions().map(|s| s.id).collect::>(), - [SessionId(9)] + store.recent_hosts().map(|s| s.id).collect::>(), + [HostId(9)] ); // Nothing is connected on load, however recently it last connected. - assert!(store.sessions().iter().all(|s| !s.state.is_connected())); + assert!(store.hosts().iter().all(|s| !s.state.is_connected())); assert_eq!( store.insert_group_unnotified(GroupDraft::new("测试", None)), GroupId(5) ); - assert_eq!(store.insert_unnotified(draft("new", None)), SessionId(10)); + assert_eq!(store.insert_unnotified(draft("new", None)), HostId(10)); } } diff --git a/src/lib.rs b/src/lib.rs index 7ca5822..b4565b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 757a1bd..4ef499d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) { +fn open_store() -> (HostStore, Option) { 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) { 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) { } } -fn load_store() -> anyhow::Result { +fn load_store() -> anyhow::Result { let path = shellrs::app::database_path()?; - Ok(SessionStore::load(SessionDatabase::open(&path)?)?) + Ok(HostStore::load(HostDatabase::open(&path)?)?) } diff --git a/src/secrets/memory.rs b/src/secrets/memory.rs index c92b0f4..b5d4550 100644 --- a/src/secrets/memory.rs +++ b/src/secrets/memory.rs @@ -51,7 +51,7 @@ impl SecretStore for InMemorySecretStore { } /// 用在没有系统钥匙串的机器上:读永远是空,写明确报错,界面据此把密码字段 -/// 禁用掉。也是 `SessionStore` 的默认值,所以单元测试不会碰到任何秘密存储。 +/// 禁用掉。也是 `HostStore` 的默认值,所以单元测试不会碰到任何秘密存储。 pub struct NoSecretStore; impl SecretStore for NoSecretStore { diff --git a/src/secrets/mod.rs b/src/secrets/mod.rs index f5f6785..275d758 100644 --- a/src/secrets/mod.rs +++ b/src/secrets/mod.rs @@ -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; diff --git a/src/session/database.rs b/src/session/database.rs deleted file mode 100644 index 4de31f0..0000000 --- a/src/session/database.rs +++ /dev/null @@ -1,2112 +0,0 @@ -//! The SQLite file behind the session store. -//! -//! Everything here is synchronous and runs on the UI thread: the writes are -//! single rows triggered by a dialog the user just confirmed, not a stream. -//! The store keeps memory as the source of truth and mirrors each change -//! here, so a failed write costs the user persistence, never the edit. - -use std::{ - collections::{HashMap, HashSet}, - path::Path, -}; - -use rusqlite::{Connection, params}; - -use super::{ - AuthKind, BookmarkSide, Credential, CredentialDraft, CredentialId, CredentialKind, - ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GroupDraft, GroupId, - HostOs, ProxyKind, ProxySettings, PublicId, Route, Session, SessionDraft, SessionGroup, - SessionId, -}; - -/// SQLite's only integer type is `i64`, so the `u64` ids cross the boundary -/// as `i64`. They are counters that start at 1 and never come near the range -/// where the round trip would lose anything. -fn to_sql(id: u64) -> i64 { - id as i64 -} - -fn from_sql(id: i64) -> u64 { - id as u64 -} - -/// The schema's version, stored in `PRAGMA user_version`. A change to the -/// schema bumps it and gives `migrate` a step from the version before. -/// -/// The steps start at 7 rather than 1 because the databases of development -/// builds were already at 7 when the older steps were folded into `SCHEMA`. -const SCHEMA_VERSION: i64 = 12; - -/// The port-forwarding rules, added in version 8. A macro rather than a -/// constant so that `SCHEMA` and the step from version 7 are built from the -/// same text and cannot drift apart. -/// -/// A rule goes with its session. A dynamic forward has no target; the other -/// two kinds must have one. -macro_rules! forwards_table { - () => { - "\ -CREATE TABLE forwards ( - id INTEGER PRIMARY KEY, - session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT '', - kind TEXT NOT NULL CHECK (kind IN ('local', 'remote', 'dynamic')), - bind_host TEXT NOT NULL, - bind_port INTEGER NOT NULL CHECK (bind_port BETWEEN 1 AND 65535), - target_host TEXT, - target_port INTEGER, - auto_start INTEGER NOT NULL DEFAULT 0, - sort_order INTEGER NOT NULL DEFAULT 0, - CHECK ((kind = 'dynamic' AND target_host IS NULL AND target_port IS NULL) - OR (kind <> 'dynamic' AND target_host IS NOT NULL - AND target_port BETWEEN 1 AND 65535)) -); -CREATE INDEX forwards_session_id ON forwards(session_id);" - }; -} - -/// The credentials and which session uses which, added in version 9. Built -/// like `forwards_table!`, for the same reason. -/// -/// The link is a table of its own rather than a column on `sessions`: adding -/// a column rewrites the table's recorded definition into a shape the full -/// schema could only match by copying it. A session uses at most one -/// credential; deleting either side drops the link. Only a key credential -/// names a key file. -macro_rules! credential_tables { - () => { - "\ -CREATE TABLE credentials ( - id INTEGER PRIMARY KEY, - keychain_id TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - kind TEXT NOT NULL CHECK (kind IN ('password', 'key', 'agent')), - username TEXT NOT NULL, - key_path TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - CHECK ((kind = 'key') = (key_path IS NOT NULL)) -); -CREATE TABLE session_credentials ( - session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, - credential_id INTEGER NOT NULL REFERENCES credentials(id) ON DELETE CASCADE -); -CREATE INDEX session_credentials_credential_id ON session_credentials(credential_id);" - }; -} - -/// How a session is reached when it is not reached directly, added in -/// version 11. Built like `forwards_table!`, for the same reason, and kept -/// off `sessions` for the reason `credential_tables!` gives. -/// -/// A session with jump rows goes through those sessions in `position` -/// order; one with a proxy row goes through that proxy; one with neither is -/// reached directly. A deleted jump host leaves its row behind with no -/// `jump_id`, so the session fails to connect rather than skip the hop. -macro_rules! route_tables { - () => { - "\ -CREATE TABLE session_jumps ( - session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - position INTEGER NOT NULL, - jump_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL, - PRIMARY KEY (session_id, position), - CHECK (jump_id <> session_id) -); -CREATE INDEX session_jumps_jump_id ON session_jumps(jump_id); -CREATE TABLE session_proxies ( - session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK (kind IN ('http', 'socks5')), - host TEXT NOT NULL, - port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535), - username TEXT -);" - }; -} - -/// A session's notes, added in version 12. Built like `forwards_table!`, -/// for the same reason, and kept off `sessions` for the reason -/// `credential_tables!` gives. Only a session with notes has a row. -macro_rules! notes_table { - () => { - "\ -CREATE TABLE session_notes ( - session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, - notes TEXT NOT NULL -);" - }; -} - -/// One step from a version to the next: one transaction that ends by -/// recording the version it reached. -enum Step { - /// Statements that make their own transaction. - Sql(&'static str), - /// A change of rows SQL alone does not express well. - Code(fn(&Connection) -> rusqlite::Result<()>), -} - -/// The steps from each older version to the next, in order. -const STEPS: [(i64, Step); 5] = [ - ( - 7, - Step::Sql(concat!( - "BEGIN;\n", - forwards_table!(), - "\nPRAGMA user_version = 8;\nCOMMIT;" - )), - ), - ( - 8, - Step::Sql(concat!( - "BEGIN;\n", - credential_tables!(), - "\nPRAGMA user_version = 9;\nCOMMIT;" - )), - ), - (9, Step::Code(keys_into_credentials)), - ( - 10, - Step::Sql(concat!( - "BEGIN;\n", - route_tables!(), - "\nPRAGMA user_version = 11;\nCOMMIT;" - )), - ), - ( - 11, - Step::Sql(concat!( - "BEGIN;\n", - notes_table!(), - "\nPRAGMA user_version = 12;\nCOMMIT;" - )), - ), -]; - -/// The whole schema, as a new database gets it. -/// -/// Groups nest through `parent_id`, and both references to a group cascade: -/// deleting one takes its subgroups and their sessions. Bookmarks are kept -/// per session and per pane, and go with their session, the way WinSCP drops -/// a site's bookmarks with the site. `public_id` may be missing, which a row -/// added by hand is until the next open (see `fill_missing_public_ids`); the -/// index keeps the ones that are there unique. -const SCHEMA: &str = concat!( - "\ -CREATE TABLE groups ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - parent_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - expanded INTEGER NOT NULL DEFAULT 1 -); -CREATE TABLE sessions ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - host TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - auth TEXT NOT NULL, - group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, - last_connected_at INTEGER, - key_path TEXT, - os TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - public_id TEXT -); -CREATE INDEX sessions_group_id ON sessions(group_id); -CREATE UNIQUE INDEX sessions_public_id ON sessions(public_id); -CREATE TABLE bookmarks ( - id INTEGER PRIMARY KEY, - session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - side TEXT NOT NULL CHECK (side IN ('local', 'remote')), - path TEXT NOT NULL, - sort_order INTEGER NOT NULL, - UNIQUE (session_id, side, path) -); -", - forwards_table!(), - "\n", - credential_tables!(), - "\n", - route_tables!(), - "\n", - notes_table!() -); - -/// Everything one launch reads back from disk. -pub struct StoredData { - /// Groups in id order; `parent` and `sort_order` give the visible tree. - pub groups: Vec, - /// Sessions in id order, all of them disconnected. - pub sessions: Vec, - /// Sessions that have ever connected, most recently connected first. - pub recent: Vec, - /// SFTP bookmarks in the order they were added. - pub bookmarks: Vec<(SessionId, BookmarkSide, String)>, - /// Port-forwarding rules in the order the forward list shows them. - pub forwards: Vec, - /// Credentials in the order the credential list shows them. - pub credentials: Vec, -} - -pub struct SessionDatabase { - connection: Connection, -} - -impl SessionDatabase { - /// Open the database at `path`, creating it if it is not there yet. - pub fn open(path: &Path) -> rusqlite::Result { - Self::prepare(Connection::open(path)?) - } - - /// An anonymous database that lives only as long as this value. - #[cfg(test)] - pub fn in_memory() -> rusqlite::Result { - Self::prepare(Connection::open_in_memory()?) - } - - fn prepare(mut connection: Connection) -> rusqlite::Result { - // Cascading deletes are how removing a group removes its subtree, and - // SQLite only honours the foreign keys when asked, per connection. - connection.pragma_update(None, "foreign_keys", true)?; - connection.execute_batch("PRAGMA journal_mode = WAL;")?; - migrate(&connection)?; - fill_missing_public_ids(&mut connection)?; - Ok(Self { connection }) - } - - pub fn load(&self) -> rusqlite::Result { - let groups = self - .connection - .prepare("SELECT id, name, parent_id, sort_order, expanded FROM groups ORDER BY id")? - .query_map([], |row| { - let id: i64 = row.get(0)?; - let name: String = row.get(1)?; - let parent: Option = row.get(2)?; - let mut group = SessionGroup::new( - GroupId(from_sql(id)), - GroupDraft::new(name, parent.map(|id| GroupId(from_sql(id)))), - ); - group.sort_order = row.get(3)?; - group.expanded = row.get(4)?; - Ok(group) - })? - .collect::>>()?; - - let mut sessions = self - .connection - .prepare( - "SELECT s.id, s.name, s.host, s.port, s.username, s.auth, s.group_id, \ - s.os, s.sort_order, s.public_id, c.credential_id \ - FROM sessions s LEFT JOIN session_credentials c ON c.session_id = s.id \ - ORDER BY s.id", - )? - .query_map([], |row| { - let id: i64 = row.get(0)?; - let name: String = row.get(1)?; - let host: String = row.get(2)?; - let port: u16 = row.get(3)?; - let user: String = row.get(4)?; - let auth: String = row.get(5)?; - let group: Option = row.get(6)?; - let os: Option = row.get(7)?; - let credential: Option = row.get(10)?; - let mut session = Session::new( - SessionId(from_sql(id)), - SessionDraft::new( - name, - host, - port, - user, - AuthKind::from_stored(&auth), - group.map(|id| GroupId(from_sql(id))), - ), - ); - session.os = os.as_deref().and_then(HostOs::from_stored); - session.sort_order = row.get(8)?; - session.public_id = PublicId::from_stored(row.get(9)?); - session.credential = credential.map(|id| CredentialId(from_sql(id))); - Ok(session) - })? - .collect::>>()?; - self.load_routes(&mut sessions)?; - self.load_notes(&mut sessions)?; - - let recent = self - .connection - .prepare( - "SELECT id FROM sessions WHERE last_connected_at IS NOT NULL \ - ORDER BY last_connected_at DESC, id DESC", - )? - .query_map([], |row| { - row.get::<_, i64>(0).map(|id| SessionId(from_sql(id))) - })? - .collect::>>()?; - - let bookmarks = self - .connection - .prepare("SELECT session_id, side, path FROM bookmarks ORDER BY sort_order, id")? - .query_map([], |row| { - let session: i64 = row.get(0)?; - let side: String = row.get(1)?; - Ok(( - SessionId(from_sql(session)), - BookmarkSide::from_stored(&side), - row.get::<_, String>(2)?, - )) - })? - .filter_map(|row| match row { - Ok((session, Some(side), path)) => Some(Ok((session, side, path))), - Ok((_, None, _)) => None, - Err(error) => Some(Err(error)), - }) - .collect::>>()?; - - let forwards = self - .connection - .prepare( - "SELECT id, session_id, name, kind, bind_host, bind_port, target_host, \ - target_port, auto_start, sort_order FROM forwards ORDER BY sort_order, id", - )? - .query_map([], |row| { - let id: i64 = row.get(0)?; - let session: i64 = row.get(1)?; - let name: String = row.get(2)?; - let kind: String = row.get(3)?; - let bind_host: String = row.get(4)?; - let bind_port: u16 = row.get(5)?; - let target_host: Option = row.get(6)?; - let target_port: Option = row.get(7)?; - let auto_start: bool = row.get(8)?; - let sort_order: i64 = row.get(9)?; - // A kind this build does not know was written by a newer one; - // the rule is left out rather than run as something else. - Ok(ForwardKind::from_stored(&kind).map(|kind| { - let target = target_host - .zip(target_port) - .map(|(host, port)| ForwardEndpoint::new(host, port)); - let draft = ForwardDraft::new( - kind, - SessionId(from_sql(session)), - ForwardEndpoint::new(bind_host, bind_port), - target, - ) - .with_name(name) - .with_auto_start(auto_start); - let mut rule = ForwardRule::new(ForwardId(from_sql(id)), draft); - rule.sort_order = sort_order; - rule - })) - })? - .filter_map(Result::transpose) - .collect::>>()?; - - let credentials = self - .connection - .prepare( - "SELECT id, keychain_id, name, kind, username, key_path, sort_order \ - FROM credentials ORDER BY sort_order, id", - )? - .query_map([], |row| { - let id: i64 = row.get(0)?; - let keychain_id: String = row.get(1)?; - let name: String = row.get(2)?; - let kind: String = row.get(3)?; - let user: String = row.get(4)?; - let key_path: Option = row.get(5)?; - let sort_order: i64 = row.get(6)?; - // A kind this build does not know was written by a newer one; - // it is left out, and the hosts using it fall back to their - // own login rather than trying it as something else. - Ok(CredentialKind::from_stored(&kind).map(|kind| { - let mut draft = CredentialDraft::new(name, kind, user); - draft.key_path = key_path.map(Into::into); - let mut credential = Credential::new(CredentialId(from_sql(id)), draft); - credential.keychain_id = PublicId::from_stored(keychain_id); - credential.sort_order = sort_order; - credential - })) - })? - .filter_map(Result::transpose) - .collect::>>()?; - - Ok(StoredData { - groups, - sessions, - recent, - bookmarks, - forwards, - credentials, - }) - } - - /// Give each of `sessions` the route its rows describe. - fn load_routes(&self, sessions: &mut [Session]) -> rusqlite::Result<()> { - let index: HashMap = sessions - .iter() - .enumerate() - .map(|(ix, session)| (session.id, ix)) - .collect(); - let hops = self - .connection - .prepare("SELECT session_id, jump_id FROM session_jumps ORDER BY session_id, position")? - .query_map([], |row| { - let session: i64 = row.get(0)?; - let jump: Option = row.get(1)?; - Ok(( - SessionId(from_sql(session)), - jump.map(|id| SessionId(from_sql(id))), - )) - })? - .collect::>>()?; - for (session, hop) in hops { - let Some(session) = index.get(&session).map(|ix| &mut sessions[*ix]) else { - continue; - }; - match &mut session.route { - Route::Jump(hops) => hops.push(hop), - route => *route = Route::Jump(vec![hop]), - } - } - let proxies = self - .connection - .prepare("SELECT session_id, kind, host, port, username FROM session_proxies")? - .query_map([], |row| { - let session: i64 = row.get(0)?; - let kind: String = row.get(1)?; - let host: String = row.get(2)?; - let port: u16 = row.get(3)?; - let user: Option = row.get(4)?; - Ok((SessionId(from_sql(session)), kind, host, port, user)) - })? - .collect::>>()?; - for (session, kind, host, port, user) in proxies { - // The table's CHECK admits only the kinds this build knows. - let Some(kind) = ProxyKind::from_stored(&kind) else { - continue; - }; - let Some(session) = index.get(&session).map(|ix| &mut sessions[*ix]) else { - continue; - }; - // Writes never leave both; jumps win should a hand edit do it. - if session.route == Route::Direct { - session.route = Route::Proxy( - ProxySettings::new(kind, host, port).with_user(user.unwrap_or_default()), - ); - } - } - Ok(()) - } - - /// Give each of `sessions` its notes. - fn load_notes(&self, sessions: &mut [Session]) -> rusqlite::Result<()> { - let mut notes: HashMap = self - .connection - .prepare("SELECT session_id, notes FROM session_notes")? - .query_map([], |row| { - Ok((SessionId(from_sql(row.get(0)?)), row.get::<_, String>(1)?)) - })? - .collect::>()?; - for session in sessions { - if let Some(text) = notes.remove(&session.id) { - session.notes = text.into(); - } - } - Ok(()) - } - - pub fn insert_group(&self, group: &SessionGroup) -> rusqlite::Result<()> { - self.connection.execute( - "INSERT INTO groups (id, name, parent_id, sort_order, expanded) VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - to_sql(group.id.0), - group.name.as_ref(), - group.parent.map(|parent| to_sql(parent.0)), - group.sort_order, - group.expanded, - ], - )?; - Ok(()) - } - - pub fn update_group(&self, group: &SessionGroup) -> rusqlite::Result<()> { - self.connection.execute( - "UPDATE groups SET name = ?2, parent_id = ?3, sort_order = ?4, expanded = ?5 WHERE id = ?1", - params![ - to_sql(group.id.0), - group.name.as_ref(), - group.parent.map(|parent| to_sql(parent.0)), - group.sort_order, - group.expanded, - ], - )?; - Ok(()) - } - - /// Save the session tree's expansion choice for one group. - pub fn set_group_expanded(&self, id: GroupId, expanded: bool) -> rusqlite::Result<()> { - self.connection.execute( - "UPDATE groups SET expanded = ?2 WHERE id = ?1", - params![to_sql(id.0), expanded], - )?; - Ok(()) - } - - /// Save an expand-all or collapse-all command in one database write. - pub fn set_all_groups_expanded(&self, expanded: bool) -> rusqlite::Result<()> { - self.connection - .execute("UPDATE groups SET expanded = ?1", params![expanded])?; - Ok(()) - } - - /// Deleting a group takes its subgroups and their sessions with it, via - /// the `ON DELETE CASCADE` on both foreign keys. - pub fn remove_group(&self, id: GroupId) -> rusqlite::Result<()> { - self.connection - .execute("DELETE FROM groups WHERE id = ?1", params![to_sql(id.0)])?; - Ok(()) - } - - /// Insert a session together with its link to a credential and its - /// route. - pub fn insert_session(&self, session: &Session) -> rusqlite::Result<()> { - let transaction = self.connection.unchecked_transaction()?; - transaction.execute( - "INSERT INTO sessions (id, name, host, port, username, auth, group_id, os, sort_order, public_id) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - to_sql(session.id.0), - session.name.as_ref(), - session.host.as_ref(), - session.port, - session.user.as_ref(), - session.auth.as_str(), - session.group.map(|group| to_sql(group.0)), - session.os.map(HostOs::as_str), - session.sort_order, - session.public_id.as_str(), - ], - )?; - link_credential(&transaction, session)?; - write_route(&transaction, session)?; - write_notes(&transaction, session)?; - transaction.commit() - } - - /// Rewrites the editable fields, the link to a credential and the route. - /// `last_connected_at` and `os` are left alone: they are written by - /// `touch_connected` and `set_host_os`, and neither is part of the session - /// form. `public_id` never changes. - pub fn update_session(&self, session: &Session) -> rusqlite::Result<()> { - let transaction = self.connection.unchecked_transaction()?; - transaction.execute( - "UPDATE sessions SET name = ?2, host = ?3, port = ?4, username = ?5, \ - auth = ?6, group_id = ?7, sort_order = ?8 WHERE id = ?1", - params![ - to_sql(session.id.0), - session.name.as_ref(), - session.host.as_ref(), - session.port, - session.user.as_ref(), - session.auth.as_str(), - session.group.map(|group| to_sql(group.0)), - session.sort_order, - ], - )?; - transaction.execute( - "DELETE FROM session_credentials WHERE session_id = ?1", - params![to_sql(session.id.0)], - )?; - link_credential(&transaction, session)?; - write_route(&transaction, session)?; - write_notes(&transaction, session)?; - transaction.commit() - } - - /// Deleting a session takes its own route with it; the sessions that - /// went through it keep their place for it, empty (`ON DELETE SET NULL`). - pub fn remove_session(&self, id: SessionId) -> rusqlite::Result<()> { - self.connection - .execute("DELETE FROM sessions WHERE id = ?1", params![to_sql(id.0)])?; - Ok(()) - } - - /// Save one drag as a unit, including parent changes and sibling order. - pub fn save_tree_order( - &self, - groups: &[SessionGroup], - sessions: &[Session], - ) -> rusqlite::Result<()> { - let transaction = self.connection.unchecked_transaction()?; - { - let mut update_group = transaction - .prepare("UPDATE groups SET parent_id = ?2, sort_order = ?3 WHERE id = ?1")?; - for group in groups { - update_group.execute(params![ - to_sql(group.id.0), - group.parent.map(|id| to_sql(id.0)), - group.sort_order, - ])?; - } - let mut update_session = transaction - .prepare("UPDATE sessions SET group_id = ?2, sort_order = ?3 WHERE id = ?1")?; - for session in sessions { - update_session.execute(params![ - to_sql(session.id.0), - session.group.map(|id| to_sql(id.0)), - session.sort_order, - ])?; - } - } - transaction.commit() - } - - /// Append a bookmark; adding one that exists is a no-op. - pub fn insert_bookmark( - &self, - session: SessionId, - side: BookmarkSide, - path: &str, - ) -> rusqlite::Result<()> { - self.connection.execute( - "INSERT OR IGNORE INTO bookmarks (session_id, side, path, sort_order) \ - VALUES (?1, ?2, ?3, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM bookmarks))", - params![to_sql(session.0), side.as_str(), path], - )?; - Ok(()) - } - - pub fn remove_bookmark( - &self, - session: SessionId, - side: BookmarkSide, - path: &str, - ) -> rusqlite::Result<()> { - self.connection.execute( - "DELETE FROM bookmarks WHERE session_id = ?1 AND side = ?2 AND path = ?3", - params![to_sql(session.0), side.as_str(), path], - )?; - Ok(()) - } - - /// Put one pane's bookmarks in the order of `paths`. Loading groups - /// bookmarks by pane, so only the order within this pane matters. - pub fn set_bookmark_order( - &self, - session: SessionId, - side: BookmarkSide, - paths: &[String], - ) -> rusqlite::Result<()> { - let transaction = self.connection.unchecked_transaction()?; - { - let mut update = transaction.prepare( - "UPDATE bookmarks SET sort_order = ?4 \ - WHERE session_id = ?1 AND side = ?2 AND path = ?3", - )?; - for (order, path) in (0_i64..).zip(paths) { - update.execute(params![to_sql(session.0), side.as_str(), path, order])?; - } - } - transaction.commit() - } - - pub fn insert_forward(&self, rule: &ForwardRule) -> rusqlite::Result<()> { - self.connection.execute( - "INSERT INTO forwards (id, session_id, name, kind, bind_host, bind_port, \ - target_host, target_port, auto_start, sort_order) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - to_sql(rule.id.0), - to_sql(rule.session.0), - rule.name.as_ref(), - rule.kind.as_str(), - rule.bind.host.as_ref(), - rule.bind.port, - rule.target.as_ref().map(|target| target.host.as_ref()), - rule.target.as_ref().map(|target| target.port), - rule.auto_start, - rule.sort_order, - ], - )?; - Ok(()) - } - - pub fn update_forward(&self, rule: &ForwardRule) -> rusqlite::Result<()> { - self.connection.execute( - "UPDATE forwards SET session_id = ?2, name = ?3, kind = ?4, bind_host = ?5, \ - bind_port = ?6, target_host = ?7, target_port = ?8, auto_start = ?9, \ - sort_order = ?10 WHERE id = ?1", - params![ - to_sql(rule.id.0), - to_sql(rule.session.0), - rule.name.as_ref(), - rule.kind.as_str(), - rule.bind.host.as_ref(), - rule.bind.port, - rule.target.as_ref().map(|target| target.host.as_ref()), - rule.target.as_ref().map(|target| target.port), - rule.auto_start, - rule.sort_order, - ], - )?; - Ok(()) - } - - pub fn remove_forward(&self, id: ForwardId) -> rusqlite::Result<()> { - self.connection - .execute("DELETE FROM forwards WHERE id = ?1", params![to_sql(id.0)])?; - Ok(()) - } - - pub fn insert_credential(&self, credential: &Credential) -> rusqlite::Result<()> { - self.connection.execute( - "INSERT INTO credentials (id, keychain_id, name, kind, username, key_path, sort_order) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - to_sql(credential.id.0), - credential.keychain_id.as_str(), - credential.name.as_ref(), - credential.kind.as_str(), - credential.user.as_ref(), - credential.key_path.as_deref(), - credential.sort_order, - ], - )?; - Ok(()) - } - - /// Rewrite a credential, and the user name of every session using it in - /// the same transaction: the sessions keep a copy of it. - pub fn update_credential(&self, credential: &Credential) -> rusqlite::Result<()> { - let transaction = self.connection.unchecked_transaction()?; - transaction.execute( - "UPDATE credentials SET name = ?2, kind = ?3, username = ?4, key_path = ?5, \ - sort_order = ?6 WHERE id = ?1", - params![ - to_sql(credential.id.0), - credential.name.as_ref(), - credential.kind.as_str(), - credential.user.as_ref(), - credential.key_path.as_deref(), - credential.sort_order, - ], - )?; - transaction.execute( - "UPDATE sessions SET username = ?2 WHERE id IN \ - (SELECT session_id FROM session_credentials WHERE credential_id = ?1)", - params![to_sql(credential.id.0), credential.user.as_ref()], - )?; - transaction.commit() - } - - /// Delete a credential. The sessions using it go back to logging in on - /// their own with `auth`, keeping the user name they had; the links go - /// through `ON DELETE CASCADE`. - pub fn remove_credential(&self, id: CredentialId, auth: AuthKind) -> rusqlite::Result<()> { - let transaction = self.connection.unchecked_transaction()?; - transaction.execute( - "UPDATE sessions SET auth = ?2 WHERE id IN \ - (SELECT session_id FROM session_credentials WHERE credential_id = ?1)", - params![to_sql(id.0), auth.as_str()], - )?; - transaction.execute( - "DELETE FROM credentials WHERE id = ?1", - params![to_sql(id.0)], - )?; - transaction.commit() - } - - /// Record the operating system a probe found on the host. `None` clears - /// it, which is what a failed probe on a rebuilt host leaves behind. - pub fn set_host_os(&self, id: SessionId, os: Option) -> rusqlite::Result<()> { - self.connection.execute( - "UPDATE sessions SET os = ?2 WHERE id = ?1", - params![to_sql(id.0), os.map(HostOs::as_str)], - )?; - Ok(()) - } - - /// Record that a session just connected, which is what orders the start - /// page's recent list across launches. - pub fn touch_connected(&self, id: SessionId, at: u64) -> rusqlite::Result<()> { - self.connection.execute( - "UPDATE sessions SET last_connected_at = ?2 WHERE id = ?1", - params![to_sql(id.0), to_sql(at)], - )?; - Ok(()) - } -} - -/// Record which credential `session` uses, if any. Its old link, if it had -/// one, is already gone. -fn link_credential(connection: &Connection, session: &Session) -> rusqlite::Result<()> { - if let Some(credential) = session.credential { - connection.execute( - "INSERT INTO session_credentials (session_id, credential_id) VALUES (?1, ?2)", - params![to_sql(session.id.0), to_sql(credential.0)], - )?; - } - Ok(()) -} - -/// Record `session`'s notes, in place of whatever was recorded. -fn write_notes(connection: &Connection, session: &Session) -> rusqlite::Result<()> { - connection.execute( - "DELETE FROM session_notes WHERE session_id = ?1", - params![to_sql(session.id.0)], - )?; - if !session.notes.is_empty() { - connection.execute( - "INSERT INTO session_notes (session_id, notes) VALUES (?1, ?2)", - params![to_sql(session.id.0), session.notes.as_ref()], - )?; - } - Ok(()) -} - -/// Record how `session` is reached, in place of whatever was recorded. -fn write_route(connection: &Connection, session: &Session) -> rusqlite::Result<()> { - let id = to_sql(session.id.0); - connection.execute( - "DELETE FROM session_jumps WHERE session_id = ?1", - params![id], - )?; - connection.execute( - "DELETE FROM session_proxies WHERE session_id = ?1", - params![id], - )?; - match &session.route { - Route::Direct => {} - Route::Jump(hops) => { - let mut insert = connection.prepare( - "INSERT INTO session_jumps (session_id, position, jump_id) VALUES (?1, ?2, ?3)", - )?; - for (position, hop) in hops.iter().enumerate() { - insert.execute(params![id, position as i64, hop.map(|hop| to_sql(hop.0))])?; - } - } - Route::Proxy(proxy) => { - connection.execute( - "INSERT INTO session_proxies (session_id, kind, host, port, username) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - id, - proxy.kind.as_str(), - proxy.host.as_ref(), - proxy.port, - proxy.user.as_deref(), - ], - )?; - } - } - Ok(()) -} - -/// Bring the database to `SCHEMA_VERSION`. A new file has version 0 and gets -/// the whole schema; an older one is backed up, then taken forward one -/// version at a time. Each step is one transaction, so a file is never left -/// between versions. -/// -/// A file from a newer ShellRS is refused and left as it is: this version -/// does not know what the newer tables mean, and writing to them could lose -/// what the newer version saved. That happens after going back to an older -/// version by hand, the one way an update can be undone. -fn migrate(connection: &Connection) -> rusqlite::Result<()> { - let mut version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; - if version == 0 { - return connection.execute_batch(&format!( - "BEGIN;\n{SCHEMA}\nPRAGMA user_version = {SCHEMA_VERSION};\nCOMMIT;" - )); - } - if version > SCHEMA_VERSION { - return Err(rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR), - Some(format!( - "数据库来自更新版本的 ShellRS(结构版本 {version},本版本支持到 \ - {SCHEMA_VERSION}),请安装最新版本" - )), - )); - } - if version < SCHEMA_VERSION { - back_up(connection, version); - } - for (from, step) in STEPS { - if version == from { - match step { - Step::Sql(sql) => connection.execute_batch(sql)?, - Step::Code(change) => change(connection)?, - } - version = from + 1; - } - } - Ok(()) -} - -/// Copy the database to `.v.bak` before migrating it, so the -/// version that wrote it can still open its data. `VACUUM INTO` writes a -/// consistent copy; copying the file would miss what is still in the WAL. -/// A failed backup does not stop the migration: the data is still there, -/// only the way back is not. -fn back_up(connection: &Connection, version: i64) { - let Some(path) = connection.path().filter(|path| !path.is_empty()) else { - return; - }; - let backup = format!("{path}.v{version}.bak"); - let _ = std::fs::remove_file(&backup); - if let Err(error) = connection.execute("VACUUM INTO ?1", [&backup]) { - eprintln!("shellrs: 迁移前无法备份数据库:{error}"); - } -} - -/// The step from version 9: a host logs in on its own with a password or -/// with nothing typed, and a private key is always a credential's. -/// -/// A host that picked a key file of its own now uses a key credential for -/// that file and user, one for each pair, named after the file. `auto` -/// hosts become password hosts, asked for one when none is saved. The -/// `key_path` column stays, empty: dropping a column rebuilds the table. -fn keys_into_credentials(connection: &Connection) -> rusqlite::Result<()> { - let transaction = connection.unchecked_transaction()?; - let keyed = transaction - .prepare( - "SELECT id, username, key_path FROM sessions s \ - WHERE auth = 'key' AND key_path IS NOT NULL AND trim(key_path) <> '' \ - AND NOT EXISTS (SELECT 1 FROM session_credentials c WHERE c.session_id = s.id) \ - ORDER BY id", - )? - .query_map([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - })? - .collect::>>()?; - let mut keychain_ids = transaction - .prepare("SELECT keychain_id FROM credentials")? - .query_map([], |row| row.get::<_, String>(0).map(PublicId::from_stored))? - .collect::>>()?; - let mut sort_order: i64 = transaction.query_row( - "SELECT COALESCE(MAX(sort_order), -1) FROM credentials", - [], - |row| row.get(0), - )?; - let mut made: HashMap<(String, String), i64> = HashMap::new(); - for (session, user, key_path) in keyed { - let pair = (user, key_path); - let credential = match made.get(&pair) { - Some(credential) => *credential, - None => { - let (user, key_path) = &pair; - let name = Path::new(key_path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(key_path); - let keychain_id = PublicId::generate_unused(|id| keychain_ids.contains(id)); - sort_order += 1; - transaction.execute( - "INSERT INTO credentials (keychain_id, name, kind, username, key_path, sort_order) \ - VALUES (?1, ?2, 'key', ?3, ?4, ?5)", - params![keychain_id.as_str(), name, user, key_path, sort_order], - )?; - keychain_ids.insert(keychain_id); - let credential = transaction.last_insert_rowid(); - made.insert(pair.clone(), credential); - credential - } - }; - transaction.execute( - "INSERT INTO session_credentials (session_id, credential_id) VALUES (?1, ?2)", - params![session, credential], - )?; - } - transaction.execute_batch( - "UPDATE sessions SET auth = 'password' WHERE auth <> 'no-password';\n\ - UPDATE sessions SET key_path = NULL;\n\ - PRAGMA user_version = 10;", - )?; - transaction.commit() -} - -/// Give every session without a [`PublicId`] one, which is any row added by -/// hand with `sqlite3`. Runs at every open; once every session has an id it -/// is one query that finds nothing. -fn fill_missing_public_ids(connection: &mut Connection) -> rusqlite::Result<()> { - let missing = connection - .prepare("SELECT id FROM sessions WHERE public_id IS NULL")? - .query_map([], |row| row.get::<_, i64>(0))? - .collect::>>()?; - if missing.is_empty() { - return Ok(()); - } - let mut taken = connection - .prepare("SELECT public_id FROM sessions WHERE public_id IS NOT NULL")? - .query_map([], |row| row.get::<_, String>(0).map(PublicId::from_stored))? - .collect::>>()?; - let transaction = connection.transaction()?; - { - let mut update = transaction.prepare("UPDATE sessions SET public_id = ?2 WHERE id = ?1")?; - for id in missing { - let public_id = PublicId::generate_unused(|candidate| taken.contains(candidate)); - update.execute(params![id, public_id.as_str()])?; - taken.insert(public_id); - } - } - transaction.commit() -} - -/// Seconds since the Unix epoch, for `touch_connected`. -pub fn now_seconds() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_secs()) - .unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn group(id: u64, name: &str, parent: Option) -> SessionGroup { - SessionGroup::new(GroupId(id), GroupDraft::new(name, parent.map(GroupId))) - } - - fn session(id: u64, name: &str, group: Option) -> Session { - Session::new( - SessionId(id), - SessionDraft::new( - name, - "10.0.0.1", - 22, - "root", - AuthKind::Password, - group.map(GroupId), - ), - ) - } - - #[test] - fn a_fresh_database_is_empty_and_at_the_current_version() { - let db = SessionDatabase::in_memory().unwrap(); - let version: i64 = db - .connection - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - assert_eq!(version, SCHEMA_VERSION); - let data = db.load().unwrap(); - assert!(data.groups.is_empty()); - assert!(data.sessions.is_empty()); - assert!(data.recent.is_empty()); - assert!(data.forwards.is_empty()); - } - - /// The schema as version 7 had it, frozen here so the step from 7 keeps - /// being tested against what is really on people's disks. - const SCHEMA_V7: &str = "\ -CREATE TABLE groups ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - parent_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - expanded INTEGER NOT NULL DEFAULT 1 -); -CREATE TABLE sessions ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - host TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - auth TEXT NOT NULL, - group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, - last_connected_at INTEGER, - key_path TEXT, - os TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - public_id TEXT -); -CREATE INDEX sessions_group_id ON sessions(group_id); -CREATE UNIQUE INDEX sessions_public_id ON sessions(public_id); -CREATE TABLE bookmarks ( - id INTEGER PRIMARY KEY, - session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - side TEXT NOT NULL CHECK (side IN ('local', 'remote')), - path TEXT NOT NULL, - sort_order INTEGER NOT NULL, - UNIQUE (session_id, side, path) -); -PRAGMA user_version = 7;"; - - /// Every table and index, as SQLite recorded its definition. - fn schema_of(connection: &Connection) -> Vec<(String, String)> { - connection - .prepare("SELECT name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY name") - .unwrap() - .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) - .unwrap() - .collect::>>() - .unwrap() - } - - #[test] - fn a_version_7_database_gains_the_forwards_table_and_keeps_its_rows() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - { - let old = Connection::open(&path).unwrap(); - old.execute_batch(SCHEMA_V7).unwrap(); - old.execute( - "INSERT INTO sessions (id, name, host, port, username, auth, public_id) \ - VALUES (1, 'web', 'example.test', 22, 'root', 'auto', 'abcdefgh12345678')", - [], - ) - .unwrap(); - } - - let db = SessionDatabase::open(&path).unwrap(); - let version: i64 = db - .connection - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - assert_eq!(version, SCHEMA_VERSION); - let data = db.load().unwrap(); - assert_eq!(data.sessions[0].name.as_ref(), "web"); - assert!(data.forwards.is_empty()); - assert!(data.credentials.is_empty()); - // Upgrading ends with exactly the schema a new file gets. - let fresh = SessionDatabase::in_memory().unwrap(); - assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection)); - - // Opening it again finds nothing left to do. - drop(db); - assert!(SessionDatabase::open(&path).is_ok()); - } - - /// The schema as version 8 had it: version 7's plus the forwards table. - const SCHEMA_V8_FORWARDS: &str = " -CREATE TABLE forwards ( - id INTEGER PRIMARY KEY, - session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT '', - kind TEXT NOT NULL CHECK (kind IN ('local', 'remote', 'dynamic')), - bind_host TEXT NOT NULL, - bind_port INTEGER NOT NULL CHECK (bind_port BETWEEN 1 AND 65535), - target_host TEXT, - target_port INTEGER, - auto_start INTEGER NOT NULL DEFAULT 0, - sort_order INTEGER NOT NULL DEFAULT 0, - CHECK ((kind = 'dynamic' AND target_host IS NULL AND target_port IS NULL) - OR (kind <> 'dynamic' AND target_host IS NOT NULL - AND target_port BETWEEN 1 AND 65535)) -); -CREATE INDEX forwards_session_id ON forwards(session_id); -PRAGMA user_version = 8;"; - - #[test] - fn a_version_8_database_gains_the_credential_tables_and_keeps_its_rows() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - { - let old = Connection::open(&path).unwrap(); - old.execute_batch(SCHEMA_V7).unwrap(); - old.execute_batch(SCHEMA_V8_FORWARDS).unwrap(); - old.execute( - "INSERT INTO sessions (id, name, host, port, username, auth, key_path, public_id) \ - VALUES (1, 'web', 'example.test', 22, 'deploy', 'key', '/tmp/id', 'abcdefgh12345678')", - [], - ) - .unwrap(); - old.execute( - "INSERT INTO forwards (id, session_id, kind, bind_host, bind_port) \ - VALUES (1, 1, 'dynamic', '127.0.0.1', 1080)", - [], - ) - .unwrap(); - } - - let db = SessionDatabase::open(&path).unwrap(); - let version: i64 = db - .connection - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - assert_eq!(version, SCHEMA_VERSION); - let data = db.load().unwrap(); - assert_eq!(data.sessions[0].user.as_ref(), "deploy"); - assert_eq!(data.forwards.len(), 1); - // On through version 10, its key file became a credential. - assert_eq!(data.credentials.len(), 1); - assert_eq!(data.sessions[0].credential, Some(data.credentials[0].id)); - let fresh = SessionDatabase::in_memory().unwrap(); - assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection)); - - drop(db); - assert!(SessionDatabase::open(&path).is_ok()); - } - - /// The credential tables version 9 added, frozen like `SCHEMA_V7`. - const SCHEMA_V9_CREDENTIALS: &str = " -CREATE TABLE credentials ( - id INTEGER PRIMARY KEY, - keychain_id TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - kind TEXT NOT NULL CHECK (kind IN ('password', 'key', 'agent')), - username TEXT NOT NULL, - key_path TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - CHECK ((kind = 'key') = (key_path IS NOT NULL)) -); -CREATE TABLE session_credentials ( - session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, - credential_id INTEGER NOT NULL REFERENCES credentials(id) ON DELETE CASCADE -); -CREATE INDEX session_credentials_credential_id ON session_credentials(credential_id); -PRAGMA user_version = 9;"; - - #[test] - fn a_version_9_database_moves_host_keys_into_credentials() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - { - let old = Connection::open(&path).unwrap(); - old.execute_batch(SCHEMA_V7).unwrap(); - old.execute_batch(SCHEMA_V8_FORWARDS).unwrap(); - old.execute_batch(SCHEMA_V9_CREDENTIALS).unwrap(); - old.execute_batch( - "INSERT INTO credentials (id, keychain_id, name, kind, username, sort_order) \ - VALUES (1, 'kept0000kept0000', 'agent', 'agent', 'me', 0); - INSERT INTO sessions (id, name, host, port, username, auth, key_path, public_id) VALUES - (1, 'auto', 'a.test', 22, 'root', 'auto', NULL, 'p000000000000001'), - (2, 'web', 'b.test', 22, 'deploy', 'key', '/home/me/.ssh/id_deploy', 'p000000000000002'), - (3, 'api', 'c.test', 22, 'deploy', 'key', '/home/me/.ssh/id_deploy', 'p000000000000003'), - (4, 'ops', 'd.test', 22, 'root', 'key', '/home/me/.ssh/id_deploy', 'p000000000000004'), - (5, 'db', 'e.test', 22, 'postgres', 'password', NULL, 'p000000000000005'), - (6, 'jump', 'f.test', 22, 'me', 'auto', NULL, 'p000000000000006'); - INSERT INTO session_credentials (session_id, credential_id) VALUES (6, 1);", - ) - .unwrap(); - } - - let db = SessionDatabase::open(&path).unwrap(); - let data = db.load().unwrap(); - // One key credential for each user and key file, named after the - // file, after the credential that was there. - let names: Vec<_> = data - .credentials - .iter() - .map(|credential| (credential.name.as_ref(), credential.user.as_ref())) - .collect(); - assert_eq!( - names, - [ - ("agent", "me"), - ("id_deploy", "deploy"), - ("id_deploy", "root") - ] - ); - let deploy = &data.credentials[1]; - assert_eq!(deploy.kind, CredentialKind::Key); - assert_eq!(deploy.key_path.as_deref(), Some("/home/me/.ssh/id_deploy")); - assert_ne!(deploy.keychain_id, data.credentials[2].keychain_id); - let uses: Vec<_> = data.sessions.iter().map(|s| s.credential).collect(); - assert_eq!( - uses, - [ - None, - Some(deploy.id), - Some(deploy.id), - Some(data.credentials[2].id), - None, - Some(CredentialId(1)), - ] - ); - // Everything left logs in with a password, asked for when none is - // saved; no host keeps a key file of its own. - assert!(data.sessions.iter().all(|s| s.auth == AuthKind::Password)); - let key_files: i64 = db - .connection - .query_row( - "SELECT COUNT(*) FROM sessions WHERE key_path IS NOT NULL", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(key_files, 0); - let fresh = SessionDatabase::in_memory().unwrap(); - assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection)); - - // Opening it again finds nothing left to do. - drop(db); - let again = SessionDatabase::open(&path).unwrap().load().unwrap(); - assert_eq!(again.credentials.len(), 3); - } - - #[test] - fn a_database_from_a_newer_shellrs_is_refused_untouched() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - drop(SessionDatabase::open(&path).unwrap()); - let newer = SCHEMA_VERSION + 1; - Connection::open(&path) - .unwrap() - .pragma_update(None, "user_version", newer) - .unwrap(); - - let Err(error) = SessionDatabase::open(&path) else { - panic!("a newer database opened"); - }; - assert!(error.to_string().contains("更新版本的 ShellRS"), "{error}"); - let version: i64 = Connection::open(&path) - .unwrap() - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - assert_eq!(version, newer); - assert!(!dir.path().join(format!("shellrs.db.v{newer}.bak")).exists()); - } - - #[test] - fn an_older_database_is_backed_up_before_it_is_migrated() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - { - let old = Connection::open(&path).unwrap(); - old.execute_batch(SCHEMA_V7).unwrap(); - old.execute_batch(SCHEMA_V8_FORWARDS).unwrap(); - old.execute_batch(SCHEMA_V9_CREDENTIALS).unwrap(); - old.execute_batch( - "INSERT INTO sessions (id, name, host, port, username, auth, public_id) \ - VALUES (1, 'web', 'example.test', 22, 'root', 'password', 'abcdefgh12345678'); - PRAGMA user_version = 10;", - ) - .unwrap(); - } - drop(SessionDatabase::open(&path).unwrap()); - - let backup = Connection::open(dir.path().join("shellrs.db.v10.bak")).unwrap(); - let version: i64 = backup - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - assert_eq!(version, 10); - let name: String = backup - .query_row("SELECT name FROM sessions WHERE id = 1", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(name, "web"); - } - - #[test] - fn a_new_or_current_database_needs_no_backup() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - drop(SessionDatabase::open(&path).unwrap()); - drop(SessionDatabase::open(&path).unwrap()); - let files: Vec = std::fs::read_dir(dir.path()) - .unwrap() - .flatten() - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .filter(|name| name.ends_with(".bak")) - .collect(); - assert!(files.is_empty(), "{files:?}"); - } - - /// The route tables version 11 added, frozen like `SCHEMA_V7`. - const SCHEMA_V11_ROUTES: &str = " -CREATE TABLE session_jumps ( - session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - position INTEGER NOT NULL, - jump_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL, - PRIMARY KEY (session_id, position), - CHECK (jump_id <> session_id) -); -CREATE INDEX session_jumps_jump_id ON session_jumps(jump_id); -CREATE TABLE session_proxies ( - session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK (kind IN ('http', 'socks5')), - host TEXT NOT NULL, - port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535), - username TEXT -); -PRAGMA user_version = 11;"; - - #[test] - fn a_version_11_database_gains_the_notes_table_and_keeps_its_rows() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - { - let old = Connection::open(&path).unwrap(); - old.execute_batch(SCHEMA_V7).unwrap(); - old.execute_batch(SCHEMA_V8_FORWARDS).unwrap(); - old.execute_batch(SCHEMA_V9_CREDENTIALS).unwrap(); - old.execute_batch(SCHEMA_V11_ROUTES).unwrap(); - old.execute_batch( - "INSERT INTO sessions (id, name, host, port, username, auth, public_id) VALUES - (1, 'jump', 'a.test', 22, 'root', 'password', 'p000000000000001'), - (2, 'db', 'b.test', 22, 'root', 'password', 'p000000000000002'); - INSERT INTO session_jumps (session_id, position, jump_id) VALUES (2, 0, 1);", - ) - .unwrap(); - } - - let db = SessionDatabase::open(&path).unwrap(); - let version: i64 = db - .connection - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - assert_eq!(version, SCHEMA_VERSION); - let data = db.load().unwrap(); - assert_eq!( - data.sessions[1].route, - Route::Jump(vec![Some(SessionId(1))]) - ); - assert!(data.sessions.iter().all(|session| session.notes.is_empty())); - let fresh = SessionDatabase::in_memory().unwrap(); - assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection)); - - drop(db); - assert!(SessionDatabase::open(&path).is_ok()); - } - - #[test] - fn notes_round_trip_and_go_with_their_session() { - let db = SessionDatabase::in_memory().unwrap(); - let mut noted = session(1, "web", None); - noted.notes = "机房 A,到期 2027-03\n负责人:张三".into(); - db.insert_session(¬ed).unwrap(); - db.insert_session(&session(2, "db", None)).unwrap(); - let notes = |db: &SessionDatabase| -> Vec { - db.load() - .unwrap() - .sessions - .into_iter() - .map(|session| session.notes.to_string()) - .collect() - }; - assert_eq!( - notes(&db), - [ - "机房 A,到期 2027-03\n负责人:张三".to_string(), - String::new() - ] - ); - - // Emptied notes leave no row behind. - db.update_session(&session(1, "web", None)).unwrap(); - assert_eq!(notes(&db), [String::new(), String::new()]); - let mut noted = session(2, "db", None); - noted.notes = "只读副本".into(); - db.update_session(¬ed).unwrap(); - db.remove_session(SessionId(2)).unwrap(); - let rows: i64 = db - .connection - .query_row("SELECT COUNT(*) FROM session_notes", [], |row| row.get(0)) - .unwrap(); - assert_eq!(rows, 0); - } - - #[test] - fn a_version_10_database_gains_the_route_tables_and_keeps_its_rows() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - { - let old = Connection::open(&path).unwrap(); - old.execute_batch(SCHEMA_V7).unwrap(); - old.execute_batch(SCHEMA_V8_FORWARDS).unwrap(); - old.execute_batch(SCHEMA_V9_CREDENTIALS).unwrap(); - // Version 10 changed rows, not tables. - old.execute_batch( - "INSERT INTO sessions (id, name, host, port, username, auth, public_id) \ - VALUES (1, 'web', 'example.test', 22, 'root', 'no-password', 'abcdefgh12345678'); - PRAGMA user_version = 10;", - ) - .unwrap(); - } - - let db = SessionDatabase::open(&path).unwrap(); - let version: i64 = db - .connection - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - assert_eq!(version, SCHEMA_VERSION); - let data = db.load().unwrap(); - assert_eq!(data.sessions[0].auth, AuthKind::NoPassword); - assert_eq!(data.sessions[0].route, Route::Direct); - let fresh = SessionDatabase::in_memory().unwrap(); - assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection)); - - drop(db); - assert!(SessionDatabase::open(&path).is_ok()); - } - - fn routed(mut session: Session, route: Route) -> Session { - session.route = route; - session - } - - #[test] - fn routes_round_trip_and_a_deleted_jump_host_leaves_its_place() { - let db = SessionDatabase::in_memory().unwrap(); - let proxy = - Route::Proxy(ProxySettings::new(ProxyKind::Socks5, "127.0.0.1", 7890).with_user("me")); - let sessions = [ - session(1, "阿里云99", None), - session(2, "禅道", None), - routed( - session(3, "db", None), - Route::Jump(vec![Some(SessionId(1)), Some(SessionId(2))]), - ), - routed(session(4, "abroad", None), proxy.clone()), - routed( - session(5, "http", None), - Route::Proxy(ProxySettings::new(ProxyKind::Http, "proxy.test", 8080)), - ), - ]; - for session in &sessions { - db.insert_session(session).unwrap(); - } - let routes = |db: &SessionDatabase| -> Vec { - db.load() - .unwrap() - .sessions - .into_iter() - .map(|session| session.route) - .collect() - }; - assert_eq!( - routes(&db), - sessions.iter().map(|s| s.route.clone()).collect::>() - ); - - // Updating rewrites the route, and the order of the hops with it. - db.update_session(&routed( - session(3, "db", None), - Route::Jump(vec![Some(SessionId(2)), Some(SessionId(1))]), - )) - .unwrap(); - db.update_session(&session(4, "abroad", None)).unwrap(); - assert_eq!( - routes(&db)[2..4], - [ - Route::Jump(vec![Some(SessionId(2)), Some(SessionId(1))]), - Route::Direct - ] - ); - - // The jump host goes; its place stays, empty. Its own route goes - // with a session that is deleted. - db.remove_session(SessionId(1)).unwrap(); - db.remove_session(SessionId(5)).unwrap(); - assert_eq!( - routes(&db), - [ - Route::Direct, - Route::Jump(vec![Some(SessionId(2)), None]), - Route::Direct - ] - ); - let proxies: i64 = db - .connection - .query_row("SELECT COUNT(*) FROM session_proxies", [], |row| row.get(0)) - .unwrap(); - assert_eq!(proxies, 0); - } - - #[test] - fn the_database_refuses_a_session_that_jumps_through_itself() { - let db = SessionDatabase::in_memory().unwrap(); - let looped = routed( - session(1, "web", None), - Route::Jump(vec![Some(SessionId(1))]), - ); - assert!(db.insert_session(&looped).is_err()); - // The transaction took the session with it. - assert!(db.load().unwrap().sessions.is_empty()); - } - - fn credential(id: u64, name: &str, kind: CredentialKind) -> Credential { - let draft = CredentialDraft::new(name, kind, "deploy"); - let draft = match kind { - CredentialKind::Key => draft.with_key_path("/tmp/id_deploy"), - _ => draft, - }; - Credential::new(CredentialId(id), draft) - } - - fn using(mut session: Session, credential: &Credential) -> Session { - session.credential = Some(credential.id); - session.user = credential.user.clone(); - session.auth = AuthKind::Password; - session - } - - #[test] - fn credentials_round_trip_and_hosts_keep_their_link() { - let db = SessionDatabase::in_memory().unwrap(); - let mut agent = credential(1, "agent", CredentialKind::Agent); - agent.sort_order = 1; - db.insert_credential(&agent).unwrap(); - let key = credential(2, "部署", CredentialKind::Key); - db.insert_credential(&key).unwrap(); - let web = using(session(1, "web", None), &key); - db.insert_session(&web).unwrap(); - db.insert_session(&session(2, "db", None)).unwrap(); - - let data = db.load().unwrap(); - // `sort_order` first, then id. - assert_eq!(data.credentials, [key.clone(), agent.clone()]); - assert_eq!(data.sessions[0].credential, Some(key.id)); - assert_eq!(data.sessions[1].credential, None); - - // Moving a host to another credential replaces its link; leaving it - // takes the link away. - db.update_session(&using(web.clone(), &agent)).unwrap(); - assert_eq!(db.load().unwrap().sessions[0].credential, Some(agent.id)); - let mut manual = web.clone(); - manual.credential = None; - db.update_session(&manual).unwrap(); - assert_eq!(db.load().unwrap().sessions[0].credential, None); - - // Editing a credential renames its user on every host using it. - db.update_session(&web).unwrap(); - let mut renamed = key.clone(); - renamed.name = "生产部署".into(); - renamed.user = "admin".into(); - db.update_credential(&renamed).unwrap(); - let data = db.load().unwrap(); - assert_eq!(data.credentials[0], renamed); - assert_eq!(data.sessions[0].user.as_ref(), "admin"); - assert_eq!(data.sessions[1].user.as_ref(), "root"); - - // Deleting a host drops its link with it. - db.remove_session(SessionId(1)).unwrap(); - let links: i64 = db - .connection - .query_row("SELECT COUNT(*) FROM session_credentials", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(links, 0); - } - - #[test] - fn removing_a_credential_leaves_its_hosts_on_their_own_and_drops_the_link() { - let db = SessionDatabase::in_memory().unwrap(); - let key = credential(1, "部署", CredentialKind::Key); - db.insert_credential(&key).unwrap(); - db.insert_session(&using(session(1, "web", None), &key)) - .unwrap(); - db.insert_session(&session(2, "db", None)).unwrap(); - - db.remove_credential(key.id, AuthKind::NoPassword).unwrap(); - let data = db.load().unwrap(); - assert!(data.credentials.is_empty()); - assert_eq!(data.sessions[0].credential, None); - assert_eq!(data.sessions[0].auth, AuthKind::NoPassword); - assert_eq!(data.sessions[0].user.as_ref(), "deploy"); - // A host that never used it is untouched. - assert_eq!(data.sessions[1].auth, AuthKind::Password); - } - - #[test] - fn the_database_refuses_a_credential_whose_key_file_does_not_fit_its_kind() { - let db = SessionDatabase::in_memory().unwrap(); - let mut key_without_file = credential(1, "部署", CredentialKind::Key); - key_without_file.key_path = None; - assert!(db.insert_credential(&key_without_file).is_err()); - - let mut password_with_file = credential(2, "运维", CredentialKind::Password); - password_with_file.key_path = Some("/tmp/id".into()); - assert!(db.insert_credential(&password_with_file).is_err()); - - // A host cannot use a credential that is not there. - let mut orphan = session(1, "web", None); - orphan.credential = Some(CredentialId(99)); - assert!(db.insert_session(&orphan).is_err()); - // And the failed insert left no half of the host behind. - assert!(db.load().unwrap().sessions.is_empty()); - } - - fn forward(id: u64, session: u64, kind: ForwardKind, port: u16) -> ForwardRule { - let target = kind - .has_target() - .then(|| ForwardEndpoint::new("db.internal", 3306)); - ForwardRule::new( - ForwardId(id), - ForwardDraft::new( - kind, - SessionId(session), - ForwardEndpoint::new("127.0.0.1", port), - target, - ), - ) - } - - #[test] - fn forwards_round_trip_in_list_order_and_go_with_their_session() { - let db = SessionDatabase::in_memory().unwrap(); - db.insert_group(&group(1, "生产", None)).unwrap(); - db.insert_session(&session(1, "web", Some(1))).unwrap(); - db.insert_session(&session(2, "db", None)).unwrap(); - - let mut local = forward(1, 1, ForwardKind::Local, 8080); - local.name = "数据库".into(); - local.auto_start = true; - local.sort_order = 1; - db.insert_forward(&local).unwrap(); - let socks = forward(2, 2, ForwardKind::Dynamic, 1080); - db.insert_forward(&socks).unwrap(); - let remote = forward(3, 1, ForwardKind::Remote, 9000); - db.insert_forward(&remote).unwrap(); - - // `sort_order` first, then id: the dynamic and remote rules are at 0. - assert_eq!( - db.load().unwrap().forwards, - [socks.clone(), remote.clone(), local.clone()] - ); - - let mut edited = local.clone(); - edited.kind = ForwardKind::Dynamic; - edited.target = None; - edited.session = SessionId(2); - edited.bind = ForwardEndpoint::new("0.0.0.0", 1081); - edited.auto_start = false; - db.update_forward(&edited).unwrap(); - assert_eq!(db.load().unwrap().forwards[2], edited); - - db.remove_forward(ForwardId(2)).unwrap(); - assert_eq!(db.load().unwrap().forwards, [remote, edited.clone()]); - - // Deleting a group takes its sessions, and they take their forwards. - db.remove_group(GroupId(1)).unwrap(); - assert_eq!(db.load().unwrap().forwards, [edited]); - db.remove_session(SessionId(2)).unwrap(); - assert!(db.load().unwrap().forwards.is_empty()); - } - - #[test] - fn the_database_refuses_a_forward_whose_target_does_not_fit_its_kind() { - let db = SessionDatabase::in_memory().unwrap(); - db.insert_session(&session(1, "web", None)).unwrap(); - - let mut dynamic_with_target = forward(1, 1, ForwardKind::Dynamic, 1080); - dynamic_with_target.target = Some(ForwardEndpoint::new("db", 3306)); - assert!(db.insert_forward(&dynamic_with_target).is_err()); - - let mut local_without_target = forward(2, 1, ForwardKind::Local, 8080); - local_without_target.target = None; - assert!(db.insert_forward(&local_without_target).is_err()); - - // A rule must go through a session that exists. - assert!( - db.insert_forward(&forward(3, 99, ForwardKind::Local, 8080)) - .is_err() - ); - } - - #[test] - fn round_trips_nested_groups_and_sessions() { - let db = SessionDatabase::in_memory().unwrap(); - db.insert_group(&group(1, "生产", None)).unwrap(); - db.insert_group(&group(2, "数据库", Some(1))).unwrap(); - db.insert_session(&session(1, "web-01", Some(1))).unwrap(); - db.insert_session(&session(2, "db-01", Some(2))).unwrap(); - // A session at the root of the tree, outside every group. - db.insert_session(&session(3, "jump", None)).unwrap(); - - let data = db.load().unwrap(); - assert_eq!(data.groups.len(), 2); - assert_eq!(data.groups[1].parent, Some(GroupId(1))); - let names: Vec<_> = data.sessions.iter().map(|s| s.name.as_ref()).collect(); - assert_eq!(names, ["web-01", "db-01", "jump"]); - assert_eq!(data.sessions[2].group, None); - assert_eq!(data.sessions[0].auth, AuthKind::Password); - // Connection state is runtime only: everything loads disconnected. - assert!(data.sessions.iter().all(|s| !s.state.is_connected())); - } - - #[test] - fn updating_rewrites_the_editable_fields() { - let db = SessionDatabase::in_memory().unwrap(); - db.insert_group(&group(1, "生产", None)).unwrap(); - db.insert_group(&group(2, "测试", None)).unwrap(); - db.insert_session(&session(1, "web-01", Some(1))).unwrap(); - - let mut moved = session(1, "web-01", Some(2)); - moved.port = 2222; - moved.auth = AuthKind::NoPassword; - db.update_session(&moved).unwrap(); - db.update_group(&group(2, "预发", None)).unwrap(); - - let data = db.load().unwrap(); - assert_eq!(data.sessions[0].port, 2222); - assert_eq!(data.sessions[0].auth, AuthKind::NoPassword); - assert_eq!(data.sessions[0].group, Some(GroupId(2))); - assert_eq!(data.groups[1].name.as_ref(), "预发"); - } - - #[test] - fn removing_a_group_cascades_to_subgroups_and_their_sessions() { - let db = SessionDatabase::in_memory().unwrap(); - db.insert_group(&group(1, "生产", None)).unwrap(); - db.insert_group(&group(2, "数据库", Some(1))).unwrap(); - db.insert_group(&group(3, "测试", None)).unwrap(); - db.insert_session(&session(1, "web-01", Some(1))).unwrap(); - db.insert_session(&session(2, "db-01", Some(2))).unwrap(); - db.insert_session(&session(3, "qa", Some(3))).unwrap(); - db.insert_session(&session(4, "jump", None)).unwrap(); - - db.remove_group(GroupId(1)).unwrap(); - - let data = db.load().unwrap(); - let groups: Vec<_> = data.groups.iter().map(|g| g.name.as_ref()).collect(); - assert_eq!(groups, ["测试"]); - let sessions: Vec<_> = data.sessions.iter().map(|s| s.name.as_ref()).collect(); - assert_eq!(sessions, ["qa", "jump"]); - } - - #[test] - fn recent_lists_the_most_recently_connected_first() { - let db = SessionDatabase::in_memory().unwrap(); - for id in 1..=3 { - db.insert_session(&session(id, &format!("s{id}"), None)) - .unwrap(); - } - db.touch_connected(SessionId(1), 100).unwrap(); - db.touch_connected(SessionId(3), 300).unwrap(); - // Session 2 never connected and stays out of the list. - assert_eq!( - db.load().unwrap().recent, - [SessionId(3), SessionId(1)], - "newest connection first" - ); - - db.touch_connected(SessionId(1), 400).unwrap(); - assert_eq!(db.load().unwrap().recent, [SessionId(1), SessionId(3)]); - } - - #[test] - fn reopening_the_same_file_keeps_the_data() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - { - let db = SessionDatabase::open(&path).unwrap(); - db.insert_group(&group(1, "生产", None)).unwrap(); - db.insert_session(&session(1, "web-01", Some(1))).unwrap(); - db.touch_connected(SessionId(1), 42).unwrap(); - } - let db = SessionDatabase::open(&path).unwrap(); - let data = db.load().unwrap(); - assert_eq!(data.groups.len(), 1); - assert_eq!(data.sessions[0].name.as_ref(), "web-01"); - assert_eq!(data.recent, [SessionId(1)]); - } - - #[test] - fn tree_order_and_parent_survive_reopening() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - { - let db = SessionDatabase::open(&path).unwrap(); - db.insert_group(&group(1, "生产", None)).unwrap(); - db.insert_group(&group(2, "测试", None)).unwrap(); - db.insert_session(&session(1, "web", Some(1))).unwrap(); - db.insert_session(&session(2, "db", Some(1))).unwrap(); - let mut data = db.load().unwrap(); - data.groups[1].parent = Some(GroupId(1)); - data.groups[1].sort_order = 0; - data.sessions[0].group = Some(GroupId(2)); - data.sessions[1].sort_order = 0; - db.save_tree_order(&data.groups, &data.sessions).unwrap(); - } - let data = SessionDatabase::open(&path).unwrap().load().unwrap(); - assert_eq!(data.groups[1].parent, Some(GroupId(1))); - assert_eq!(data.sessions[0].group, Some(GroupId(2))); - assert_eq!(data.sessions[1].sort_order, 0); - } - - #[test] - fn groups_start_expanded_and_then_keep_the_saved_choice() { - let database = SessionDatabase::in_memory().unwrap(); - database - .connection - .execute("INSERT INTO groups (id, name) VALUES (1, '生产')", []) - .unwrap(); - - let mut group = database.load().unwrap().groups.remove(0); - assert!(group.expanded); - database.set_group_expanded(group.id, false).unwrap(); - group.name = "生产环境".into(); - group.expanded = false; - database.update_group(&group).unwrap(); - let saved = database.load().unwrap().groups.remove(0); - assert_eq!(saved.name.as_ref(), "生产环境"); - assert!(!saved.expanded); - } - - #[test] - fn a_public_id_is_saved_and_kept_through_updates() { - let db = SessionDatabase::in_memory().unwrap(); - let session = session(1, "web-01", None); - db.insert_session(&session).unwrap(); - assert_eq!(db.load().unwrap().sessions[0].public_id, session.public_id); - - let mut renamed = session.clone(); - renamed.name = "web".into(); - renamed.public_id = PublicId::generate(); - db.update_session(&renamed).unwrap(); - assert_eq!(db.load().unwrap().sessions[0].public_id, session.public_id); - - // Two sessions can never share one. - let mut twin = Session::new(SessionId(2), session.draft()); - twin.public_id = session.public_id.clone(); - assert!(db.insert_session(&twin).is_err()); - } - - #[test] - fn sessions_added_by_hand_get_distinct_public_ids_that_stay() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shellrs.db"); - SessionDatabase::open(&path) - .unwrap() - .connection - .execute( - "INSERT INTO sessions (id, name, host, port, username, auth) \ - VALUES (1, 'web', 'example.test', 22, 'root', 'auto'), \ - (2, 'db', 'example.test', 22, 'root', 'auto')", - [], - ) - .unwrap(); - - let ids = || -> Vec { - let data = SessionDatabase::open(&path).unwrap().load().unwrap(); - data.sessions.into_iter().map(|s| s.public_id).collect() - }; - let first = ids(); - assert_eq!(first.len(), 2); - assert_ne!(first[0], first[1]); - assert!(first.iter().all(|id| id.as_str().len() == 16)); - // Assigned once, not again at every open. - assert_eq!(ids(), first); - } - - #[test] - fn the_detected_operating_system_is_read_back() { - let db = SessionDatabase::in_memory().unwrap(); - let session = Session::new( - SessionId(1), - SessionDraft::new("web", "10.0.0.1", 22, "root", AuthKind::Password, None), - ); - db.insert_session(&session).unwrap(); - assert_eq!(db.load().unwrap().sessions[0].os, None); - - db.set_host_os(session.id, Some(HostOs::Ubuntu)).unwrap(); - assert_eq!(db.load().unwrap().sessions[0].os, Some(HostOs::Ubuntu)); - - // Renaming must not disturb it: the form does not own this column. - let mut renamed = session.clone(); - renamed.name = "web-01".into(); - db.update_session(&renamed).unwrap(); - assert_eq!(db.load().unwrap().sessions[0].os, Some(HostOs::Ubuntu)); - - db.set_host_os(session.id, None).unwrap(); - assert_eq!(db.load().unwrap().sessions[0].os, None); - } - - #[test] - fn bookmarks_keep_their_order_and_go_with_their_session() { - let db = SessionDatabase::in_memory().unwrap(); - db.insert_session(&session(1, "web", None)).unwrap(); - db.insert_session(&session(2, "db", None)).unwrap(); - db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/var/log") - .unwrap(); - db.insert_bookmark(SessionId(1), BookmarkSide::Local, "/Users/me") - .unwrap(); - db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/etc") - .unwrap(); - // Adding the same path twice keeps one bookmark in its first place. - db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/var/log") - .unwrap(); - db.insert_bookmark(SessionId(2), BookmarkSide::Remote, "/srv") - .unwrap(); - assert_eq!( - db.load().unwrap().bookmarks, - [ - (SessionId(1), BookmarkSide::Remote, "/var/log".to_string()), - (SessionId(1), BookmarkSide::Local, "/Users/me".to_string()), - (SessionId(1), BookmarkSide::Remote, "/etc".to_string()), - (SessionId(2), BookmarkSide::Remote, "/srv".to_string()), - ] - ); - // Reordering one pane leaves the other panes' order alone, and a - // bookmark added afterwards still goes last. - db.set_bookmark_order( - SessionId(1), - BookmarkSide::Remote, - &["/etc".into(), "/var/log".into()], - ) - .unwrap(); - db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/opt") - .unwrap(); - let remote: Vec<_> = db - .load() - .unwrap() - .bookmarks - .into_iter() - .filter(|(session, side, _)| *session == SessionId(1) && *side == BookmarkSide::Remote) - .map(|(_, _, path)| path) - .collect(); - assert_eq!(remote, ["/etc", "/var/log", "/opt"]); - db.remove_bookmark(SessionId(1), BookmarkSide::Remote, "/opt") - .unwrap(); - db.remove_bookmark(SessionId(1), BookmarkSide::Remote, "/etc") - .unwrap(); - db.remove_session(SessionId(2)).unwrap(); - assert_eq!( - db.load().unwrap().bookmarks, - [ - (SessionId(1), BookmarkSide::Remote, "/var/log".to_string()), - (SessionId(1), BookmarkSide::Local, "/Users/me".to_string()), - ] - ); - } - - #[test] - fn schema_never_contains_secret_columns() { - let db = SessionDatabase::in_memory().unwrap(); - let tables = db - .connection - .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") - .unwrap() - .query_map([], |row| row.get::<_, String>(0)) - .unwrap() - .collect::>>() - .unwrap(); - for table in [ - "sessions", - "forwards", - "credentials", - "session_credentials", - "session_jumps", - "session_proxies", - "session_notes", - ] { - assert!(tables.contains(&table.to_string()), "{table}"); - } - let mut columns = Vec::new(); - for table in tables { - let mut statement = db - .connection - .prepare(&format!("PRAGMA table_info({table})")) - .unwrap(); - columns.extend( - statement - .query_map([], |row| row.get::<_, String>(1)) - .unwrap() - .collect::>>() - .unwrap(), - ); - } - assert!(columns.contains(&"key_path".to_string())); - assert!(!columns.iter().any(|column| { - column.contains("password") || column.contains("passphrase") || column.contains("otp") - })); - } -} diff --git a/src/session/mod.rs b/src/session/mod.rs deleted file mode 100644 index 6d9c4b9..0000000 --- a/src/session/mod.rs +++ /dev/null @@ -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}; diff --git a/src/settings/model.rs b/src/settings/model.rs index f5caee1..ecc0c8a 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -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, } diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index d9dbc93..496fd2b 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -318,7 +318,7 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec { ] } -/// 外部 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 { let (reader, writer) = (panel.store.clone(), panel.store.clone()); diff --git a/src/settings/store.rs b/src/settings/store.rs index 435e2bf..a0c84d4 100644 --- a/src/settings/store.rs +++ b/src/settings/store.rs @@ -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 { diff --git a/src/sftp/protocol_tests.rs b/src/sftp/protocol_tests.rs index 2ae930a..4a7ab24 100644 --- a/src/sftp/protocol_tests.rs +++ b/src/sftp/protocol_tests.rs @@ -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()); diff --git a/src/sftp/transport.rs b/src/sftp/transport.rs index f5f5b76..1037a66 100644 --- a/src/sftp/transport.rs +++ b/src/sftp/transport.rs @@ -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; + fn create(&self, login: &HostLogin) -> Box; } pub type SharedSftpTransportProvider = Arc; diff --git a/src/sftp/worker.rs b/src/sftp/worker.rs index 7ad2a7c..8cc6a0c 100644 --- a/src/sftp/worker.rs +++ b/src/sftp/worker.rs @@ -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 { + fn create(&self, login: &HostLogin) -> Box { 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), diff --git a/src/shared/closable_tab.rs b/src/shared/closable_tab.rs index 9a5b65b..0d84a68 100644 --- a/src/shared/closable_tab.rs +++ b/src/shared/closable_tab.rs @@ -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) -> PopupMenu>; @@ -20,9 +20,9 @@ type MenuBuilder = Rc) -> /// 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() diff --git a/src/shared/host_mark.rs b/src/shared/host_mark.rs index 86cf1df..3ca4f04 100644 --- a/src/shared/host_mark.rs +++ b/src/shared/host_mark.rs @@ -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 diff --git a/src/ssh/connection.rs b/src/ssh/connection.rs index ba6b292..438c535 100644 --- a/src/ssh/connection.rs +++ b/src/ssh/connection.rs @@ -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, secrets: &SharedSecretStore, @@ -200,7 +200,7 @@ impl SshConnector { async fn through_jumps( &self, hops: &[JumpLogin], - login: &SessionLogin, + login: &HostLogin, broker: &Arc, secrets: &SharedSecretStore, ) -> Result { @@ -255,7 +255,7 @@ impl SshConnector { &self, tunnel: Option, name: &str, - hop: &SessionLogin, + hop: &HostLogin, next: (&str, u16), broker: &Arc, secrets: &SharedSecretStore, @@ -349,7 +349,7 @@ async fn tcp(host: &str, port: u16) -> Result { /// A stream to `login`'s host through `proxy`. async fn through_proxy( proxy: &ProxyLogin, - login: &SessionLogin, + login: &HostLogin, secrets: &SharedSecretStore, ) -> Result { 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, @@ -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 Err(AgentProblem::Unreachable("这个平台不支持".to_string())) } -fn key_paths(login: &SessionLogin) -> Result> { +fn key_paths(login: &HostLogin) -> Result> { 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())) { diff --git a/src/ssh/exec.rs b/src/ssh/exec.rs index b27a493..070c249 100644 --- a/src/ssh/exec.rs +++ b/src/ssh/exec.rs @@ -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. diff --git a/src/ssh/probe.rs b/src/ssh/probe.rs index 50e952d..462fed9 100644 --- a/src/ssh/probe.rs +++ b/src/ssh/probe.rs @@ -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() { diff --git a/src/ssh/proxy.rs b/src/ssh/proxy.rs index 26752eb..065d6e7 100644 --- a/src/ssh/proxy.rs +++ b/src/ssh/proxy.rs @@ -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> { diff --git a/src/ssh/route_tests.rs b/src/ssh/route_tests.rs index 5b41878..2aad311 100644 --- a/src/ssh/route_tests.rs +++ b/src/ssh/route_tests.rs @@ -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 { diff --git a/src/ssh/tester.rs b/src/ssh/tester.rs index 78d1191..4970a1c 100644 --- a/src/ssh/tester.rs +++ b/src/ssh/tester.rs @@ -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 diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index d16e959..9fa4e91 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -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, ) -> ( @@ -982,7 +982,7 @@ mod tests { Vec, ) { 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(); diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 62a9576..29383e3 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -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::{ diff --git a/src/terminal/terminal_panel.rs b/src/terminal/terminal_panel.rs index fc51c73..79bb9a7 100644 --- a/src/terminal/terminal_panel.rs +++ b/src/terminal/terminal_panel.rs @@ -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, + host_id: HostId, + store: Entity, terminal: Entity, remote_provider: SharedRemoteTerminalTransportProvider, tab_group: Option>, @@ -48,36 +48,36 @@ pub struct TerminalPanel { impl TerminalPanel { pub fn new( id: RemoteTerminalId, - session_id: SessionId, - store: Entity, + host_id: HostId, + store: Entity, remote_provider: SharedRemoteTerminalTransportProvider, window: &mut Window, cx: &mut Context, ) -> 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> { @@ -124,12 +124,12 @@ impl TerminalPanel { fn tab_menu(&self, cx: &Context) -> 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) { - 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) -> Option> { - 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>, 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( diff --git a/src/terminal/transport.rs b/src/terminal/transport.rs index 79f1f41..0e485ae 100644 --- a/src/terminal/transport.rs +++ b/src/terminal/transport.rs @@ -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; } -/// 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; -/// 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() } } diff --git a/src/workspace/credentials.rs b/src/workspace/credentials.rs index f81600c..499598e 100644 --- a/src/workspace/credentials.rs +++ b/src/workspace/credentials.rs @@ -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, ) { - 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, ) { 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 { 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 )) } } diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index adad115..ca7d63a 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -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; diff --git a/src/workspace/recent_sessions.rs b/src/workspace/recent_hosts.rs similarity index 84% rename from src/workspace/recent_sessions.rs rename to src/workspace/recent_hosts.rs index 1111a2d..371b14c 100644 --- a/src/workspace/recent_sessions.rs +++ b/src/workspace/recent_hosts.rs @@ -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, +pub struct RecentHosts { + store: Entity, /// 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, + selected: Option, /// 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>>, + menu_hit: Rc>>, _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, @@ -42,8 +40,8 @@ struct RecentRow { os: Option, } -impl RecentSessions { - pub fn new(store: Entity, target: FocusHandle, cx: &mut Context) -> Self { +impl RecentHosts { + pub fn new(store: Entity, target: FocusHandle, cx: &mut Context) -> Self { let subscription = cx.observe(&store, |_, _, cx| cx.notify()); Self { store, @@ -66,18 +64,18 @@ impl RecentSessions { fn rows(&self, cx: &App) -> Vec { 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, ) { 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) -> 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() diff --git a/src/workspace/sidebar.rs b/src/workspace/sidebar.rs index a6809c6..f28f296 100644 --- a/src/workspace/sidebar.rs +++ b/src/workspace/sidebar.rs @@ -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, + hosts: Entity, forwards: Entity, credentials: Entity, } impl Sidebar { pub fn new( - sessions: Entity, + hosts: Entity, forwards: Entity, credentials: Entity, ) -> 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) { 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 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) -> 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, ) -> Option> { 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) -> 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(), }; diff --git a/src/workspace/status_bar.rs b/src/workspace/status_bar.rs index aeb4197..284f87a 100644 --- a/src/workspace/status_bar.rs +++ b/src/workspace/status_bar.rs @@ -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, Option), + Host(Option, Option), Local(TerminalStatus), /// An SFTP tab: its own connection and what went wrong in it. - Explorer(Option, ExplorerStatus), + Explorer(Option, ExplorerStatus), } /// The icon for a connection state; a dropped one, like a problem, in red. @@ -50,13 +50,13 @@ impl RenderOnce for WorkspaceStatus { Option, Option, ) = 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(); diff --git a/src/workspace/title_bar.rs b/src/workspace/title_bar.rs index 0c4079c..66f3139 100644 --- a/src/workspace/title_bar.rs +++ b/src/workspace/title_bar.rs @@ -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, 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( diff --git a/src/workspace/updates.rs b/src/workspace/updates.rs index a4d5342..b4ae07d 100644 --- a/src/workspace/updates.rs +++ b/src/workspace/updates.rs @@ -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}; diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 4f40edc..aab6262 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -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, + pub(super) store: Entity, pub(super) dock_area: Entity, skin: Rc, - /// 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, - session_panel: Entity, + host_panel: Entity, /// The port forwards that are running, each on a connection of its own. pub(super) forwards: Entity, /// The start page the dock skin shows while the center has no tab. - recent: Entity, + recent: Entity, pub(super) terminals: HashMap>, - /// SFTP tabs; a session can have several, like terminals. + /// SFTP tabs; a host can have several, like terminals. pub(super) explorers: HashMap>, pub(super) local_terminals: HashMap>, /// 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, - 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, + store: Entity, settings: Entity, window: &mut Window, cx: &mut Context, @@ -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, + store: Entity, settings: Entity, remote_terminal_provider: SharedRemoteTerminalTransportProvider, local_terminal_factory: SharedTerminalTransportFactory, @@ -290,7 +289,7 @@ impl Workspace { cx: &mut Context, ) -> 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 = store + // Hosts that start out connected get a terminal tab right away. + let connected: Vec = 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 { + /// The host store, for tests and for panels created later. + pub fn store(&self) -> &Entity { &self.store } - pub fn terminal(&self, id: SessionId, cx: &App) -> Option<&Entity> { + pub fn terminal(&self, id: HostId, cx: &App) -> Option<&Entity> { 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> { 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> { self.explorers.get(&id) } - /// A session's SFTP tabs, oldest first. - pub fn explorers_of(&self, session: SessionId, cx: &App) -> Vec> { + /// A host's SFTP tabs, oldest first. + pub fn explorers_of(&self, host: HostId, cx: &App) -> Vec> { 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.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) { - 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, ) { - 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, ) { - // 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) { + fn refresh_host_connection_state(&mut self, host_id: HostId, cx: &mut Context) { 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.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, ) { - 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, - ) { - if self.store.read(cx).session(session_id).is_none() { + fn connect_host(&mut self, host_id: HostId, window: &mut Window, cx: &mut Context) { + 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, ) { - 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, ) { 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, ) { 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, ) { 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) { - open_session_dialog( + fn on_new_host(&mut self, _: &NewHost, window: &mut Window, cx: &mut Context) { + 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, - ) { - 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) { + 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, ) { @@ -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, - ) { + fn on_move_host_node(&mut self, action: &MoveHostNode, _: &mut Window, cx: &mut Context) { 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, - ) { + fn on_delete_host(&mut self, action: &DeleteHost, window: &mut Window, cx: &mut Context) { 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.close_session_tabs(id, window, cx); + fn remove_host(&mut self, id: HostId, window: &mut Window, cx: &mut Context) { + 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.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.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, ) { - 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.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) { 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, ) { @@ -1895,17 +1870,17 @@ impl Workspace { } fn new_terminal_panel( - store: &Entity, + store: &Entity, remote_provider: SharedRemoteTerminalTransportProvider, terminal_id: RemoteTerminalId, - session_id: SessionId, + host_id: HostId, window: &mut Window, cx: &mut Context, ) -> (Entity, 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, ) -> (Entity, 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)) diff --git a/tests/cli_bin.rs b/tests/cli_bin.rs index c14f242..4a8149e 100644 --- a/tests/cli_bin.rs +++ b/tests/cli_bin.rs @@ -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 = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(sessions[0].id, id); + let hosts: Vec = 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( diff --git a/tests/ui.rs b/tests/ui.rs index d9a25dd..207a984 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -15,12 +15,12 @@ use gpui_kit::{ use semver::Version; use shellrs::app::{ CenterTab, CheckForUpdates, ClearTerminal, CloseScope, CloseTabs, CollapseAllGroups, - ConnectGroup, ConnectSession, CopyCredentialPublicKey, CopySessionHost, CopySessionId, - DeleteCredential, DeleteForward, DeleteGroup, DeleteSession, DisconnectSession, - DisconnectTerminal, EditCredential, EditForward, EditSession, ExpandAllGroups, FindInTerminal, - FindNextInTerminal, FindPreviousInTerminal, FocusSearch, InstallCliCommand, NewLocalTerminal, - NewSessionInGroup, OpenExplorer, ReconnectTerminal, RemoveAgentSkill, RenameGroup, - RenameTerminal, StartForward, StopForward, ToggleSessionPanel, + ConnectGroup, ConnectHost, CopyCredentialPublicKey, CopyHostAddress, CopyHostId, + DeleteCredential, DeleteForward, DeleteGroup, DeleteHost, DisconnectHost, DisconnectTerminal, + EditCredential, EditForward, EditHost, ExpandAllGroups, FindInTerminal, FindNextInTerminal, + FindPreviousInTerminal, FocusSearch, InstallCliCommand, NewHostInGroup, NewLocalTerminal, + OpenExplorer, ReconnectTerminal, RemoveAgentSkill, RenameGroup, RenameTerminal, StartForward, + StopForward, ToggleHostPanel, }; use shellrs::cli::{AgentKind, IntegrationPaths}; use shellrs::connection::{ @@ -31,13 +31,13 @@ use shellrs::explorer::ExplorerId; use shellrs::forward::{ ForwardCommand, ForwardEvent, ForwardStatus, ForwardTransport, ForwardTransportProvider, }; -use shellrs::secrets::{InMemorySecretStore, SecretRef, SecretStore as _}; -use shellrs::session::{ +use shellrs::host::{ AuthKind, ConnectionState, CredentialDraft, CredentialId, CredentialKind, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GeneratedKey, GroupDraft, GroupId, - HostOs, JumpLogin, KeyAlgorithm, LoginMethod, LoginRoute, PastedKey, ProxyKind, ProxySettings, - Route, SessionDatabase, SessionDraft, SessionId, SessionLogin, SessionStore, read_public_key, + HostDatabase, HostDraft, HostId, HostLogin, HostOs, HostStore, JumpLogin, KeyAlgorithm, + LoginMethod, LoginRoute, PastedKey, ProxyKind, ProxySettings, Route, read_public_key, }; +use shellrs::secrets::{InMemorySecretStore, SecretRef, SecretStore as _}; use shellrs::settings::{Appearance, InterfaceLanguage, SettingsStore}; use shellrs::sftp::{ DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath, @@ -55,7 +55,7 @@ use shellrs::update::{ }; use shellrs::workspace::Workspace; -/// Seeded session ids, in insertion order (see `SessionStore::seed`). +/// Seeded host ids, in insertion order (see `HostStore::seed`). const WEB_01: u64 = 1; const WEB_02: u64 = 2; const DB_01: u64 = 3; @@ -71,26 +71,26 @@ const PRODUCTION: u64 = 1; const DEVELOPMENT: u64 = 3; fn open_workspace(cx: &mut TestAppContext) -> (WindowHandle, Entity) { - open_workspace_with_store(cx, SessionStore::seed()) + open_workspace_with_store(cx, HostStore::seed()) } /// Production loads the store from the database; the tests hand one in -/// directly so they get the fixed shape `SessionStore::seed` describes. +/// directly so they get the fixed shape `HostStore::seed` describes. fn open_workspace_with_store( cx: &mut TestAppContext, - store: SessionStore, + store: HostStore, ) -> (WindowHandle, Entity) { open_workspace_with_tester(cx, store, Arc::new(FakeConnectionTester::default())) } -/// Same, with the session dialog's connection test answered by `tester`. +/// Same, with the host dialog's connection test answered by `tester`. /// /// Motion is reduced, as in the other fixtures: dialogs would otherwise /// slide in over real time, and under a loaded test run a field or button /// can move between being found and being clicked. fn open_workspace_with_tester( cx: &mut TestAppContext, - store: SessionStore, + store: HostStore, tester: Arc, ) -> (WindowHandle, Entity) { cx.update(shellrs::init); @@ -371,7 +371,7 @@ fn open_workspace_with_factory( cx.update(|cx| cx.set_reduce_motion(true)); let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { - let store = cx.new(|_| SessionStore::seed()); + let store = cx.new(|_| HostStore::seed()); let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new( FakeTerminalFactory::default(), ))); @@ -397,7 +397,7 @@ fn open_workspace_with_factory( fn open_workspace_with_remote_factory( cx: &mut TestAppContext, - store: SessionStore, + store: HostStore, factory: Arc, ) -> (WindowHandle, Entity) { cx.update(shellrs::init); @@ -530,9 +530,9 @@ impl TerminalTransport for PromptTerminalTransport { } } -fn one_session_store(auth: AuthKind) -> (SessionStore, SessionId) { - let mut store = SessionStore::empty(); - let id = store.insert_unnotified(SessionDraft::new( +fn one_host_store(auth: AuthKind) -> (HostStore, HostId) { + let mut store = HostStore::empty(); + let id = store.insert_unnotified(HostDraft::new( "prompt-host", "example.test", 22, @@ -547,26 +547,26 @@ fn one_session_store(auth: AuthKind) -> (SessionStore, SessionId) { async fn a_host_without_a_password_says_what_it_tries_and_is_saved_as_such( cx: &mut TestAppContext, ) { - let (handle, workspace) = open_workspace_with_store(cx, SessionStore::empty()); - in_frame(cx, handle, |window, cx| window.click("new-session", cx)); + let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty()); + in_frame(cx, handle, |window, cx| window.click("new-host", cx)); in_frame(cx, handle, |window, cx| { // A new host logs in with a password. - let sources = window.within("session-auth-source"); + let sources = window.within("host-auth-source"); assert_eq!(sources.find(0usize).selected(), Some(true)); - assert!(window.find("session-password").visible()); - assert!(window.try_find("session-no-password-note").is_none()); - window.click("session-name", cx); + assert!(window.find("host-password").visible()); + assert!(window.try_find("host-no-password-note").is_none()); + window.click("host-name", cx); window.input("box", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("10.0.0.9", cx); - window.within("session-auth-source").click(2usize, cx); + window.within("host-auth-source").click(2usize, cx); }); in_frame(cx, handle, |window, cx| { - assert!(window.find("session-user").visible()); - assert!(window.try_find("session-password").is_none()); - assert!(window.try_find("session-credential").is_none()); + assert!(window.find("host-user").visible()); + assert!(window.try_find("host-password").is_none()); + assert!(window.try_find("host-credential").is_none()); assert_eq!( - window.find("session-no-password-note").label(), + window.find("host-no-password-note").label(), Some( "依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥;服务器要求密码时连接失败,不会询问。" ) @@ -576,11 +576,11 @@ async fn a_host_without_a_password_says_what_it_tries_and_is_saved_as_such( wait_for_dialog_to_close(cx, handle).await; cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - let session = &store.sessions()[0]; - assert_eq!(session.auth, AuthKind::NoPassword); - assert_eq!(session.credential, None); + let host = &store.hosts()[0]; + assert_eq!(host.auth, AuthKind::NoPassword); + assert_eq!(host.credential, None); assert_eq!( - store.login(session.id).unwrap().method, + store.login(host.id).unwrap().method, LoginMethod::NoPassword ); }); @@ -588,15 +588,15 @@ async fn a_host_without_a_password_says_what_it_tries_and_is_saved_as_such( /// A store with three hosts to jump through or to, in this order: /// 阿里云99, 禅道 and 内网库. -fn store_with_jump_hosts() -> (SessionStore, [SessionId; 3]) { - let mut store = SessionStore::empty(); +fn store_with_jump_hosts() -> (HostStore, [HostId; 3]) { + let mut store = HostStore::empty(); let ids = [ ("阿里云99", "120.25.220.186"), ("禅道", "8.138.95.125"), ("内网库", "10.0.0.5"), ] .map(|(name, host)| { - store.insert_unnotified(SessionDraft::new( + store.insert_unnotified(HostDraft::new( name, host, 22, @@ -619,32 +619,29 @@ fn add_jump_host(cx: &mut TestAppContext, handle: WindowHandle, name: &str } fn chain(window: &mut gpui_kit::Window) -> Option { - window - .find("session-route-chain") - .label() - .map(str::to_string) + window.find("host-route-chain").label().map(str::to_string) } #[gpui_kit::test] async fn a_host_goes_through_the_jump_hosts_it_lists_in_order(cx: &mut TestAppContext) { let (store, [aliyun, zentao, _]) = store_with_jump_hosts(); let (handle, workspace) = open_workspace_with_store(cx, store); - in_frame(cx, handle, |window, cx| window.click("new-session", cx)); + in_frame(cx, handle, |window, cx| window.click("new-host", cx)); in_frame(cx, handle, |window, cx| { // A new host connects directly, with nothing more to fill in. - let routes = window.within("session-route"); + let routes = window.within("host-route"); assert_eq!(routes.find(0usize).selected(), Some(true)); - assert!(window.try_find("session-route-chain").is_none()); - window.click("session-name", cx); + assert!(window.try_find("host-route-chain").is_none()); + window.click("host-name", cx); window.input("db", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("10.0.9.9", cx); - window.within("session-route").click(1usize, cx); + window.within("host-route").click(1usize, cx); }); in_frame(cx, handle, |window, cx| { assert_eq!(chain(window).as_deref(), Some("本机 → 当前主机")); assert_eq!( - window.find("session-route-note").label(), + window.find("host-route-note").label(), Some( "依次经过跳板主机连接到当前主机,可添加多台。跳板主机自己的「连接方式」在这里不生效。" ) @@ -682,13 +679,13 @@ async fn a_host_goes_through_the_jump_hosts_it_lists_in_order(cx: &mut TestAppCo Some("本机 → 禅道 → 阿里云99 → 当前主机") ); // Off the picker first: a focused picker opens on the commit. - window.click("session-name", cx); + window.click("host-name", cx); window.click("commit", cx); }); wait_for_dialog_to_close(cx, handle).await; cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - let created = store.sessions().last().unwrap(); + let created = store.hosts().last().unwrap(); assert_eq!(created.name.as_ref(), "db"); assert_eq!(created.route, Route::Jump(vec![Some(zentao), Some(aliyun)])); }); @@ -699,17 +696,17 @@ async fn a_deleted_jump_host_keeps_its_place_until_it_is_removed(cx: &mut TestAp let (mut store, [aliyun, zentao, inner]) = store_with_jump_hosts(); store.update_unnotified( inner, - SessionDraft::new("内网库", "10.0.0.5", 22, "root", AuthKind::Password, None) + HostDraft::new("内网库", "10.0.0.5", 22, "root", AuthKind::Password, None) .with_route(Route::Jump(vec![Some(aliyun)])), ); store.remove_unnotified(aliyun); let (handle, workspace) = open_workspace_with_store(cx, store); in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(EditSession(inner)), cx) + window.dispatch_action(Box::new(EditHost(inner)), cx) }); in_frame(cx, handle, |window, cx| { assert_eq!( - window.within("session-route").find(1usize).selected(), + window.within("host-route").find(1usize).selected(), Some(true) ); assert_eq!( @@ -732,14 +729,14 @@ async fn a_deleted_jump_host_keeps_its_place_until_it_is_removed(cx: &mut TestAp add_jump_host(cx, handle, "禅道"); in_frame(cx, handle, |window, cx| { assert_eq!(chain(window).as_deref(), Some("本机 → 禅道 → 当前主机")); - window.click("session-name", cx); + window.click("host-name", cx); window.click("commit", cx); }); wait_for_dialog_to_close(cx, handle).await; cx.update(|cx| { let store = workspace.read(cx).store().read(cx); assert_eq!( - store.session(inner).unwrap().route, + store.host(inner).unwrap().route, Route::Jump(vec![Some(zentao)]) ); }); @@ -750,17 +747,17 @@ async fn testing_a_connection_through_a_jump_host_sends_its_login(cx: &mut TestA let (store, _) = store_with_jump_hosts(); let tester = Arc::new(FakeConnectionTester::default()); let (handle, _) = open_workspace_with_tester(cx, store, tester.clone()); - in_frame(cx, handle, |window, cx| window.click("new-session", cx)); + in_frame(cx, handle, |window, cx| window.click("new-host", cx)); in_frame(cx, handle, |window, cx| { - window.click("session-name", cx); + window.click("host-name", cx); window.input("db", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("10.0.9.9", cx); - window.within("session-route").click(1usize, cx); + window.within("host-route").click(1usize, cx); }); add_jump_host(cx, handle, "阿里云"); in_frame(cx, handle, |window, cx| { - window.click("session-name", cx); + window.click("host-name", cx); window.click("test-connection", cx); }); cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| { @@ -778,20 +775,20 @@ async fn testing_a_connection_through_a_jump_host_sends_its_login(cx: &mut TestA assert_eq!(name, "阿里云99"); assert_eq!( **login, - SessionLogin::manual("120.25.220.186", 22, "root", AuthKind::Password) + HostLogin::manual("120.25.220.186", 22, "root", AuthKind::Password) ); } #[gpui_kit::test] async fn a_hosts_notes_take_several_lines_and_come_back_when_edited(cx: &mut TestAppContext) { - let (handle, workspace) = open_workspace_with_store(cx, SessionStore::empty()); - in_frame(cx, handle, |window, cx| window.click("new-session", cx)); + let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty()); + in_frame(cx, handle, |window, cx| window.click("new-host", cx)); in_frame(cx, handle, |window, cx| { - window.click("session-name", cx); + window.click("host-name", cx); window.input("db", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("10.0.9.9", cx); - window.click("session-notes", cx); + window.click("host-notes", cx); window.input("机房 A", cx); // Enter starts a new line; it does not submit the dialog. window.press("enter", cx); @@ -804,19 +801,19 @@ async fn a_hosts_notes_take_several_lines_and_come_back_when_edited(cx: &mut Tes wait_for_dialog_to_close(cx, handle).await; let id = cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - let session = &store.sessions()[0]; - assert_eq!(session.notes.as_ref(), "机房 A\n负责人:张三"); - session.id + let host = &store.hosts()[0]; + assert_eq!(host.notes.as_ref(), "机房 A\n负责人:张三"); + host.id }); // Edited, the notes are there to change. The closed dialog took the // focus with it, so the list takes it back first. in_frame(cx, handle, |window, cx| { - window.click(("session-row", id.0), cx); - window.dispatch_action(Box::new(EditSession(id)), cx) + window.click(("host-row", id.0), cx); + window.dispatch_action(Box::new(EditHost(id)), cx) }); in_frame(cx, handle, |window, cx| { - window.click("session-notes", cx); + window.click("host-notes", cx); window.press("cmd-a", cx); window.input("已下线", cx); window.click("commit", cx); @@ -824,7 +821,7 @@ async fn a_hosts_notes_take_several_lines_and_come_back_when_edited(cx: &mut Tes wait_for_dialog_to_close(cx, handle).await; cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - assert_eq!(store.session(id).unwrap().notes.as_ref(), "已下线"); + assert_eq!(store.host(id).unwrap().notes.as_ref(), "已下线"); }); } @@ -833,7 +830,7 @@ async fn deleting_a_jump_host_leaves_the_connection_behind_it_alone(cx: &mut Tes let (mut store, [aliyun, _, inner]) = store_with_jump_hosts(); store.update_unnotified( inner, - SessionDraft::new("内网库", "10.0.0.5", 22, "root", AuthKind::Password, None) + HostDraft::new("内网库", "10.0.0.5", 22, "root", AuthKind::Password, None) .with_route(Route::Jump(vec![Some(aliyun)])), ); let remote = Arc::new(RecordingRemoteProvider::default()); @@ -844,7 +841,7 @@ async fn deleting_a_jump_host_leaves_the_connection_behind_it_alone(cx: &mut Tes Arc::new(FakeConnectionTester::default()), ); in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(ConnectSession(inner)), cx) + window.dispatch_action(Box::new(ConnectHost(inner)), cx) }); // The terminal is given the way there, jump host and all. let logins = remote.logins(); @@ -859,14 +856,14 @@ async fn deleting_a_jump_host_leaves_the_connection_behind_it_alone(cx: &mut Tes ); in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(DeleteSession(aliyun)), cx) + window.dispatch_action(Box::new(DeleteHost(aliyun)), cx) }); in_frame(cx, handle, |window, cx| window.click("ok", cx)); cx.run_until_parked(); cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - assert!(store.session(aliyun).is_none()); - assert_eq!(store.session(inner).unwrap().route, Route::Jump(vec![None])); + assert!(store.host(aliyun).is_none()); + assert_eq!(store.host(inner).unwrap().route, Route::Jump(vec![None])); }); // Not reconnected: that would only fail now. assert_eq!(remote.logins().len(), 1); @@ -878,38 +875,35 @@ async fn a_host_behind_a_proxy_keeps_the_proxys_password_in_the_keychain(cx: &mu let tester = Arc::new(FakeConnectionTester::default()); let (handle, workspace) = open_workspace_with_tester( cx, - SessionStore::empty().with_secrets(secrets.clone()), + HostStore::empty().with_secrets(secrets.clone()), tester.clone(), ); - in_frame(cx, handle, |window, cx| window.click("new-session", cx)); + in_frame(cx, handle, |window, cx| window.click("new-host", cx)); in_frame(cx, handle, |window, cx| { - window.click("session-name", cx); + window.click("host-name", cx); window.input("abroad", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("203.0.113.7", cx); - window.within("session-route").click(2usize, cx); + window.within("host-route").click(2usize, cx); }); in_frame(cx, handle, |window, cx| { - assert_eq!(window.find("session-proxy-kind").value(), Some("HTTP 代理")); + assert_eq!(window.find("host-proxy-kind").value(), Some("HTTP 代理")); window.click("commit", cx); }); in_frame(cx, handle, |window, cx| { assert_eq!(window.find("form-error").label(), Some("请输入代理地址")); - window.within("session-proxy-kind").click("input", cx); + window.within("host-proxy-kind").click("input", cx); }); for key in ["down", "enter"] { in_frame(cx, handle, |window, cx| window.press(key, cx)); } in_frame(cx, handle, |window, cx| { - assert_eq!( - window.find("session-proxy-kind").value(), - Some("SOCKS5 代理") - ); - window.click("session-proxy-host", cx); + assert_eq!(window.find("host-proxy-kind").value(), Some("SOCKS5 代理")); + window.click("host-proxy-host", cx); window.input("127.0.0.1", cx); - window.click("session-proxy-port", cx); + window.click("host-proxy-port", cx); window.input("7890", cx); - window.click("session-proxy-password", cx); + window.click("host-proxy-password", cx); window.input("hunter2", cx); window.click("commit", cx); }); @@ -918,7 +912,7 @@ async fn a_host_behind_a_proxy_keeps_the_proxys_password_in_the_keychain(cx: &mu window.find("form-error").label(), Some("填写代理密码时请同时填写用户名") ); - window.click("session-proxy-user", cx); + window.click("host-proxy-user", cx); window.input("me", cx); window.click("test-connection", cx); }); @@ -949,21 +943,21 @@ async fn a_host_behind_a_proxy_keeps_the_proxys_password_in_the_keychain(cx: &mu ); cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - assert_eq!(store.sessions()[0].route, Route::Proxy(proxy)); + assert_eq!(store.hosts()[0].route, Route::Proxy(proxy)); }); } #[gpui_kit::test] async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Password); + let (store, id) = one_host_store(AuthKind::Password); let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::Authentication)); let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", id.0), cx); + .within("host-tree") + .double_click(("host-row", id.0), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -1011,7 +1005,7 @@ async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut Tes workspace .store() .read(cx) - .session(id) + .host(id) .unwrap() .state .is_connected() @@ -1019,16 +1013,16 @@ async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut Tes } #[gpui_kit::test] -async fn canceling_unknown_host_prompt_keeps_session_disconnected(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Password); +async fn canceling_unknown_host_prompt_keeps_host_disconnected(cx: &mut TestAppContext) { + let (store, id) = one_host_store(AuthKind::Password); let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::UnknownHost)); let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", id.0), cx); + .within("host-tree") + .double_click(("host-row", id.0), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -1070,19 +1064,19 @@ async fn canceling_unknown_host_prompt_keeps_session_disconnected(cx: &mut TestA workspace .store() .read(cx) - .session(id) + .host(id) .unwrap() .state .is_connected() })); - // The failed terminal tab remains visible, but connecting the session + // The failed terminal tab remains visible, but connecting the host // again must start a fresh transport instead of only activating that tab. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", id.0), cx); + .within("host-tree") + .double_click(("host-row", id.0), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -1105,7 +1099,7 @@ async fn canceling_unknown_host_prompt_keeps_session_disconnected(cx: &mut TestA // restart the exited transport instead of merely activating the tab. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(ConnectSession(id)), cx); + window.dispatch_action(Box::new(ConnectHost(id)), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -1122,15 +1116,15 @@ async fn canceling_unknown_host_prompt_keeps_session_disconnected(cx: &mut TestA #[gpui_kit::test] async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Password); + let (store, id) = one_host_store(AuthKind::Password); let factory = Arc::new(FakeTerminalFactory::default()); let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", id.0), cx); + .within("host-tree") + .double_click(("host-row", id.0), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -1143,8 +1137,8 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA cx.update(|cx| { let store = workspace.read(cx).store().clone(); store.update(cx, |store, cx| { - let mut draft = store.session(id).unwrap().draft(); - draft.host = "new.example.test".into(); + let mut draft = store.host(id).unwrap().draft(); + draft.address = "new.example.test".into(); assert!(store.update(id, draft, cx)); }); }); @@ -1159,7 +1153,7 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA cx.update(|cx| { let store = workspace.read(cx).store().clone(); store.update(cx, |store, cx| { - let mut draft = store.session(id).unwrap().draft(); + let mut draft = store.host(id).unwrap().draft(); draft.name = "renamed".into(); assert!(store.update(id, draft, cx)); }); @@ -1170,7 +1164,7 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA #[gpui_kit::test] async fn opening_sftp_updates_connection_state_without_terminal(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Password); + let (store, id) = one_host_store(AuthKind::Password); let (handle, workspace) = open_workspace_with_store(cx, store); cx.update_window(handle.into(), |_, window, cx| { @@ -1179,14 +1173,7 @@ async fn opening_sftp_updates_connection_state_without_terminal(cx: &mut TestApp }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { - workspace - .read(cx) - .store() - .read(cx) - .session(id) - .unwrap() - .state - == ConnectionState::Connected + workspace.read(cx).store().read(cx).host(id).unwrap().state == ConnectionState::Connected }) .await; @@ -1194,25 +1181,25 @@ async fn opening_sftp_updates_connection_state_without_terminal(cx: &mut TestApp let workspace = workspace.read(cx); assert_eq!(workspace.explorers_of(id, cx).len(), 1); assert_eq!( - workspace.store().read(cx).session(id).unwrap().state, - shellrs::session::ConnectionState::Connected + workspace.store().read(cx).host(id).unwrap().state, + shellrs::host::ConnectionState::Connected ); }); } #[gpui_kit::test] -async fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppContext) { +async fn double_click_on_host_opens_terminal_and_updates_status(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - // Sessions seeded as connected already have terminal tabs. + // Hosts seeded as connected already have terminal tabs. assert!(window.find(("terminal", INITIAL_WEB_TERMINAL)).visible()); assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none()); window - .within("session-tree") - .double_click(("session-row", DB_01), cx); + .within("host-tree") + .double_click(("host-row", DB_01), cx); }) .unwrap(); cx.run_until_parked(); @@ -1235,29 +1222,23 @@ async fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut Test cx.update(|cx| { let workspace = workspace.read(cx); - assert!(workspace.terminal(SessionId(DB_01), cx).is_some()); + assert!(workspace.terminal(HostId(DB_01), cx).is_some()); let store = workspace.store().read(cx); - assert!( - store - .session(SessionId(DB_01)) - .unwrap() - .state - .is_connected() - ); + assert!(store.host(HostId(DB_01)).unwrap().state.is_connected()); }); } #[gpui_kit::test] -async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Password); +async fn connected_host_opens_an_independent_terminal_each_time(cx: &mut TestAppContext) { + let (store, id) = one_host_store(AuthKind::Password); let factory = Arc::new(FakeTerminalFactory::default()); let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", id.0), cx); + .within("host-tree") + .double_click(("host-row", id.0), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -1269,8 +1250,8 @@ async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut Test cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", id.0), cx); + .within("host-tree") + .double_click(("host-row", id.0), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -1282,7 +1263,7 @@ async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut Test // The right-click menu's “连接” item dispatches this same action. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(ConnectSession(id)), cx); + window.dispatch_action(Box::new(ConnectHost(id)), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -1292,7 +1273,7 @@ async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut Test .read(cx) .store() .read(cx) - .session(id) + .host(id) .unwrap() .state .is_connected() @@ -1319,7 +1300,7 @@ async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut Test && workspace .store() .read(cx) - .session(id) + .host(id) .unwrap() .state .is_connected() @@ -1332,7 +1313,7 @@ async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut Test workspace .store() .read(cx) - .session(id) + .host(id) .unwrap() .state .is_connected() @@ -1341,12 +1322,12 @@ async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut Test } #[gpui_kit::test] -async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) { +async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("new-session", cx); + window.click("new-host", cx); }) .unwrap(); cx.run_until_parked(); @@ -1358,13 +1339,8 @@ async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) { // The address and its port share a row; the name above them and the // user name below each take the row's whole width. - let [name, host, port, user] = [ - "session-name", - "session-host", - "session-port", - "session-user", - ] - .map(|id| window.find(id).bounds()); + let [name, host, port, user] = ["host-name", "host-address", "host-port", "host-user"] + .map(|id| window.find(id).bounds()); assert_eq!(host.top(), port.top()); assert!(host.right() < port.left() && host.size.width > port.size.width); assert!(name.bottom() < host.top() && user.top() > host.bottom()); @@ -1374,8 +1350,8 @@ async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) { // An empty form is rejected and the dialog stays open. The commit // action is dispatched deferred, so the error shows after effects run. // The dialog's focus trap owns focus until a field is clicked. - window.click("session-name", cx); - assert_eq!(window.find("session-name").focused(), Some(true)); + window.click("host-name", cx); + assert_eq!(window.find("host-name").focused(), Some(true)); window.click("commit", cx); }) .unwrap(); @@ -1391,9 +1367,9 @@ async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) { window.render_frame(cx); assert!(window.find("commit").visible()); - window.click("session-name", cx); + window.click("host-name", cx); window.input("db-02", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("10.0.3.7", cx); window.click("commit", cx); }) @@ -1407,11 +1383,11 @@ async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) { cx.update(|cx| { let store = workspace.read(cx).store().read(cx); let created = store - .sessions() + .hosts() .iter() - .find(|session| session.name == "db-02") + .find(|host| host.name == "db-02") .expect("db-02 inserted"); - assert_eq!(created.host.as_ref(), "10.0.3.7"); + assert_eq!(created.address.as_ref(), "10.0.3.7"); assert_eq!(created.port, 22); }); } @@ -1429,17 +1405,17 @@ async fn testing_a_connection_logs_in_with_what_the_form_shows(cx: &mut TestAppC cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(EditSession(id)), cx); + window.dispatch_action(Box::new(EditHost(id)), cx); }) .unwrap(); // The saved password is read on a background thread, then fills the field. cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("session-password", cx); + window.click("host-password", cx); window.press("cmd-a", cx); window.input("wrong-password", cx); - window.click("session-port", cx); + window.click("host-port", cx); window.press("cmd-a", cx); window.input("2222", cx); window.click("test-connection", cx); @@ -1468,7 +1444,7 @@ async fn testing_a_connection_logs_in_with_what_the_form_shows(cx: &mut TestAppC .unwrap(); cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - assert_eq!(store.session(id).unwrap().port, 22); + assert_eq!(store.host(id).unwrap().port, 22); }); assert_eq!( secrets @@ -1484,11 +1460,11 @@ async fn testing_a_connection_logs_in_with_what_the_form_shows(cx: &mut TestAppC #[gpui_kit::test] async fn a_connection_test_needs_a_host_and_a_user_first(cx: &mut TestAppContext) { let tester = Arc::new(FakeConnectionTester::default()); - let (handle, _) = open_workspace_with_tester(cx, SessionStore::empty(), tester.clone()); + let (handle, _) = open_workspace_with_tester(cx, HostStore::empty(), tester.clone()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("new-session", cx); + window.click("new-host", cx); }) .unwrap(); cx.run_until_parked(); @@ -1509,9 +1485,9 @@ async fn a_connection_test_needs_a_host_and_a_user_first(cx: &mut TestAppContext // Reported at once as a failed test; the form shows no result of its own. assert_eq!(window.notifications(cx).len(), 1); assert!(window.try_find("form-error").is_none()); - window.click("session-host", cx); + window.click("host-address", cx); window.input("10.0.3.7", cx); - window.click("session-user", cx); + window.click("host-user", cx); #[cfg(target_os = "macos")] window.press("cmd-a", cx); #[cfg(not(target_os = "macos"))] @@ -1530,17 +1506,15 @@ async fn a_connection_test_needs_a_host_and_a_user_first(cx: &mut TestAppContext } #[gpui_kit::test] -async fn a_first_seen_host_key_is_put_to_the_user_above_the_session_dialog( - cx: &mut TestAppContext, -) { +async fn a_first_seen_host_key_is_put_to_the_user_above_the_host_dialog(cx: &mut TestAppContext) { let tester = Arc::new(FakeConnectionTester::asking_trust()); - let (store, id) = one_session_store(AuthKind::Password); + let (store, id) = one_host_store(AuthKind::Password); let (handle, _) = open_workspace_with_tester(cx, store, tester.clone()); // Trust: the question reaches the tester as a yes. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(EditSession(id)), cx); + window.dispatch_action(Box::new(EditHost(id)), cx); }) .unwrap(); cx.run_until_parked(); @@ -1566,7 +1540,7 @@ async fn a_first_seen_host_key_is_put_to_the_user_above_the_session_dialog( .await; assert_eq!(tester.trust_answers(), [true]); - // Escape dismisses the question, which declines it; the session dialog + // Escape dismisses the question, which declines it; the host dialog // underneath stays open. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); @@ -1605,11 +1579,11 @@ fn search_filters_the_tree(cx: &mut TestAppContext) { window.render_frame(cx); assert!( window - .within("session-tree") - .try_find(("session-row", WEB_01)) + .within("host-tree") + .try_find(("host-row", WEB_01)) .is_some() ); - window.click("session-search", cx); + window.click("host-search", cx); window.input("staging", cx); }) .unwrap(); @@ -1617,9 +1591,9 @@ fn search_filters_the_tree(cx: &mut TestAppContext) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - let tree = window.within("session-tree"); - assert!(tree.try_find(("session-row", STAGING_API)).is_some()); - assert!(tree.try_find(("session-row", WEB_01)).is_none()); + let tree = window.within("host-tree"); + assert!(tree.try_find(("host-row", STAGING_API)).is_some()); + assert!(tree.try_find(("host-row", WEB_01)).is_none()); }) .unwrap(); } @@ -1628,19 +1602,19 @@ fn search_filters_the_tree(cx: &mut TestAppContext) { fn group_expansion_survives_reopening_the_database(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("shellrs.db"); - let database = SessionDatabase::open(&path).unwrap(); - let seed = SessionStore::seed(); + let database = HostDatabase::open(&path).unwrap(); + let seed = HostStore::seed(); for group in seed.groups() { database.insert_group(group).unwrap(); } - for session in seed.sessions() { - database.insert_session(session).unwrap(); + for host in seed.hosts() { + database.insert_host(host).unwrap(); } - let (handle, _) = open_workspace_with_store(cx, SessionStore::load(database).unwrap()); + let (handle, _) = open_workspace_with_store(cx, HostStore::load(database).unwrap()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find(("session-row", WEB_01)).visible()); + assert!(window.find(("host-row", WEB_01)).visible()); window.click(("group-row", PRODUCTION), cx); }) .unwrap(); @@ -1649,10 +1623,10 @@ fn group_expansion_survives_reopening_the_database(cx: &mut TestAppContext) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert!(window.find(("group-row", PRODUCTION)).visible()); - assert!(window.try_find(("session-row", WEB_01)).is_none()); + assert!(window.try_find(("host-row", WEB_01)).is_none()); }) .unwrap(); - let database = SessionDatabase::open(&path).unwrap(); + let database = HostDatabase::open(&path).unwrap(); assert!( !database .load() @@ -1664,22 +1638,22 @@ fn group_expansion_survives_reopening_the_database(cx: &mut TestAppContext) { .expanded ); - let (reopened, _) = open_workspace_with_store(cx, SessionStore::load(database).unwrap()); + let (reopened, _) = open_workspace_with_store(cx, HostStore::load(database).unwrap()); cx.update_window(reopened.into(), |_, window, cx| { window.render_frame(cx); assert!(window.find(("group-row", PRODUCTION)).visible()); - assert!(window.try_find(("session-row", WEB_01)).is_none()); - assert!(window.find(("session-row", STAGING_API)).visible()); + assert!(window.try_find(("host-row", WEB_01)).is_none()); + assert!(window.find(("host-row", STAGING_API)).visible()); window.click(("group-row", PRODUCTION), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(reopened.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find(("session-row", WEB_01)).visible()); + assert!(window.find(("host-row", WEB_01)).visible()); }) .unwrap(); - let saved = SessionDatabase::open(&path).unwrap().load().unwrap(); + let saved = HostDatabase::open(&path).unwrap().load().unwrap(); assert!( saved .groups @@ -1694,22 +1668,22 @@ fn group_expansion_survives_reopening_the_database(cx: &mut TestAppContext) { fn expand_and_collapse_all_groups_include_nested_groups_and_persist(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("shellrs.db"); - let database = SessionDatabase::open(&path).unwrap(); - let mut seed = SessionStore::seed(); + let database = HostDatabase::open(&path).unwrap(); + let mut seed = HostStore::seed(); let nested = seed.insert_group_unnotified(GroupDraft::new("内部服务", Some(GroupId(PRODUCTION)))); for group in seed.groups() { database.insert_group(group).unwrap(); } - for session in seed.sessions() { - database.insert_session(session).unwrap(); + for host in seed.hosts() { + database.insert_host(host).unwrap(); } - let (handle, _) = open_workspace_with_store(cx, SessionStore::load(database).unwrap()); + let (handle, _) = open_workspace_with_store(cx, HostStore::load(database).unwrap()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert!(window.find(("group-row", nested.0)).visible()); - assert!(window.find(("session-row", WEB_01)).visible()); + assert!(window.find(("host-row", WEB_01)).visible()); window.dispatch_action(Box::new(CollapseAllGroups), cx); }) .unwrap(); @@ -1718,11 +1692,11 @@ fn expand_and_collapse_all_groups_include_nested_groups_and_persist(cx: &mut Tes window.render_frame(cx); assert!(window.find(("group-row", PRODUCTION)).visible()); assert!(window.try_find(("group-row", nested.0)).is_none()); - assert!(window.try_find(("session-row", WEB_01)).is_none()); + assert!(window.try_find(("host-row", WEB_01)).is_none()); }) .unwrap(); assert!( - SessionDatabase::open(&path) + HostDatabase::open(&path) .unwrap() .load() .unwrap() @@ -1739,10 +1713,10 @@ fn expand_and_collapse_all_groups_include_nested_groups_and_persist(cx: &mut Tes cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert!(window.find(("group-row", nested.0)).visible()); - assert!(window.find(("session-row", WEB_01)).visible()); + assert!(window.find(("host-row", WEB_01)).visible()); }) .unwrap(); - let database = SessionDatabase::open(&path).unwrap(); + let database = HostDatabase::open(&path).unwrap(); assert!( database .load() @@ -1751,22 +1725,22 @@ fn expand_and_collapse_all_groups_include_nested_groups_and_persist(cx: &mut Tes .iter() .all(|group| group.expanded) ); - let (reopened, _) = open_workspace_with_store(cx, SessionStore::load(database).unwrap()); + let (reopened, _) = open_workspace_with_store(cx, HostStore::load(database).unwrap()); cx.update_window(reopened.into(), |_, window, cx| { window.render_frame(cx); assert!(window.find(("group-row", nested.0)).visible()); - assert!(window.find(("session-row", WEB_01)).visible()); + assert!(window.find(("host-row", WEB_01)).visible()); }) .unwrap(); } #[gpui_kit::test] fn connect_group_opens_each_host_in_its_subtree(cx: &mut TestAppContext) { - let mut store = SessionStore::seed(); + let mut store = HostStore::seed(); let child = store.insert_group_unnotified(GroupDraft::new("内部服务", Some(GroupId(PRODUCTION)))); let grandchild = store.insert_group_unnotified(GroupDraft::new("后端", Some(child))); - let nested_host = store.insert_unnotified(SessionDraft::new( + let nested_host = store.insert_unnotified(HostDraft::new( "backend-01", "10.0.3.8", 22, @@ -1785,9 +1759,9 @@ fn connect_group_opens_each_host_in_its_subtree(cx: &mut TestAppContext) { cx.update(|cx| { let workspace = workspace.read(cx); assert_eq!(workspace.terminal_count(nested_host, cx), 1); - assert_eq!(workspace.terminal_count(SessionId(WEB_01), cx), 1); - assert_eq!(workspace.terminal_count(SessionId(DB_01), cx), 0); - assert_eq!(workspace.terminal_count(SessionId(STAGING_API), cx), 1); + assert_eq!(workspace.terminal_count(HostId(WEB_01), cx), 1); + assert_eq!(workspace.terminal_count(HostId(DB_01), cx), 0); + assert_eq!(workspace.terminal_count(HostId(STAGING_API), cx), 1); }); cx.update_window(handle.into(), |_, window, cx| { @@ -1799,16 +1773,16 @@ fn connect_group_opens_each_host_in_its_subtree(cx: &mut TestAppContext) { cx.update(|cx| { let workspace = workspace.read(cx); assert_eq!(workspace.terminal_count(nested_host, cx), 2); - assert_eq!(workspace.terminal_count(SessionId(WEB_01), cx), 2); - assert_eq!(workspace.terminal_count(SessionId(WEB_02), cx), 1); - assert_eq!(workspace.terminal_count(SessionId(DB_01), cx), 1); - assert_eq!(workspace.terminal_count(SessionId(STAGING_API), cx), 1); - assert_eq!(workspace.terminal_count(SessionId(DEV_BOX), cx), 0); + assert_eq!(workspace.terminal_count(HostId(WEB_01), cx), 2); + assert_eq!(workspace.terminal_count(HostId(WEB_02), cx), 1); + assert_eq!(workspace.terminal_count(HostId(DB_01), cx), 1); + assert_eq!(workspace.terminal_count(HostId(STAGING_API), cx), 1); + assert_eq!(workspace.terminal_count(HostId(DEV_BOX), cx), 0); }); } #[gpui_kit::test] -fn dragging_a_host_into_a_group_updates_the_session_tree(cx: &mut TestAppContext) { +fn dragging_a_host_into_a_group_updates_the_host_tree(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { @@ -1821,11 +1795,9 @@ fn dragging_a_host_into_a_group_updates_the_session_tree(cx: &mut TestAppContext window.find(("group-count", DEVELOPMENT)).label(), Some("1 台主机") ); - window.within("session-tree").drag_to( - ("session-row", DB_01), - ("group-row", DEVELOPMENT), - cx, - ); + window + .within("host-tree") + .drag_to(("host-row", DB_01), ("group-row", DEVELOPMENT), cx); }) .unwrap(); cx.run_until_parked(); @@ -1833,7 +1805,7 @@ fn dragging_a_host_into_a_group_updates_the_session_tree(cx: &mut TestAppContext cx.update(|cx| { let store = workspace.read(cx).store().read(cx); assert_eq!( - store.session(SessionId(DB_01)).unwrap().group, + store.host(HostId(DB_01)).unwrap().group, Some(GroupId(DEVELOPMENT)) ); }); @@ -1857,9 +1829,9 @@ fn dragging_peers_changes_their_order_and_groups_can_nest(cx: &mut TestAppContex cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .drag_to(("session-row", DB_01), ("session-row", WEB_01), cx); - window.within("session-tree").drag_to( + .within("host-tree") + .drag_to(("host-row", DB_01), ("host-row", WEB_01), cx); + window.within("host-tree").drag_to( ("group-row", DEVELOPMENT), ("group-row", PRODUCTION), cx, @@ -1874,9 +1846,9 @@ fn dragging_peers_changes_their_order_and_groups_can_nest(cx: &mut TestAppContex store.group(GroupId(DEVELOPMENT)).unwrap().parent, Some(GroupId(PRODUCTION)) ); - let db = store.session(SessionId(DB_01)).unwrap(); - let web = store.session(SessionId(WEB_01)).unwrap(); - let web02 = store.session(SessionId(2)).unwrap(); + let db = store.host(HostId(DB_01)).unwrap(); + let web = store.host(HostId(WEB_01)).unwrap(); + let web02 = store.host(HostId(2)).unwrap(); assert!(web.sort_order < db.sort_order && db.sort_order < web02.sort_order); }); cx.update_window(handle.into(), |_, window, cx| { @@ -1898,15 +1870,15 @@ fn dragging_a_host_to_blank_tree_space_moves_it_to_the_root(cx: &mut TestAppCont let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - let from = window.find(("session-row", DB_01)).bounds().center(); - let tree = window.find("session-tree").bounds(); + let from = window.find(("host-row", DB_01)).bounds().center(); + let tree = window.find("host-tree").bounds(); window.drag(from, point(tree.center().x, tree.bottom() - px(12.)), cx); }) .unwrap(); cx.run_until_parked(); cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - assert_eq!(store.session(SessionId(DB_01)).unwrap().group, None); + assert_eq!(store.host(HostId(DB_01)).unwrap().group, None); }); } @@ -1914,31 +1886,31 @@ fn dragging_a_host_to_blank_tree_space_moves_it_to_the_root(cx: &mut TestAppCont fn dragged_order_survives_reopening_the_database(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("shellrs.db"); - let database = SessionDatabase::open(&path).unwrap(); - let seed = SessionStore::seed(); + let database = HostDatabase::open(&path).unwrap(); + let seed = HostStore::seed(); for group in seed.groups() { database.insert_group(group).unwrap(); } - for session in seed.sessions() { - database.insert_session(session).unwrap(); + for host in seed.hosts() { + database.insert_host(host).unwrap(); } - let (handle, _) = open_workspace_with_store(cx, SessionStore::load(database).unwrap()); + let (handle, _) = open_workspace_with_store(cx, HostStore::load(database).unwrap()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .drag_to(("session-row", DB_01), ("session-row", WEB_01), cx); + .within("host-tree") + .drag_to(("host-row", DB_01), ("host-row", WEB_01), cx); }) .unwrap(); cx.run_until_parked(); - let reopened = SessionDatabase::open(&path).unwrap().load().unwrap(); + let reopened = HostDatabase::open(&path).unwrap().load().unwrap(); let order = |id| { reopened - .sessions + .hosts .iter() - .find(|session| session.id == SessionId(id)) + .find(|host| host.id == HostId(id)) .unwrap() .sort_order }; @@ -1953,8 +1925,8 @@ async fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", DB_01), cx); + .within("host-tree") + .double_click(("host-row", DB_01), cx); }) .unwrap(); cx.run_until_parked(); @@ -2040,8 +2012,8 @@ fn tab_close_button_closes_the_terminal_and_disconnects(cx: &mut TestAppContext) cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", DB_01), cx); + .within("host-tree") + .double_click(("host-row", DB_01), cx); }) .unwrap(); cx.run_until_parked(); @@ -2073,26 +2045,20 @@ fn tab_close_button_closes_the_terminal_and_disconnects(cx: &mut TestAppContext) cx.update(|cx| { let workspace = workspace.read(cx); - assert!(workspace.terminal(SessionId(DB_01), cx).is_none()); + assert!(workspace.terminal(HostId(DB_01), cx).is_none()); let store = workspace.store().read(cx); - assert!( - !store - .session(SessionId(DB_01)) - .unwrap() - .state - .is_connected() - ); + assert!(!store.host(HostId(DB_01)).unwrap().state.is_connected()); }); } #[gpui_kit::test] -async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { +async fn closing_every_tab_shows_the_recent_hosts(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); // While tabs are open the start page stays out of the way. - assert!(window.try_find("recent-sessions").is_none()); + assert!(window.try_find("recent-hosts").is_none()); window.click(("close-terminal", INITIAL_WEB_TERMINAL), cx); }) .unwrap(); @@ -2105,7 +2071,7 @@ async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { .try_find(("terminal", INITIAL_WEB_TERMINAL)) .is_none() ); - assert!(window.try_find("recent-sessions").is_none()); + assert!(window.try_find("recent-hosts").is_none()); // The last tab closes too (the tab group alone would refuse). window.click(("close-terminal", INITIAL_STAGING_TERMINAL), cx); }) @@ -2119,24 +2085,24 @@ async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { .try_find(("terminal", INITIAL_STAGING_TERMINAL)) .is_none() ); - assert!(window.find("recent-sessions").visible()); + assert!(window.find("recent-hosts").visible()); // The page takes the focus the closed tab held. - assert_eq!(window.find("recent-sessions").focused(), Some(true)); - // Both sessions had been connected, so both are listed. - assert!(window.find(("recent-session", WEB_01)).visible()); - assert!(window.find(("recent-session", STAGING_API)).visible()); + assert_eq!(window.find("recent-hosts").focused(), Some(true)); + // Both hosts had been connected, so both are listed. + assert!(window.find(("recent-host", WEB_01)).visible()); + assert!(window.find(("recent-host", STAGING_API)).visible()); assert_eq!(window.find("status-connection").label(), Some("未连接")); - window.click(("recent-session", WEB_01), cx); + window.click(("recent-host", WEB_01), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find("recent-sessions").visible()); + assert!(window.find("recent-hosts").visible()); assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none()); - window.double_click(("recent-session", WEB_01), cx); + window.double_click(("recent-host", WEB_01), cx); }) .unwrap(); cx.run_until_parked(); @@ -2150,7 +2116,7 @@ async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible()); - assert!(window.try_find("recent-sessions").is_none()); + assert!(window.try_find("recent-hosts").is_none()); assert_eq!( window.find("status-connection").label(), Some("已连接 web-01") @@ -2161,8 +2127,8 @@ async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { cx.update(|cx| { let store = workspace.read(cx).store().read(cx); let recent: Vec<_> = store - .recent_sessions() - .map(|session| session.name.to_string()) + .recent_hosts() + .map(|host| host.name.to_string()) .collect(); // Reconnecting moved web-01 to the front. assert_eq!(recent, ["web-01", "staging-api"]); @@ -2177,7 +2143,7 @@ async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find("recent-sessions").visible()); + assert!(window.find("recent-hosts").visible()); // Reopening the page must not reconnect the old selection on Enter. window.press("enter", cx); }) @@ -2185,7 +2151,7 @@ async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find("recent-sessions").visible()); + assert!(window.find("recent-hosts").visible()); assert!( window .try_find(("terminal", FIRST_NEW_TERMINAL + 1)) @@ -2196,7 +2162,7 @@ async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { } #[gpui_kit::test] -async fn enter_connects_the_selected_recent_session(cx: &mut TestAppContext) { +async fn enter_connects_the_selected_recent_host(cx: &mut TestAppContext) { let (handle, _) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); @@ -2213,13 +2179,13 @@ async fn enter_connects_the_selected_recent_session(cx: &mut TestAppContext) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click(("recent-session", STAGING_API), cx); + window.click(("recent-host", STAGING_API), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find("recent-sessions").visible()); + assert!(window.find("recent-hosts").visible()); window.press("enter", cx); }) .unwrap(); @@ -2232,12 +2198,12 @@ async fn enter_connects_the_selected_recent_session(cx: &mut TestAppContext) { .await; } -/// The start page's row menu is the session tree's menu. Menus are not +/// The start page's row menu is the host tree's menu. Menus are not /// driven here, so this dispatches what its items dispatch, from the page: /// the page is drawn deferred over the dock, and its actions must still /// reach the workspace. #[gpui_kit::test] -async fn recent_session_menu_commands_work_from_the_start_page(cx: &mut TestAppContext) { +async fn recent_host_menu_commands_work_from_the_start_page(cx: &mut TestAppContext) { let (handle, _) = open_workspace(cx); for terminal in [INITIAL_WEB_TERMINAL, INITIAL_STAGING_TERMINAL] { cx.update_window(handle.into(), |_, window, cx| { @@ -2250,14 +2216,14 @@ async fn recent_session_menu_commands_work_from_the_start_page(cx: &mut TestAppC cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert_eq!(window.find("recent-sessions").focused(), Some(true)); - window.dispatch_action(Box::new(EditSession(SessionId(STAGING_API))), cx); + assert_eq!(window.find("recent-hosts").focused(), Some(true)); + window.dispatch_action(Box::new(EditHost(HostId(STAGING_API))), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert_eq!(window.find("session-name").value(), Some("staging-api")); + assert_eq!(window.find("host-name").value(), Some("staging-api")); window.press("escape", cx); }) .unwrap(); @@ -2267,13 +2233,13 @@ async fn recent_session_menu_commands_work_from_the_start_page(cx: &mut TestAppC // page again, as a right click would. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click(("recent-session", WEB_01), cx); + window.click(("recent-host", WEB_01), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(DeleteSession(SessionId(WEB_01))), cx); + window.dispatch_action(Box::new(DeleteHost(HostId(WEB_01))), cx); }) .unwrap(); cx.run_until_parked(); @@ -2286,14 +2252,14 @@ async fn recent_session_menu_commands_work_from_the_start_page(cx: &mut TestAppC cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.try_find(("recent-session", WEB_01)).is_none()); - assert!(window.find(("recent-session", STAGING_API)).visible()); + assert!(window.try_find(("recent-host", WEB_01)).is_none()); + assert!(window.find(("recent-host", STAGING_API)).visible()); }) .unwrap(); } #[gpui_kit::test] -fn settings_open_from_the_session_list_as_one_tab(cx: &mut TestAppContext) { +fn settings_open_from_the_host_list_as_one_tab(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { @@ -2348,7 +2314,7 @@ fn settings_open_from_the_session_list_as_one_tab(cx: &mut TestAppContext) { cx.update(|cx| { let settings = workspace.read(cx).settings_tab().expect("settings open"); let group = settings.read(cx).tab_group().unwrap().upgrade().unwrap(); - // The two session terminals and a single settings tab. + // The two host terminals and a single settings tab. assert_eq!(group.read(cx).panels().len(), 3); }); @@ -2599,8 +2565,8 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext) cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", DB_01), cx); + .within("host-tree") + .double_click(("host-row", DB_01), cx); }) .unwrap(); cx.run_until_parked(); @@ -2617,7 +2583,7 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext) cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none()); - assert!(window.try_find("recent-sessions").is_none()); + assert!(window.try_find("recent-hosts").is_none()); // Keep closing whichever tab the dock displays next. window.press("cmd-w", cx); }) @@ -2643,7 +2609,7 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext) .try_find(("terminal", INITIAL_STAGING_TERMINAL)) .is_none() ); - assert!(window.find("recent-sessions").visible()); + assert!(window.find("recent-hosts").visible()); // With nothing open the shortcut does nothing. window.press("cmd-w", cx); }) @@ -2652,7 +2618,7 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext) cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find("recent-sessions").visible()); + assert!(window.find("recent-hosts").visible()); }) .unwrap(); } @@ -2903,7 +2869,7 @@ async fn terminal_selection_copies_only_on_command_and_finishes_outside_view( cx.read_from_clipboard().and_then(|item| item.text()), Some("原剪贴板".into()) ); - window.click("session-search", cx); + window.click("host-search", cx); }) .unwrap(); cx.run_until_parked(); @@ -3319,7 +3285,7 @@ async fn the_status_bar_shows_the_size_of_the_terminal_in_front(cx: &mut TestApp let terminal = RemoteTerminalId(FIRST_NEW_TERMINAL); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(ConnectSession(SessionId(WEB_01))), cx); + window.dispatch_action(Box::new(ConnectHost(HostId(WEB_01))), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { @@ -3364,7 +3330,7 @@ async fn the_status_bar_shows_the_size_of_the_terminal_in_front(cx: &mut TestApp // An SFTP tab has no terminal, so no size either. in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(OpenExplorer(SessionId(WEB_01))), cx); + window.dispatch_action(Box::new(OpenExplorer(HostId(WEB_01))), cx); }); cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { window @@ -3395,12 +3361,12 @@ async fn the_status_bar_shows_the_size_of_the_terminal_in_front(cx: &mut TestApp } #[gpui_kit::test] -async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_session(cx: &mut TestAppContext) { +async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_host(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); let second = RemoteTerminalId(FIRST_NEW_TERMINAL); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(ConnectSession(SessionId(WEB_01))), cx); + window.dispatch_action(Box::new(ConnectHost(HostId(WEB_01))), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { @@ -3424,7 +3390,7 @@ async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_session(cx: &mut Tes let workspace = workspace.read(cx); let store = workspace.store().read(cx); assert_eq!( - store.session(SessionId(WEB_01)).unwrap().state, + store.host(HostId(WEB_01)).unwrap().state, ConnectionState::Connected ); let screen = workspace @@ -3492,7 +3458,7 @@ async fn new_group_from_the_toolbar_appears_in_the_tree(cx: &mut TestAppContext) }); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - let tree = window.within("session-tree"); + let tree = window.within("host-tree"); assert!(tree.find(("group-row", created.0)).visible()); assert_eq!( tree.find(("group-count", created.0)).label(), @@ -3504,21 +3470,21 @@ async fn new_group_from_the_toolbar_appears_in_the_tree(cx: &mut TestAppContext) /// The action a group row's 新建主机… menu entry dispatches. #[gpui_kit::test] -async fn a_new_session_in_a_group_starts_out_in_that_group(cx: &mut TestAppContext) { +async fn a_new_host_in_a_group_starts_out_in_that_group(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(NewSessionInGroup(GroupId(DEVELOPMENT))), cx); + window.dispatch_action(Box::new(NewHostInGroup(GroupId(DEVELOPMENT))), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("session-name", cx); + window.click("host-name", cx); window.input("dev-02", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("192.168.1.21", cx); window.click("commit", cx); }) @@ -3532,9 +3498,9 @@ async fn a_new_session_in_a_group_starts_out_in_that_group(cx: &mut TestAppConte cx.update(|cx| { let store = workspace.read(cx).store().read(cx); let created = store - .sessions() + .hosts() .iter() - .find(|session| session.name == "dev-02") + .find(|host| host.name == "dev-02") .expect("dev-02 inserted"); // The form opened with 开发 pre-selected and nothing changed it. assert_eq!(created.group, Some(GroupId(DEVELOPMENT))); @@ -3542,7 +3508,7 @@ async fn a_new_session_in_a_group_starts_out_in_that_group(cx: &mut TestAppConte } #[gpui_kit::test] -async fn renaming_a_group_keeps_the_sessions_under_it(cx: &mut TestAppContext) { +async fn renaming_a_group_keeps_the_hosts_under_it(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { @@ -3572,10 +3538,10 @@ async fn renaming_a_group_keeps_the_sessions_under_it(cx: &mut TestAppContext) { let group = store.group(GroupId(PRODUCTION)).expect("group kept"); assert_eq!(group.name.as_ref(), "生产环境"); assert_eq!(store.group_path(GroupId(PRODUCTION)), "生产环境"); - // The three sessions still belong to it. + // The three hosts still belong to it. assert_eq!( store - .sessions() + .hosts() .iter() .filter(|s| s.group == Some(GroupId(PRODUCTION))) .count(), @@ -3585,23 +3551,23 @@ async fn renaming_a_group_keeps_the_sessions_under_it(cx: &mut TestAppContext) { } #[gpui_kit::test] -async fn deleting_a_group_removes_its_sessions_and_closes_their_tabs(cx: &mut TestAppContext) { +async fn deleting_a_group_removes_its_hosts_and_closes_their_tabs(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); // db-01 joins web-01, which starts connected, in having an open tab. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", DB_01), cx); + .within("host-tree") + .double_click(("host-row", DB_01), cx); }) .unwrap(); cx.run_until_parked(); cx.update(|cx| { let workspace = workspace.read(cx); - assert!(workspace.terminal(SessionId(WEB_01), cx).is_some()); - assert!(workspace.terminal(SessionId(DB_01), cx).is_some()); + assert!(workspace.terminal(HostId(WEB_01), cx).is_some()); + assert!(workspace.terminal(HostId(DB_01), cx).is_some()); }); cx.update_window(handle.into(), |_, window, cx| { @@ -3620,32 +3586,32 @@ async fn deleting_a_group_removes_its_sessions_and_closes_their_tabs(cx: &mut Te cx.update(|cx| { let workspace = workspace.read(cx); - assert!(workspace.terminal(SessionId(WEB_01), cx).is_none()); - assert!(workspace.terminal(SessionId(DB_01), cx).is_none()); + assert!(workspace.terminal(HostId(WEB_01), cx).is_none()); + assert!(workspace.terminal(HostId(DB_01), cx).is_none()); // staging-api is in another group and keeps its tab. - assert!(workspace.terminal(SessionId(STAGING_API), cx).is_some()); + assert!(workspace.terminal(HostId(STAGING_API), cx).is_some()); let store = workspace.store().read(cx); assert!(store.group(GroupId(PRODUCTION)).is_none()); assert_eq!(store.groups().len(), 2); let names: Vec<_> = store - .sessions() + .hosts() .iter() - .map(|session| session.name.as_ref()) + .map(|host| host.name.as_ref()) .collect(); assert_eq!(names, ["staging-api", "qa-runner", "dev-box"]); }); } #[gpui_kit::test] -async fn groups_and_sessions_are_read_back_from_the_database(cx: &mut TestAppContext) { +async fn groups_and_hosts_are_read_back_from_the_database(cx: &mut TestAppContext) { let directory = tempfile::tempdir().expect("temp dir"); let path = directory.path().join("shellrs.db"); - let store = SessionStore::load(SessionDatabase::open(&path).expect("database opened")) - .expect("store loaded"); + let store = + HostStore::load(HostDatabase::open(&path).expect("database opened")).expect("store loaded"); // A first launch starts with nothing at all. assert_eq!(store.groups().len(), 0); - assert_eq!(store.sessions().len(), 0); + assert_eq!(store.hosts().len(), 0); let (handle, workspace) = open_workspace_with_store(cx, store); cx.update_window(handle.into(), |_, window, cx| { @@ -3673,17 +3639,17 @@ async fn groups_and_sessions_are_read_back_from_the_database(cx: &mut TestAppCon window.render_frame(cx); // A closed dialog leaves nothing focused, and an action only reaches // handlers on the focused element's path. - window.click("session-search", cx); - window.dispatch_action(Box::new(NewSessionInGroup(group)), cx); + window.click("host-search", cx); + window.dispatch_action(Box::new(NewHostInGroup(group)), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("session-name", cx); + window.click("host-name", cx); window.input("web-01", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("10.0.1.12", cx); window.click("commit", cx); }) @@ -3693,14 +3659,14 @@ async fn groups_and_sessions_are_read_back_from_the_database(cx: &mut TestAppCon }) .await; - let session = cx.update(|cx| workspace.read(cx).store().read(cx).sessions()[0].id); + let host = cx.update(|cx| workspace.read(cx).store().read(cx).hosts()[0].id); - // Connecting is what puts a session on the start page's recent list. + // Connecting is what puts a host on the start page's recent list. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", session.0), cx); + .within("host-tree") + .double_click(("host-row", host.0), cx); }) .unwrap(); cx.run_until_parked(); @@ -3713,43 +3679,43 @@ async fn groups_and_sessions_are_read_back_from_the_database(cx: &mut TestAppCon // Everything above went through the real write path; read it back with a // second connection to the same file. - let reloaded = SessionStore::load(SessionDatabase::open(&path).expect("database reopened")) + let reloaded = HostStore::load(HostDatabase::open(&path).expect("database reopened")) .expect("store reloaded"); assert_eq!(reloaded.groups().len(), 1); assert_eq!(reloaded.groups()[0].name.as_ref(), "生产"); - assert_eq!(reloaded.sessions().len(), 1); - let saved = &reloaded.sessions()[0]; + assert_eq!(reloaded.hosts().len(), 1); + let saved = &reloaded.hosts()[0]; assert_eq!(saved.name.as_ref(), "web-01"); - assert_eq!(saved.host.as_ref(), "10.0.1.12"); + assert_eq!(saved.address.as_ref(), "10.0.1.12"); assert_eq!(saved.port, 22); assert_eq!(saved.group, Some(group)); // Runtime state is not persisted, but the last connection time is. assert!(!saved.state.is_connected()); assert_eq!( - reloaded.recent_sessions().map(|s| s.id).collect::>(), - [session] + reloaded.recent_hosts().map(|s| s.id).collect::>(), + [host] ); } -/// A session's terminal and SFTP tabs sit side by side in the center, and +/// A host's terminal and SFTP tabs sit side by side in the center, and /// only the active one renders. The tab going inactive used to keep the /// window focus, which took its focus handle out of the dispatch tree and /// left every 「×」 dead. #[gpui_kit::test] -fn both_tabs_of_one_session_stay_closable(cx: &mut TestAppContext) { +fn both_tabs_of_one_host_stay_closable(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", DB_01), cx); + .within("host-tree") + .double_click(("host-row", DB_01), cx); }) .unwrap(); cx.run_until_parked(); // The terminal tab is active and focused; opening SFTP puts a second tab - // for the same session beside it and activates that one. + // for the same host beside it and activates that one. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window.click(("sftp", FIRST_NEW_TERMINAL), cx); @@ -3788,21 +3754,21 @@ fn both_tabs_of_one_session_stay_closable(cx: &mut TestAppContext) { cx.update(|cx| { let workspace = workspace.read(cx); assert!(workspace.explorer(ExplorerId(SFTP_TAB)).is_none()); - assert!(workspace.terminal(SessionId(DB_01), cx).is_none()); + assert!(workspace.terminal(HostId(DB_01), cx).is_none()); }); } -/// Double-clicking a tab's title shows or hides the session sidebar; a +/// Double-clicking a tab's title shows or hides the host sidebar; a /// single click only selects the tab. #[gpui_kit::test] -async fn double_clicking_a_tab_toggles_the_session_sidebar(cx: &mut TestAppContext) { +async fn double_clicking_a_tab_toggles_the_host_sidebar(cx: &mut TestAppContext) { let (handle, _) = open_workspace(cx); cx.run_until_parked(); let sidebar_shown = |cx: &mut TestAppContext| { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .try_find("session-search") + .try_find("host-search") .is_some_and(|search| search.visible()) }) .unwrap() @@ -3827,7 +3793,7 @@ async fn double_clicking_a_tab_toggles_the_session_sidebar(cx: &mut TestAppConte } #[gpui_kit::test] -async fn a_terminal_tab_can_be_renamed_and_follow_the_session_again(cx: &mut TestAppContext) { +async fn a_terminal_tab_can_be_renamed_and_follow_the_host_again(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); let tab = ("terminal-tab", INITIAL_WEB_TERMINAL); @@ -3866,7 +3832,7 @@ async fn a_terminal_tab_can_be_renamed_and_follow_the_session_again(cx: &mut Tes cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert_eq!(window.find(tab).label(), Some("日志排查")); - // The other tab of the bar keeps its session name. + // The other tab of the bar keeps its host name. assert_eq!( window .find(("terminal-tab", INITIAL_STAGING_TERMINAL)) @@ -3876,18 +3842,18 @@ async fn a_terminal_tab_can_be_renamed_and_follow_the_session_again(cx: &mut Tes }) .unwrap(); cx.update(|cx| { - // A tab title is not a session setting. + // A tab title is not a host setting. let store = workspace.read(cx).store().read(cx); - let session = store.session(SessionId(WEB_01)).expect("session kept"); - assert_eq!(session.name.as_ref(), "web-01"); + let host = store.host(HostId(WEB_01)).expect("host kept"); + assert_eq!(host.name.as_ref(), "web-01"); }); - // Clearing the field returns the tab to the session name. + // Clearing the field returns the tab to the host name. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); // A closed dialog leaves nothing focused, and an action only reaches // handlers on the focused element's path. - window.click("session-search", cx); + window.click("host-search", cx); window.dispatch_action( Box::new(RenameTerminal(RemoteTerminalId(INITIAL_WEB_TERMINAL))), cx, @@ -3916,12 +3882,12 @@ async fn a_terminal_tab_can_be_renamed_and_follow_the_session_again(cx: &mut Tes } #[gpui_kit::test] -fn copy_session_host_puts_the_host_on_the_clipboard(cx: &mut TestAppContext) { +fn copy_host_address_puts_the_host_on_the_clipboard(cx: &mut TestAppContext) { let (handle, _) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(CopySessionHost(SessionId(STAGING_API))), cx); + window.dispatch_action(Box::new(CopyHostAddress(HostId(STAGING_API))), cx); }) .unwrap(); cx.run_until_parked(); @@ -3934,9 +3900,9 @@ fn copy_session_host_puts_the_host_on_the_clipboard(cx: &mut TestAppContext) { } #[gpui_kit::test] -fn hovering_a_session_row_shows_its_address_beside_the_row(cx: &mut TestAppContext) { +fn hovering_a_host_row_shows_its_address_beside_the_row(cx: &mut TestAppContext) { let (handle, _) = open_workspace(cx); - let row = ("session-row", WEB_01); + let row = ("host-row", WEB_01); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); @@ -3948,7 +3914,7 @@ fn hovering_a_session_row_shows_its_address_beside_the_row(cx: &mut TestAppConte cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - let tooltip = window.find("session-tooltip"); + let tooltip = window.find("host-tooltip"); assert_eq!(tooltip.label(), Some("root@10.0.1.12:22")); // Beside the row, so it never covers the rows below. let row_bounds = window.find(row).bounds(); @@ -3961,7 +3927,7 @@ fn hovering_a_session_row_shows_its_address_beside_the_row(cx: &mut TestAppConte cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.try_find("session-tooltip").is_none()); + assert!(window.try_find("host-tooltip").is_none()); }) .unwrap(); } @@ -3971,7 +3937,7 @@ fn the_row_tooltip_follows_the_pointer_down_the_list(cx: &mut TestAppContext) { let (handle, _) = open_workspace(cx); let move_to = |cx: &mut TestAppContext, id: u64| { cx.update_window(handle.into(), |_, window, cx| { - let row = window.find(("session-row", id)).bounds(); + let row = window.find(("host-row", id)).bounds(); window.dispatch_event( gpui_kit::PlatformInput::MouseMove(MouseMoveEvent { position: point(row.left() + px(40.), row.center().y), @@ -3990,7 +3956,7 @@ fn the_row_tooltip_follows_the_pointer_down_the_list(cx: &mut TestAppContext) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .try_find("session-tooltip") + .try_find("host-tooltip") .and_then(|tooltip| tooltip.label().map(str::to_string)) }) .unwrap() @@ -4009,13 +3975,13 @@ fn the_row_tooltip_follows_the_pointer_down_the_list(cx: &mut TestAppContext) { } #[gpui_kit::test] -fn copy_session_id_puts_the_public_id_on_the_clipboard(cx: &mut TestAppContext) { +fn copy_host_id_puts_the_public_id_on_the_clipboard(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); let public_id = workspace.read_with(cx, |workspace, cx| { workspace .store() .read(cx) - .session(SessionId(STAGING_API)) + .host(HostId(STAGING_API)) .unwrap() .public_id .to_string() @@ -4023,7 +3989,7 @@ fn copy_session_id_puts_the_public_id_on_the_clipboard(cx: &mut TestAppContext) cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(CopySessionId(SessionId(STAGING_API))), cx); + window.dispatch_action(Box::new(CopyHostId(HostId(STAGING_API))), cx); }) .unwrap(); cx.run_until_parked(); @@ -4058,7 +4024,7 @@ fn batch_close_commands_take_the_tabs_around_the_clicked_one(cx: &mut TestAppCon }; // [web-01, staging-api, web-01 · SFTP, local] - dispatch(cx, Box::new(OpenExplorer(SessionId(WEB_01)))); + dispatch(cx, Box::new(OpenExplorer(HostId(WEB_01)))); dispatch(cx, Box::new(NewLocalTerminal)); cx.update(|cx| { let workspace = workspace.read(cx); @@ -4082,7 +4048,7 @@ fn batch_close_commands_take_the_tabs_around_the_clicked_one(cx: &mut TestAppCon ); // [web-01, staging-api, web-01 #2] → left of staging-api. - dispatch(cx, Box::new(ConnectSession(SessionId(WEB_01)))); + dispatch(cx, Box::new(ConnectHost(HostId(WEB_01)))); dispatch( cx, close(terminal(INITIAL_STAGING_TERMINAL), CloseScope::Left), @@ -4093,7 +4059,7 @@ fn batch_close_commands_take_the_tabs_around_the_clicked_one(cx: &mut TestAppCon ); // [staging-api, web-01 #2, staging-api #2] → others than web-01 #2. - dispatch(cx, Box::new(ConnectSession(SessionId(STAGING_API)))); + dispatch(cx, Box::new(ConnectHost(HostId(STAGING_API)))); assert_eq!(open_terminals(cx).len(), 3); dispatch(cx, close(terminal(FIRST_NEW_TERMINAL), CloseScope::Others)); assert_eq!(open_terminals(cx), [FIRST_NEW_TERMINAL]); @@ -4103,15 +4069,15 @@ fn batch_close_commands_take_the_tabs_around_the_clicked_one(cx: &mut TestAppCon assert!(open_terminals(cx).is_empty()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find("recent-sessions").visible()); + assert!(window.find("recent-hosts").visible()); }) .unwrap(); } -/// A store holding one session, wired to a keychain the test can inspect. -fn store_with_secrets(secrets: Arc) -> (SessionStore, SessionId, SecretRef) { - let mut store = SessionStore::empty(); - let id = store.insert_unnotified(SessionDraft::new( +/// A store holding one host, wired to a keychain the test can inspect. +fn store_with_secrets(secrets: Arc) -> (HostStore, HostId, SecretRef) { + let mut store = HostStore::empty(); + let id = store.insert_unnotified(HostDraft::new( "db-01", "10.0.2.5", 22, @@ -4119,30 +4085,30 @@ fn store_with_secrets(secrets: Arc) -> (SessionStore, Sessi AuthKind::Password, None, )); - let endpoint = store.session(id).unwrap().password_secret(); + let endpoint = store.host(id).unwrap().password_secret(); (store.with_secrets(secrets), id, endpoint) } #[gpui_kit::test] -async fn a_new_session_saves_its_password_to_the_keychain(cx: &mut TestAppContext) { +async fn a_new_host_saves_its_password_to_the_keychain(cx: &mut TestAppContext) { let secrets = Arc::new(InMemorySecretStore::default()); let (handle, workspace) = - open_workspace_with_store(cx, SessionStore::empty().with_secrets(secrets.clone())); + open_workspace_with_store(cx, HostStore::empty().with_secrets(secrets.clone())); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("new-session", cx); + window.click("new-host", cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("session-name", cx); + window.click("host-name", cx); window.input("db-02", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("10.0.3.7", cx); - window.click("session-password", cx); + window.click("host-password", cx); window.input("hunter2", cx); window.click("commit", cx); }) @@ -4165,9 +4131,9 @@ async fn a_new_session_saves_its_password_to_the_keychain(cx: &mut TestAppContex cx.update(|cx| { let store = workspace.read(cx).store().read(cx); let created = store - .sessions() + .hosts() .iter() - .find(|session| session.name == "db-02") + .find(|host| host.name == "db-02") .expect("db-02 inserted"); assert!( !format!("{created:?}").contains("hunter2"), @@ -4185,7 +4151,7 @@ async fn editing_the_host_moves_the_saved_password_with_it(cx: &mut TestAppConte cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(EditSession(id)), cx); + window.dispatch_action(Box::new(EditHost(id)), cx); }) .unwrap(); // The saved password is read on a background thread, then fills the field. @@ -4193,8 +4159,8 @@ async fn editing_the_host_moves_the_saved_password_with_it(cx: &mut TestAppConte cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find("session-password").visible()); - window.click("session-host", cx); + assert!(window.find("host-password").visible()); + window.click("host-address", cx); window.press("cmd-a", cx); window.input("10.9.9.9", cx); window.click("commit", cx); @@ -4235,14 +4201,14 @@ async fn clearing_the_password_field_forgets_the_saved_password(cx: &mut TestApp cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(EditSession(id)), cx); + window.dispatch_action(Box::new(EditHost(id)), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("session-password", cx); + window.click("host-password", cx); window.press("cmd-a", cx); window.press("backspace", cx); window.click("commit", cx); @@ -4266,7 +4232,7 @@ async fn clearing_the_password_field_forgets_the_saved_password(cx: &mut TestApp } #[gpui_kit::test] -async fn deleting_the_last_session_on_an_endpoint_forgets_its_password(cx: &mut TestAppContext) { +async fn deleting_the_last_host_on_an_endpoint_forgets_its_password(cx: &mut TestAppContext) { let secrets = Arc::new(InMemorySecretStore::default()); let (store, id, endpoint) = store_with_secrets(secrets.clone()); secrets.set(&endpoint, "hunter2").unwrap(); @@ -4274,7 +4240,7 @@ async fn deleting_the_last_session_on_an_endpoint_forgets_its_password(cx: &mut cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(DeleteSession(id)), cx); + window.dispatch_action(Box::new(DeleteHost(id)), cx); }) .unwrap(); cx.run_until_parked(); @@ -4299,8 +4265,8 @@ async fn deleting_the_last_session_on_an_endpoint_forgets_its_password(cx: &mut #[gpui_kit::test] async fn the_tree_falls_back_to_the_first_letter_until_a_host_is_probed(cx: &mut TestAppContext) { - let mut store = SessionStore::empty(); - let probed = store.insert_unnotified(SessionDraft::new( + let mut store = HostStore::empty(); + let probed = store.insert_unnotified(HostDraft::new( "web-01", "10.0.1.12", 22, @@ -4308,7 +4274,7 @@ async fn the_tree_falls_back_to_the_first_letter_until_a_host_is_probed(cx: &mut AuthKind::Password, None, )); - let fresh = store.insert_unnotified(SessionDraft::new( + let fresh = store.insert_unnotified(HostDraft::new( "数据库", "10.0.2.5", 22, @@ -4321,12 +4287,9 @@ async fn the_tree_falls_back_to_the_first_letter_until_a_host_is_probed(cx: &mut cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); + assert_eq!(window.find(("host-os", probed.0)).label(), Some("Debian")); assert_eq!( - window.find(("session-os", probed.0)).label(), - Some("Debian") - ); - assert_eq!( - window.find(("session-os", fresh.0)).label(), + window.find(("host-os", fresh.0)).label(), Some("未探测到系统"), "没探测过就退回名称首字的中性徽章" ); @@ -4339,46 +4302,43 @@ async fn the_tree_falls_back_to_the_first_letter_until_a_host_is_probed(cx: &mut cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find(("session-os", probed.0)).visible()); - assert!(window.find(("session-os", fresh.0)).visible()); + assert!(window.find(("host-os", probed.0)).visible()); + assert!(window.find(("host-os", fresh.0)).visible()); }) .unwrap(); } #[gpui_kit::test] -async fn connecting_marks_the_session_with_the_host_operating_system(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Password); +async fn connecting_marks_the_host_with_the_host_operating_system(cx: &mut TestAppContext) { + let (store, id) = one_host_store(AuthKind::Password); let factory = Arc::new(FakeTerminalFactory::reports_os(HostOs::Fedora)); let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert_eq!( - window.find(("session-os", id.0)).label(), - Some("未探测到系统") - ); + assert_eq!(window.find(("host-os", id.0)).label(), Some("未探测到系统")); window - .within("session-tree") - .double_click(("session-row", id.0), cx); + .within("host-tree") + .double_click(("host-row", id.0), cx); }) .unwrap(); // The engine batches transport events on a 16ms timer, so the mark // changes a frame or two after the tab opens. cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); - window.find(("session-os", id.0)).label() == Some("Fedora") + window.find(("host-os", id.0)).label() == Some("Fedora") }) .await; cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - assert_eq!(store.session(id).unwrap().os, Some(HostOs::Fedora)); + assert_eq!(store.host(id).unwrap().os, Some(HostOs::Fedora)); }); } #[gpui_kit::test] async fn the_tab_bar_shows_the_connection_latency_while_it_runs(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Password); + let (store, id) = one_host_store(AuthKind::Password); let factory = Arc::new(FakeTerminalFactory::reports_latency(Latency::Measured( Duration::from_millis(32), ))); @@ -4390,8 +4350,8 @@ async fn the_tab_bar_shows_the_connection_latency_while_it_runs(cx: &mut TestApp cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window - .within("session-tree") - .double_click(("session-row", id.0), cx); + .within("host-tree") + .double_click(("host-row", id.0), cx); }) .unwrap(); // Transport events reach the engine on its 16ms batching timer. @@ -4418,8 +4378,8 @@ async fn the_tab_bar_shows_the_connection_latency_while_it_runs(cx: &mut TestApp #[gpui_kit::test] async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut TestAppContext) { - let mut store = SessionStore::empty(); - let id = store.insert_unnotified(SessionDraft::new( + let mut store = HostStore::empty(); + let id = store.insert_unnotified(HostDraft::new( "web-01", "10.0.1.12", 22, @@ -4436,9 +4396,9 @@ async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find("recent-sessions").visible()); + assert!(window.find("recent-hosts").visible()); assert_eq!( - window.find(("recent-session-os", id.0)).label(), + window.find(("recent-host-os", id.0)).label(), Some("Ubuntu"), "开始页和主机树用同一个标记" ); @@ -4461,7 +4421,7 @@ struct FakeSftpProvider { slow_home: bool, } impl SftpTransportProvider for FakeSftpProvider { - fn create(&self, _: &SessionLogin) -> Box { + fn create(&self, _: &HostLogin) -> Box { Box::new(FakeSftpTransport { requests: self.requests.clone(), downloads: self.downloads.clone(), @@ -4679,7 +4639,7 @@ fn open_workspace_with_services( cx.update(|cx| cx.set_reduce_motion(true)); let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { - let store = cx.new(|_| SessionStore::seed()); + let store = cx.new(|_| HostStore::seed()); let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new( FakeTerminalFactory::default(), ))); @@ -4705,7 +4665,7 @@ fn open_workspace_with_services( async fn open_test_explorer(cx: &mut TestAppContext, handle: WindowHandle) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(OpenExplorer(HostId(DB_01))), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -5468,7 +5428,7 @@ async fn click_remote_tool( #[gpui_kit::test] async fn sftp_toolbar_goes_up_root_home_back_and_forward(cx: &mut TestAppContext) { use shellrs::app::{ExplorerAction, ExplorerCommand}; - use shellrs::session::BookmarkSide; + use shellrs::host::BookmarkSide; let provider = Arc::new(FakeSftpProvider::default()); let (handle, workspace) = open_workspace_with_sftp(cx, provider); open_test_explorer(cx, handle).await; @@ -5524,7 +5484,7 @@ async fn sftp_toolbar_goes_up_root_home_back_and_forward(cx: &mut TestAppContext assert_eq!(remote.back_target().as_deref(), Some("/home")); }); - // Bookmarks belong to the session and the pane. + // Bookmarks belong to the host and the pane. cx.update_window(handle.into(), |_, window, cx| { window.dispatch_action( Box::new(ExplorerAction::new( @@ -5542,12 +5502,12 @@ async fn sftp_toolbar_goes_up_root_home_back_and_forward(cx: &mut TestAppContext cx.update(|cx| { let store = workspace.read(cx).store().read(cx); assert_eq!( - store.bookmarks(SessionId(DB_01), BookmarkSide::Remote), + store.bookmarks(HostId(DB_01), BookmarkSide::Remote), ["/home/tester"] ); assert!( store - .bookmarks(SessionId(DB_01), BookmarkSide::Local) + .bookmarks(HostId(DB_01), BookmarkSide::Local) .is_empty() ); }); @@ -5571,7 +5531,7 @@ async fn sftp_toolbar_goes_up_root_home_back_and_forward(cx: &mut TestAppContext .read(cx) .store() .read(cx) - .bookmarks(SessionId(DB_01), BookmarkSide::Remote) + .bookmarks(HostId(DB_01), BookmarkSide::Remote) .is_empty() ) }); @@ -5738,7 +5698,7 @@ async fn sftp_path_label_opens_ancestors_and_the_open_directory_dialog(cx: &mut #[gpui_kit::test] async fn sftp_bookmark_dialog_adds_orders_removes_and_opens(cx: &mut TestAppContext) { - use shellrs::session::BookmarkSide; + use shellrs::host::BookmarkSide; let provider = Arc::new(FakeSftpProvider::default()); let (handle, workspace) = open_workspace_with_sftp(cx, provider); open_test_explorer(cx, handle).await; @@ -5748,7 +5708,7 @@ async fn sftp_bookmark_dialog_adds_orders_removes_and_opens(cx: &mut TestAppCont .read(cx) .store() .read(cx) - .bookmarks(SessionId(DB_01), BookmarkSide::Remote) + .bookmarks(HostId(DB_01), BookmarkSide::Remote) .to_vec() }) }; @@ -5933,7 +5893,7 @@ async fn sftp_path_label_folds_the_middle_of_a_long_path(cx: &mut TestAppContext } /// An SFTP tab has a terminal tab's buttons: 打开 SFTP opens another tab of -/// the session, and 重新连接 connects again, from a dropped connection or a +/// the host, and 重新连接 connects again, from a dropped connection or a /// live one. Its connection, and why it dropped, show in red at the window's /// bottom left, so the list is never pushed around. /// A new SFTP tab splits its width half and half between the panes, with @@ -5991,9 +5951,9 @@ async fn sftp_panes_keep_their_split_across_tab_switches(cx: &mut TestAppContext let dragged = local_width(cx); assert!(dragged < opened - px(100.), "{opened:?} → {dragged:?}"); - // Another SFTP tab of the session comes to the front, then this one. + // Another SFTP tab of the host comes to the front, then this one. cx.update_window(handle.into(), |_, window, cx| { - window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(OpenExplorer(HostId(DB_01))), cx); }) .unwrap(); cx.run_until_parked(); @@ -6172,19 +6132,14 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left( ) .await; - // 打开 SFTP opens another tab of the same session. + // 打开 SFTP opens another tab of the same host. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window.click(("open-sftp", SFTP_TAB), cx); }) .unwrap(); cx.run_until_parked(); - cx.update(|cx| { - assert_eq!( - workspace.read(cx).explorers_of(SessionId(DB_01), cx).len(), - 2 - ) - }); + cx.update(|cx| assert_eq!(workspace.read(cx).explorers_of(HostId(DB_01), cx).len(), 2)); } /// Click a row in one pane, then press a key with the list focused. @@ -6765,11 +6720,11 @@ async fn the_sftp_tab_shows_the_host_mark_like_its_terminal_tabs(cx: &mut TestAp assert_eq!(window.find(mark).label(), Some("未探测到系统")); }) .unwrap(); - // A terminal of the same session finds the system; the SFTP tab follows. + // A terminal of the same host finds the system; the SFTP tab follows. cx.update(|cx| { let store = workspace.read(cx).store().clone(); store.update(cx, |store, cx| { - store.set_host_os(SessionId(DB_01), Some(HostOs::Ubuntu), cx) + store.set_host_os(HostId(DB_01), Some(HostOs::Ubuntu), cx) }); }); cx.run_until_parked(); @@ -6781,7 +6736,7 @@ async fn the_sftp_tab_shows_the_host_mark_like_its_terminal_tabs(cx: &mut TestAp } #[gpui_kit::test] -async fn the_sftp_tab_can_be_renamed_and_follow_the_session_again(cx: &mut TestAppContext) { +async fn the_sftp_tab_can_be_renamed_and_follow_the_host_again(cx: &mut TestAppContext) { use shellrs::app::RenameExplorer; let provider = Arc::new(FakeSftpProvider::default()); let (handle, workspace) = open_workspace_with_sftp(cx, provider); @@ -6789,7 +6744,7 @@ async fn the_sftp_tab_can_be_renamed_and_follow_the_session_again(cx: &mut TestA let tab = ("explorer-tab", SFTP_TAB); let default = cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - format!("{} · SFTP", store.session(SessionId(DB_01)).unwrap().name) + format!("{} · SFTP", store.host(HostId(DB_01)).unwrap().name) }); cx.update_window(handle.into(), |_, window, cx| { @@ -6822,7 +6777,7 @@ async fn the_sftp_tab_can_be_renamed_and_follow_the_session_again(cx: &mut TestA cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); // A closed dialog leaves nothing focused. - window.click("session-search", cx); + window.click("host-search", cx); window.dispatch_action(Box::new(RenameExplorer(ExplorerId(SFTP_TAB))), cx); }) .unwrap(); @@ -6901,13 +6856,13 @@ async fn sftp_clicking_empty_list_space_makes_that_pane_current_at_once(cx: &mut #[gpui_kit::test] async fn opening_sftp_again_opens_another_tab_of_its_own(cx: &mut TestAppContext) { - use shellrs::app::{DisconnectSession, ExplorerAction, ExplorerCommand}; + use shellrs::app::{DisconnectHost, ExplorerAction, ExplorerCommand}; let provider = Arc::new(FakeSftpProvider::default()); let (handle, workspace) = open_workspace_with_sftp(cx, provider); open_test_explorer(cx, handle).await; let second = ExplorerId(SFTP_TAB + 1); cx.update_window(handle.into(), |_, window, cx| { - window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(OpenExplorer(HostId(DB_01))), cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { @@ -6944,7 +6899,7 @@ async fn opening_sftp_again_opens_another_tab_of_its_own(cx: &mut TestAppContext cx.update(|cx| { workspace .read(cx) - .explorers_of(SessionId(DB_01), cx) + .explorers_of(HostId(DB_01), cx) .iter() .map(|panel| panel.read(cx).remote().read(cx).path()) .collect::>() @@ -6952,7 +6907,7 @@ async fn opening_sftp_again_opens_another_tab_of_its_own(cx: &mut TestAppContext }; assert_eq!(paths(cx), ["/home/tester", "/etc"]); - // Closing one keeps the other, and the session stays connected. + // Closing one keeps the other, and the host stays connected. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window.click(("close-explorer", second.0), cx); @@ -6963,24 +6918,24 @@ async fn opening_sftp_again_opens_another_tab_of_its_own(cx: &mut TestAppContext cx.update(|cx| { let store = workspace.read(cx).store().read(cx); assert_eq!( - store.session(SessionId(DB_01)).unwrap().state, + store.host(HostId(DB_01)).unwrap().state, ConnectionState::Connected ); }); - // Disconnecting the session disconnects every SFTP tab of it. + // Disconnecting the host disconnects every SFTP tab of it. cx.update_window(handle.into(), |_, window, cx| { - window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(OpenExplorer(HostId(DB_01))), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { - window.dispatch_action(Box::new(DisconnectSession(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(DisconnectHost(HostId(DB_01))), cx); }) .unwrap(); cx.run_until_parked(); cx.update(|cx| { - let explorers = workspace.read(cx).explorers_of(SessionId(DB_01), cx); + let explorers = workspace.read(cx).explorers_of(HostId(DB_01), cx); assert_eq!(explorers.len(), 2); assert!( explorers @@ -7000,7 +6955,7 @@ async fn sftp_connecting_shows_under_the_list_without_moving_it(cx: &mut TestApp let (handle, _) = open_workspace_with_sftp(cx, provider); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(OpenExplorer(HostId(DB_01))), cx); }) .unwrap(); cx.run_until_parked(); @@ -7051,7 +7006,7 @@ async fn sftp_list_says_what_it_waits_for_before_saying_empty(cx: &mut TestAppCo }; cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(OpenExplorer(HostId(DB_01))), cx); }) .unwrap(); cx.run_until_parked(); @@ -7284,7 +7239,7 @@ enum ForwardScript { #[derive(Clone)] struct FakeForwardRun { rule: ForwardRule, - session: SessionId, + host: HostId, /// The worker's end of the event channel: a test sends what a real /// forward would report later (a dropped connection, a failure). events: async_channel::Sender, @@ -7324,11 +7279,11 @@ impl FakeForwardProvider { } impl ForwardTransportProvider for FakeForwardProvider { - fn create(&self, rule: &ForwardRule, _: &SessionLogin) -> Box { + fn create(&self, rule: &ForwardRule, _: &HostLogin) -> Box { Box::new(FakeForwardTransport { script: self.script.clone(), rule: rule.clone(), - session: rule.session, + host: rule.host, runs: self.runs.clone(), }) } @@ -7337,7 +7292,7 @@ impl ForwardTransportProvider for FakeForwardProvider { struct FakeForwardTransport { script: ForwardScript, rule: ForwardRule, - session: SessionId, + host: HostId, runs: Arc>>, } @@ -7353,7 +7308,7 @@ impl ForwardTransport for FakeForwardTransport { .unwrap_or_else(|error| error.into_inner()) .push(FakeForwardRun { rule: self.rule.clone(), - session: self.session, + host: self.host, events: events.clone(), stopped: stopped.clone(), }); @@ -7396,7 +7351,7 @@ impl ForwardTransport for FakeForwardTransport { /// The workspace with a fake forward provider the test keeps hold of. fn open_workspace_with_forwards( cx: &mut TestAppContext, - store: SessionStore, + store: HostStore, provider: Arc, ) -> (WindowHandle, Entity) { open_sized_workspace_with_forwards(cx, store, provider, size(px(1280.), px(800.))) @@ -7404,7 +7359,7 @@ fn open_workspace_with_forwards( fn open_sized_workspace_with_forwards( cx: &mut TestAppContext, - store: SessionStore, + store: HostStore, provider: Arc, window_size: gpui_kit::Size, ) -> (WindowHandle, Entity) { @@ -7454,20 +7409,20 @@ fn in_frame( result } -/// A local forward through a seeded session: `port` on this machine to the +/// A local forward through a seeded host: `port` on this machine to the /// database behind the server. -fn local_forward(session: u64, port: u16) -> ForwardDraft { +fn local_forward(host: u64, port: u16) -> ForwardDraft { ForwardDraft::new( ForwardKind::Local, - SessionId(session), + HostId(host), ForwardEndpoint::new("127.0.0.1", port), Some(ForwardEndpoint::new("db.internal", 3306)), ) } /// The seeded store with one rule, 「数据库」, through db-01. -fn store_with_forward() -> (SessionStore, ForwardId) { - let mut store = SessionStore::seed(); +fn store_with_forward() -> (HostStore, ForwardId) { + let mut store = HostStore::seed(); let id = store .insert_forward_unnotified(local_forward(DB_01, 8080).with_name("数据库")) .expect("db-01 is seeded"); @@ -7499,10 +7454,10 @@ async fn wait_for_forward_status( } #[gpui_kit::test] -async fn the_title_bar_switches_the_sidebar_between_sessions_and_forwards(cx: &mut TestAppContext) { +async fn the_title_bar_switches_the_sidebar_between_hosts_and_forwards(cx: &mut TestAppContext) { let (handle, _) = open_workspace_with_forwards( cx, - SessionStore::seed(), + HostStore::seed(), Arc::new(FakeForwardProvider::default()), ); cx.run_until_parked(); @@ -7512,28 +7467,28 @@ async fn the_title_bar_switches_the_sidebar_between_sessions_and_forwards(cx: &m }) }; - // Sessions come first, and the switch says so. - assert!(showing(cx, "session-search")); + // Hosts come first, and the switch says so. + assert!(showing(cx, "host-search")); assert!(!showing(cx, "forward-search")); assert!(showing(cx, "new-group")); in_frame(cx, handle, |window, _| { - assert_eq!(window.find("show-sessions").checked(), Some(true)); + assert_eq!(window.find("show-hosts").checked(), Some(true)); assert_eq!(window.find("show-forwards").checked(), Some(false)); // A new host, the usual addition, comes before a new group. - let host = window.find("new-session-panel").bounds(); + let host = window.find("new-host-panel").bounds(); let group = window.find("new-group").bounds(); assert!(host.right() <= group.left(), "{host:?} {group:?}"); }); show_forwards(cx, handle).await; - assert!(!showing(cx, "session-search")); + assert!(!showing(cx, "host-search")); // The dock's title bar and toolbar follow the list. assert!(showing(cx, "new-forward")); assert!(!showing(cx, "new-group")); // What both lists share stays. assert!(showing(cx, "open-settings")); in_frame(cx, handle, |window, _| { - assert_eq!(window.find("show-sessions").checked(), Some(false)); + assert_eq!(window.find("show-hosts").checked(), Some(false)); assert_eq!(window.find("show-forwards").checked(), Some(true)); assert_eq!(window.find("forward-empty").label(), Some("还没有端口转发")); }); @@ -7549,22 +7504,22 @@ async fn the_title_bar_switches_the_sidebar_between_sessions_and_forwards(cx: &m // Hidden, the sidebar marks neither; picking a list brings it back. in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(ToggleSessionPanel), cx); + window.dispatch_action(Box::new(ToggleHostPanel), cx); }); assert!(!showing(cx, "forward-search")); in_frame(cx, handle, |window, _| { assert_eq!(window.find("show-forwards").checked(), Some(false)); }); - in_frame(cx, handle, |window, cx| window.click("show-sessions", cx)); - assert!(showing(cx, "session-search")); + in_frame(cx, handle, |window, cx| window.click("show-hosts", cx)); + assert!(showing(cx, "host-search")); assert!(!showing(cx, "forward-search")); - // And the session list still takes its own commands. + // And the host list still takes its own commands. in_frame(cx, handle, |window, cx| { window.activate_window(); window.dispatch_action(Box::new(FocusSearch), cx); }); in_frame(cx, handle, |window, _| { - assert_eq!(window.find("session-search").focused(), Some(true)); + assert_eq!(window.find("host-search").focused(), Some(true)); }); } @@ -7572,16 +7527,16 @@ async fn the_title_bar_switches_the_sidebar_between_sessions_and_forwards(cx: &m async fn a_forward_is_created_edited_and_deleted_through_its_dialog(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace_with_forwards( cx, - SessionStore::seed(), + HostStore::seed(), Arc::new(FakeForwardProvider::default()), ); cx.run_until_parked(); show_forwards(cx, handle).await; // The second host of the list, which the test picks below. It is not // the one whose tab is in front: a new rule does not take that one. - let session = cx.update(|cx| { + let host = cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - let second = store.sessions()[1].clone(); + let second = store.hosts()[1].clone(); assert_ne!(store.active().map(|active| active.id), Some(second.id)); second }); @@ -7603,8 +7558,8 @@ async fn a_forward_is_created_edited_and_deleted_through_its_dialog(cx: &mut Tes Some("请选择端口转发经由的主机") ); // The field shows its prompt, not a host. - assert_eq!(window.find("forward-session").value(), Some("请选择主机")); - window.within("forward-session").click("input", cx); + assert_eq!(window.find("forward-host").value(), Some("请选择主机")); + window.within("forward-host").click("input", cx); }); // The second entry of the list that opened. for key in ["down", "down", "enter"] { @@ -7612,8 +7567,8 @@ async fn a_forward_is_created_edited_and_deleted_through_its_dialog(cx: &mut Tes } in_frame(cx, handle, |window, cx| { assert_eq!( - window.find("forward-session").value(), - Some(format!("{}({})", session.name, session.address()).as_str()) + window.find("forward-host").value(), + Some(format!("{}({})", host.name, host.endpoint()).as_str()) ); window.click("forward-bind-port", cx); window.input("8080", cx); @@ -7645,7 +7600,7 @@ async fn a_forward_is_created_edited_and_deleted_through_its_dialog(cx: &mut Tes assert_eq!(store.forwards().len(), 1); let rule = &store.forwards()[0]; assert_eq!(rule.kind, ForwardKind::Local); - assert_eq!(rule.session, session.id); + assert_eq!(rule.host, host.id); assert_eq!(rule.bind, ForwardEndpoint::new("127.0.0.1", 8080)); assert_eq!(rule.target, Some(ForwardEndpoint::new("db.internal", 3306))); assert!(!rule.auto_start); @@ -7792,7 +7747,7 @@ async fn a_forward_starts_and_stops_from_its_row_and_from_the_keyboard(cx: &mut let runs = provider.runs(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].rule.id, first); - assert_eq!(runs[0].session, SessionId(DB_01)); + assert_eq!(runs[0].host, HostId(DB_01)); in_frame(cx, handle, |window, _| { // The title bar says a forward is running, whatever list is up. assert_eq!( @@ -7808,22 +7763,16 @@ async fn a_forward_starts_and_stops_from_its_row_and_from_the_keyboard(cx: &mut .unwrap(); wait_for_forward_status(cx, handle, first, "运行中,3 个连接").await; - // A forward is on its own: 断开连接 on its session leaves it running, - // and the session does not read as connected because of it. + // A forward is on its own: 断开连接 on its host leaves it running, + // and the host does not read as connected because of it. in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(DisconnectSession(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(DisconnectHost(HostId(DB_01))), cx); }); cx.update(|cx| { let workspace = workspace.read(cx); assert!(workspace.forwards().read(cx).is_active(first)); let store = workspace.store().read(cx); - assert!( - !store - .session(SessionId(DB_01)) - .unwrap() - .state - .is_connected() - ); + assert!(!store.host(HostId(DB_01)).unwrap().state.is_connected()); }); assert_eq!(provider.stopped(), 0); @@ -7945,7 +7894,7 @@ async fn a_forward_that_fails_says_so_in_its_row_and_in_a_notification(cx: &mut ))); let (handle, workspace) = open_workspace_with_forwards(cx, store, provider.clone()); cx.run_until_parked(); - // From the session list: the forward list is not even showing. + // From the host list: the forward list is not even showing. in_frame(cx, handle, |window, cx| { window.dispatch_action(Box::new(StartForward(id)), cx); }); @@ -8034,7 +7983,7 @@ async fn a_running_forward_reports_reconnecting_and_giving_up(cx: &mut TestAppCo } #[gpui_kit::test] -async fn deleting_a_session_or_its_group_takes_the_forwards_through_it(cx: &mut TestAppContext) { +async fn deleting_a_host_or_its_group_takes_the_forwards_through_it(cx: &mut TestAppContext) { let (mut store, database) = store_with_forward(); let replica = store .insert_forward_unnotified(local_forward(DB_01, 8081)) @@ -8058,9 +8007,9 @@ async fn deleting_a_session_or_its_group_takes_the_forwards_through_it(cx: &mut }); wait_for_forward_status(cx, handle, web, "运行中").await; - // The session goes, and both of its rules with it; the running one stops. + // The host goes, and both of its rules with it; the running one stops. in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(DeleteSession(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(DeleteHost(HostId(DB_01))), cx); }); in_frame(cx, handle, |window, cx| window.click("ok", cx)); cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { @@ -8082,7 +8031,7 @@ async fn deleting_a_session_or_its_group_takes_the_forwards_through_it(cx: &mut assert!(workspace.forwards().read(cx).is_active(web)); }); - // A group takes the rules of every session inside it. + // A group takes the rules of every host inside it. in_frame(cx, handle, |window, cx| { window.click(("forward-row", web.0), cx); window.dispatch_action(Box::new(DeleteGroup(GroupId(PRODUCTION))), cx); @@ -8140,9 +8089,9 @@ async fn a_running_forward_restarts_when_what_it_does_changes(cx: &mut TestAppCo // So does the server moving. cx.update(|cx| { store.update(cx, |store, cx| { - let mut draft = store.session(SessionId(DB_01)).unwrap().draft(); - draft.host = "10.0.2.99".into(); - store.update(SessionId(DB_01), draft, cx) + let mut draft = store.host(HostId(DB_01)).unwrap().draft(); + draft.address = "10.0.2.99".into(); + store.update(HostId(DB_01), draft, cx) }) }); cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| { @@ -8193,9 +8142,9 @@ async fn forwards_marked_to_start_with_the_application_do(cx: &mut TestAppContex async fn forwards_are_read_back_from_the_database(cx: &mut TestAppContext) { let directory = tempfile::tempdir().expect("temp dir"); let path = directory.path().join("shellrs.db"); - let store = SessionStore::load(SessionDatabase::open(&path).expect("database opened")) - .expect("store loaded"); - let session = SessionDraft::new( + let store = + HostStore::load(HostDatabase::open(&path).expect("database opened")).expect("store loaded"); + let host = HostDraft::new( "db-01", "10.0.2.5", 22, @@ -8207,14 +8156,14 @@ async fn forwards_are_read_back_from_the_database(cx: &mut TestAppContext) { open_workspace_with_forwards(cx, store, Arc::new(FakeForwardProvider::default())); cx.run_until_parked(); let store = cx.update(|cx| workspace.read(cx).store().clone()); - let (session, rule) = cx.update(|cx| { + let (host, rule) = cx.update(|cx| { store.update(cx, |store, cx| { - let session = store.insert(session, cx); + let host = store.insert(host, cx); let rule = store .insert_forward( ForwardDraft::new( ForwardKind::Remote, - session, + host, ForwardEndpoint::new("0.0.0.0", 9000), Some(ForwardEndpoint::new("localhost", 3000)), ) @@ -8222,8 +8171,8 @@ async fn forwards_are_read_back_from_the_database(cx: &mut TestAppContext) { .with_auto_start(true), cx, ) - .expect("the session exists"); - (session, rule) + .expect("the host exists"); + (host, rule) }) }); cx.run_until_parked(); @@ -8234,28 +8183,28 @@ async fn forwards_are_read_back_from_the_database(cx: &mut TestAppContext) { ); }); - let reloaded = SessionStore::load(SessionDatabase::open(&path).expect("database reopened")) + let reloaded = HostStore::load(HostDatabase::open(&path).expect("database reopened")) .expect("store reloaded"); assert_eq!(reloaded.forwards().len(), 1); let saved = &reloaded.forwards()[0]; assert_eq!(saved.id, rule); - assert_eq!(saved.session, session); + assert_eq!(saved.host, host); assert_eq!(saved.kind, ForwardKind::Remote); assert_eq!(saved.name.as_ref(), "演示站"); assert_eq!(saved.bind, ForwardEndpoint::new("0.0.0.0", 9000)); assert_eq!(saved.target, Some(ForwardEndpoint::new("localhost", 3000))); assert!(saved.auto_start); - // Deleting the session on disk takes the rule with it. - cx.update(|cx| store.update(cx, |store, cx| store.remove(session, cx))); - let reloaded = SessionStore::load(SessionDatabase::open(&path).expect("database reopened")) + // Deleting the host on disk takes the rule with it. + cx.update(|cx| store.update(cx, |store, cx| store.remove(host, cx))); + let reloaded = HostStore::load(HostDatabase::open(&path).expect("database reopened")) .expect("store reloaded"); assert!(reloaded.forwards().is_empty()); } #[gpui_kit::test] async fn the_forward_dialog_and_a_long_row_fit_the_smallest_window(cx: &mut TestAppContext) { - let mut store = SessionStore::seed(); + let mut store = HostStore::seed(); let id = store .insert_forward_unnotified( local_forward(DB_01, 8080) @@ -8311,13 +8260,13 @@ async fn the_forward_dialog_and_a_long_row_fit_the_smallest_window(cx: &mut Test async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) { let (handle, _) = open_workspace_with_forwards( cx, - SessionStore::seed(), + HostStore::seed(), Arc::new(FakeForwardProvider::default()), ); cx.run_until_parked(); for (open, field) in [ - ("new-session", "session-name"), + ("new-host", "host-name"), ("new-group", "group-name"), ("new-forward", "forward-name"), ("new-credential", "credential-name"), @@ -8396,7 +8345,7 @@ async fn the_three_kinds_of_forward_share_one_row(cx: &mut TestAppContext) { assert!(cards[2].right() <= picture.right() + (picture.left() - cards[0].left())); // Below the picture, the name and the host each take a row. let name = window.find("forward-name").bounds(); - let host = window.find("forward-session").bounds(); + let host = window.find("forward-host").bounds(); assert!(host.top() > name.bottom(), "{host:?} beside {name:?}"); assert_eq!( (host.left(), host.size.width), @@ -8433,7 +8382,7 @@ async fn the_forward_diagram_keeps_showing_its_flow(cx: &mut TestAppContext) { }; in_frame(cx, handle, |window, cx| { - window.click("session-search", cx); + window.click("host-search", cx); window.dispatch_action(Box::new(EditForward(id)), cx); }); let entrance = dot(cx).expect("the flow sets out when the dialog opens"); @@ -8465,7 +8414,7 @@ async fn the_forward_diagram_keeps_showing_its_flow(cx: &mut TestAppContext) { } #[gpui_kit::test] -async fn disconnecting_a_session_leaves_a_forward_question_open(cx: &mut TestAppContext) { +async fn disconnecting_a_host_leaves_a_forward_question_open(cx: &mut TestAppContext) { let (store, id) = store_with_forward(); let provider = Arc::new(FakeForwardProvider::with_script(ForwardScript::AsksTrust)); let (handle, workspace) = open_workspace_with_forwards(cx, store, provider.clone()); @@ -8477,10 +8426,10 @@ async fn disconnecting_a_session_leaves_a_forward_question_open(cx: &mut TestApp window.try_find("cancel").is_some() }) .await; - // 断开连接 is about the session's tabs; the forward goes on asking. + // 断开连接 is about the host's tabs; the forward goes on asking. in_frame(cx, handle, |window, cx| { // The dialog holds the focus, inside the workspace. - window.dispatch_action(Box::new(DisconnectSession(SessionId(DB_01))), cx); + window.dispatch_action(Box::new(DisconnectHost(HostId(DB_01))), cx); }); in_frame(cx, handle, |window, cx| { assert!(window.try_find("cancel").is_some()); @@ -8503,7 +8452,7 @@ async fn disconnecting_a_session_leaves_a_forward_question_open(cx: &mut TestApp fn a_problem_found_while_the_window_is_built_is_shown_once_it_is_open(cx: &mut TestAppContext) { cx.update(shellrs::init); let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { - let store = cx.new(|_| SessionStore::empty()); + let store = cx.new(|_| HostStore::empty()); let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new( FakeTerminalFactory::default(), ))); @@ -8533,7 +8482,7 @@ fn a_problem_found_while_the_window_is_built_is_shown_once_it_is_open(cx: &mut T cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert_eq!(window.notifications(cx).len(), 1); - assert!(window.find("session-search").visible()); + assert!(window.find("host-search").visible()); }) .unwrap(); } @@ -8547,11 +8496,11 @@ fn a_problem_found_while_the_window_is_built_is_shown_once_it_is_open(cx: &mut T #[derive(Default)] struct RecordingRemoteProvider { factory: Arc, - logins: Mutex>, + logins: Mutex>, } impl RecordingRemoteProvider { - fn logins(&self) -> Vec { + fn logins(&self) -> Vec { self.logins .lock() .unwrap_or_else(|error| error.into_inner()) @@ -8560,7 +8509,7 @@ impl RecordingRemoteProvider { } impl RemoteTerminalTransportProvider for RecordingRemoteProvider { - fn factory_for(&self, login: &SessionLogin) -> SharedTerminalTransportFactory { + fn factory_for(&self, login: &HostLogin) -> SharedTerminalTransportFactory { self.logins .lock() .unwrap_or_else(|error| error.into_inner()) @@ -8574,7 +8523,7 @@ impl RemoteTerminalTransportProvider for RecordingRemoteProvider { /// fields stay where they were found. fn open_workspace_with_credentials( cx: &mut TestAppContext, - store: SessionStore, + store: HostStore, remote: Arc, tester: Arc, ) -> (WindowHandle, Entity) { @@ -8626,10 +8575,8 @@ async fn wait_for_dialog_to_close(cx: &mut TestAppContext, handle: WindowHandle< /// A store with one password credential, 「运维」 as `deploy`, and the host /// db-01 logging in with it; the keychain holds the credential's password. -fn store_with_credential( - secrets: Arc, -) -> (SessionStore, CredentialId, SessionId) { - let mut store = SessionStore::empty(); +fn store_with_credential(secrets: Arc) -> (HostStore, CredentialId, HostId) { + let mut store = HostStore::empty(); let credential = store.insert_credential_unnotified(CredentialDraft::new( "运维", CredentialKind::Password, @@ -8641,32 +8588,32 @@ fn store_with_credential( "hunter2", ) .unwrap(); - let session = store.insert_unnotified( - SessionDraft::new("db-01", "10.0.2.5", 22, "root", AuthKind::Password, None) + let host = store.insert_unnotified( + HostDraft::new("db-01", "10.0.2.5", 22, "root", AuthKind::Password, None) .with_credential(credential), ); - (store.with_secrets(secrets), credential, session) + (store.with_secrets(secrets), credential, host) } #[gpui_kit::test] async fn the_title_bar_switches_the_sidebar_to_credentials(cx: &mut TestAppContext) { let (handle, _) = open_workspace_with_credentials( cx, - SessionStore::seed(), + HostStore::seed(), Arc::new(RecordingRemoteProvider::default()), Arc::new(FakeConnectionTester::default()), ); show_credentials(cx, handle).await; in_frame(cx, handle, |window, _| { assert_eq!(window.find("show-credentials").checked(), Some(true)); - assert_eq!(window.find("show-sessions").checked(), Some(false)); + assert_eq!(window.find("show-hosts").checked(), Some(false)); assert_eq!(window.find("show-forwards").checked(), Some(false)); assert_eq!(window.find("credential-empty").label(), Some("还没有凭据")); // The dock's toolbar follows the list; the settings footer stays. assert!(window.find("new-credential").visible()); assert!(window.try_find("new-group").is_none()); assert!(window.find("open-settings").visible()); - assert!(window.try_find("session-search").is_none()); + assert!(window.try_find("host-search").is_none()); }); // The search shortcut goes to the list that is up. @@ -8678,9 +8625,9 @@ async fn the_title_bar_switches_the_sidebar_to_credentials(cx: &mut TestAppConte assert_eq!(window.find("credential-search").focused(), Some(true)); }); - in_frame(cx, handle, |window, cx| window.click("show-sessions", cx)); + in_frame(cx, handle, |window, cx| window.click("show-hosts", cx)); in_frame(cx, handle, |window, _| { - assert!(window.find("session-search").visible()); + assert!(window.find("host-search").visible()); assert!(window.try_find("credential-search").is_none()); assert_eq!(window.find("show-credentials").checked(), Some(false)); }); @@ -8691,7 +8638,7 @@ async fn a_new_password_credential_keeps_its_password_in_the_keychain(cx: &mut T let secrets = Arc::new(InMemorySecretStore::default()); let (handle, workspace) = open_workspace_with_credentials( cx, - SessionStore::empty().with_secrets(secrets.clone()), + HostStore::empty().with_secrets(secrets.clone()), Arc::new(RecordingRemoteProvider::default()), Arc::new(FakeConnectionTester::default()), ); @@ -8746,7 +8693,7 @@ async fn a_new_password_credential_keeps_its_password_in_the_keychain(cx: &mut T async fn the_credential_kind_decides_which_fields_show(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace_with_credentials( cx, - SessionStore::empty(), + HostStore::empty(), Arc::new(RecordingRemoteProvider::default()), Arc::new(FakeConnectionTester::default()), ); @@ -8805,7 +8752,7 @@ async fn the_credential_kind_decides_which_fields_show(cx: &mut TestAppContext) #[gpui_kit::test] async fn the_credential_list_moves_with_the_arrow_keys_and_edits_on_enter(cx: &mut TestAppContext) { - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let first = store.insert_credential_unnotified(CredentialDraft::new( "运维", CredentialKind::Password, @@ -8855,42 +8802,39 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC Arc::new(RecordingRemoteProvider::default()), Arc::new(FakeConnectionTester::default()), ); - in_frame(cx, handle, |window, cx| window.click("new-session", cx)); + in_frame(cx, handle, |window, cx| window.click("new-host", cx)); in_frame(cx, handle, |window, cx| { - window.click("session-name", cx); + window.click("host-name", cx); window.input("web-01", cx); - window.click("session-host", cx); + window.click("host-address", cx); window.input("10.0.1.12", cx); - window.within("session-auth-source").click(1usize, cx); + window.within("host-auth-source").click(1usize, cx); }); in_frame(cx, handle, |window, cx| { // The credential brings the user and the secret: neither is asked. - assert!(window.try_find("session-user").is_none()); - assert!(window.try_find("session-password").is_none()); - assert_eq!( - window.find("session-credential").value(), - Some("请选择凭据") - ); + assert!(window.try_find("host-user").is_none()); + assert!(window.try_find("host-password").is_none()); + assert_eq!(window.find("host-credential").value(), Some("请选择凭据")); window.click("commit", cx); }); in_frame(cx, handle, |window, cx| { assert_eq!(window.find("form-error").label(), Some("请选择凭据")); - window.within("session-credential").click("input", cx); + window.within("host-credential").click("input", cx); }); for key in ["down", "enter"] { in_frame(cx, handle, |window, cx| window.press(key, cx)); } in_frame(cx, handle, |window, cx| { assert_eq!( - window.find("session-credential").value(), + window.find("host-credential").value(), Some("运维(deploy · 密码)") ); assert_eq!( - window.find("session-credential-summary").label(), + window.find("host-credential-summary").label(), Some("以 deploy 登录,使用凭据保存的密码") ); // Off the select first: a focused select opens on the commit. - window.click("session-name", cx); + window.click("host-name", cx); window.click("commit", cx); }); wait_for_dialog_to_close(cx, handle).await; @@ -8898,9 +8842,9 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC cx.update(|cx| { let store = workspace.read(cx).store().read(cx); let created = store - .sessions() + .hosts() .iter() - .find(|session| session.name == "web-01") + .find(|host| host.name == "web-01") .expect("web-01 inserted"); assert_eq!(created.credential, Some(credential)); assert_eq!(created.user.as_ref(), "deploy"); @@ -8913,7 +8857,7 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC #[gpui_kit::test] async fn testing_a_connection_with_a_credential_uses_the_saved_login(cx: &mut TestAppContext) { let secrets = Arc::new(InMemorySecretStore::default()); - let (store, _, session) = store_with_credential(secrets); + let (store, _, host) = store_with_credential(secrets); let tester = Arc::new(FakeConnectionTester::default()); let (handle, _) = open_workspace_with_credentials( cx, @@ -8922,15 +8866,15 @@ async fn testing_a_connection_with_a_credential_uses_the_saved_login(cx: &mut Te tester.clone(), ); in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(EditSession(session)), cx) + window.dispatch_action(Box::new(EditHost(host)), cx) }); in_frame(cx, handle, |window, cx| { // A host using a credential opens that way. assert_eq!( - window.find("session-credential").value(), + window.find("host-credential").value(), Some("运维(deploy · 密码)") ); - assert!(window.try_find("session-user").is_none()); + assert!(window.try_find("host-user").is_none()); window.click("test-connection", cx); }); cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| { @@ -8949,7 +8893,7 @@ async fn testing_a_connection_with_a_credential_uses_the_saved_login(cx: &mut Te #[gpui_kit::test] async fn a_terminal_logs_in_with_its_credentials_login(cx: &mut TestAppContext) { let secrets = Arc::new(InMemorySecretStore::default()); - let (store, credential, session) = store_with_credential(secrets); + let (store, credential, host) = store_with_credential(secrets); let secret = store.credential(credential).unwrap().password_secret(); let remote = Arc::new(RecordingRemoteProvider::default()); let (handle, _) = open_workspace_with_credentials( @@ -8959,7 +8903,7 @@ async fn a_terminal_logs_in_with_its_credentials_login(cx: &mut TestAppContext) Arc::new(FakeConnectionTester::default()), ); in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(ConnectSession(session)), cx) + window.dispatch_action(Box::new(ConnectHost(host)), cx) }); let logins = remote.logins(); assert_eq!(logins.len(), 1); @@ -8971,8 +8915,8 @@ async fn a_terminal_logs_in_with_its_credentials_login(cx: &mut TestAppContext) #[gpui_kit::test] async fn editing_a_credentials_user_reconnects_the_hosts_using_it(cx: &mut TestAppContext) { let secrets = Arc::new(InMemorySecretStore::default()); - let (mut store, credential, session) = store_with_credential(secrets); - let other = store.insert_unnotified(SessionDraft::new( + let (mut store, credential, host) = store_with_credential(secrets); + let other = store.insert_unnotified(HostDraft::new( "web-01", "10.0.1.12", 22, @@ -8987,9 +8931,9 @@ async fn editing_a_credentials_user_reconnects_the_hosts_using_it(cx: &mut TestA remote.clone(), Arc::new(FakeConnectionTester::default()), ); - for id in [session, other] { + for id in [host, other] { in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(ConnectSession(id)), cx) + window.dispatch_action(Box::new(ConnectHost(id)), cx) }); } cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { @@ -8997,8 +8941,8 @@ async fn editing_a_credentials_user_reconnects_the_hosts_using_it(cx: &mut TestA .read(cx) .store() .read(cx) - .session(session) - .is_some_and(|session| session.state.is_connected()) + .host(host) + .is_some_and(|host| host.state.is_connected()) }) .await; assert_eq!(remote.logins().len(), 2); @@ -9041,11 +8985,8 @@ async fn editing_a_credentials_user_reconnects_the_hosts_using_it(cx: &mut TestA assert_eq!(logins[2].user, "admin"); cx.update(|cx| { let store = workspace.read(cx).store().read(cx); - assert_eq!( - store.session(session).unwrap().address(), - "admin@10.0.2.5:22" - ); - assert_eq!(store.session(other).unwrap().user.as_ref(), "root"); + assert_eq!(store.host(host).unwrap().endpoint(), "admin@10.0.2.5:22"); + assert_eq!(store.host(other).unwrap().user.as_ref(), "root"); }); } @@ -9054,7 +8995,7 @@ async fn deleting_a_used_credential_leaves_its_hosts_connected_and_logging_in_on cx: &mut TestAppContext, ) { let secrets = Arc::new(InMemorySecretStore::default()); - let (store, credential, session) = store_with_credential(secrets.clone()); + let (store, credential, host) = store_with_credential(secrets.clone()); let secret = store.credential(credential).unwrap().password_secret(); let remote = Arc::new(RecordingRemoteProvider::default()); let (handle, workspace) = open_workspace_with_credentials( @@ -9064,7 +9005,7 @@ async fn deleting_a_used_credential_leaves_its_hosts_connected_and_logging_in_on Arc::new(FakeConnectionTester::default()), ); in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(ConnectSession(session)), cx) + window.dispatch_action(Box::new(ConnectHost(host)), cx) }); show_credentials(cx, handle).await; in_frame(cx, handle, |window, cx| { @@ -9081,7 +9022,7 @@ async fn deleting_a_used_credential_leaves_its_hosts_connected_and_logging_in_on cx.update(|cx| { let store = workspace.read(cx).store().read(cx); assert!(store.credentials().is_empty()); - let host = store.session(session).unwrap(); + let host = store.host(host).unwrap(); assert_eq!(host.credential, None); assert_eq!(host.auth, AuthKind::Password); assert_eq!(host.user.as_ref(), "deploy"); @@ -9094,7 +9035,7 @@ async fn deleting_a_used_credential_leaves_its_hosts_connected_and_logging_in_on #[gpui_kit::test] async fn a_long_credential_row_fits_the_smallest_window(cx: &mut TestAppContext) { - let mut store = SessionStore::seed(); + let mut store = HostStore::seed(); let id = store.insert_credential_unnotified( CredentialDraft::new( "生产环境所有数据库服务器共用的只读巡检账号(不要用于写操作)", @@ -9120,8 +9061,8 @@ async fn a_long_credential_row_fits_the_smallest_window(cx: &mut TestAppContext) /// A store that keeps pasted and generated keys under `data`, as the app /// keeps them beside its database. -fn store_keeping_keys(data: &std::path::Path, secrets: Arc) -> SessionStore { - SessionStore::empty() +fn store_keeping_keys(data: &std::path::Path, secrets: Arc) -> HostStore { + HostStore::empty() .with_secrets(secrets) .with_key_dir(data.join("keys")) } @@ -9332,7 +9273,7 @@ async fn a_key_credentials_public_key_is_copied_from_its_file(cx: &mut TestAppCo let key = GeneratedKey::generate(KeyAlgorithm::Ed25519).unwrap(); let file = data.path().join("id_deploy"); std::fs::write(&file, key.encode("deploy@laptop", "").unwrap().as_str()).unwrap(); - let mut store = SessionStore::empty(); + let mut store = HostStore::empty(); let credential = store.insert_credential_unnotified( CredentialDraft::new("部署", CredentialKind::Key, "deploy") .with_key_path(file.display().to_string()), @@ -9362,14 +9303,14 @@ async fn a_key_credentials_public_key_is_copied_from_its_file(cx: &mut TestAppCo async fn a_dialogs_choices_are_equal_segments_of_one_track(cx: &mut TestAppContext) { let (handle, _) = open_workspace_with_credentials( cx, - SessionStore::seed(), + HostStore::seed(), Arc::new(RecordingRemoteProvider::default()), Arc::new(FakeConnectionTester::default()), ); - in_frame(cx, handle, |window, cx| window.click("new-session", cx)); + in_frame(cx, handle, |window, cx| window.click("new-host", cx)); in_frame(cx, handle, |window, cx| { - let name = window.find("session-name").bounds(); - let mut group = window.within("session-auth-source"); + let name = window.find("host-name").bounds(); + let mut group = window.within("host-auth-source"); let segments = [0usize, 1, 2].map(|ix| group.find(ix)); let selected: Vec<_> = segments.iter().map(|segment| segment.selected()).collect(); assert_eq!(selected, [Some(true), Some(false), Some(false)]); @@ -9388,10 +9329,10 @@ async fn a_dialogs_choices_are_equal_segments_of_one_track(cx: &mut TestAppConte group.click(1usize, cx); }); in_frame(cx, handle, |window, _| { - let group = window.within("session-auth-source"); + let group = window.within("host-auth-source"); assert_eq!(group.find(1usize).selected(), Some(true)); assert_eq!(group.find(0usize).selected(), Some(false)); - assert!(window.find("session-credential").visible()); + assert!(window.find("host-credential").visible()); }); } @@ -9400,7 +9341,7 @@ async fn a_segment_that_cannot_be_chosen_stays_unchosen(cx: &mut TestAppContext) // No key directory: pasted and generated keys have nowhere to go. let (handle, _) = open_workspace_with_credentials( cx, - SessionStore::empty(), + HostStore::empty(), Arc::new(RecordingRemoteProvider::default()), Arc::new(FakeConnectionTester::default()), );