表结构发布前重构,「会话」在代码和数据库里统一改名为 Host
- 数据库升到第 13 版:主机的登录、连接方式、代理和备注合进 hosts 一行,只有一对多的跳板、书签和端口转发单独成表;全部表改为 STRICT,并用 CHECK 写明约束;用凭据的主机不再冗余存用户名,凭据外键不再级联,在用的凭据删不掉 - 表名 sessions → hosts、session_jumps → host_jumps,session_id → host_id,地址列 host → address;schema 比对去掉排版,以后加字段可以直接 ADD COLUMN - 删除开发期的历史迁移代码:版本 1–12 的库直接拒绝打开、不动文件 - 代码里 Session / SessionId / SessionStore 等改为 Host*,模块 session/ → host/,Host.host → Host.address,菜单命令与界面元素 id 同步改名 - 外部 CLI 的参数、JSON 字段和错误码改为 host(host_not_found),协议版本升到 2 - CLAUDE.md、AGENTS.md、README 与 Agent skill 同步更新
This commit is contained in:
1 parent
f0690fe6b2
commit
ef83812f45
79 files changed
+3955
-4606
No files matched your search
@@ -4,11 +4,11 @@
|
||||
|
||||
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
|
||||
|
||||
**界面上叫「主机」,代码里叫 `Session`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」;它以前叫「会话」,代码标识符(`Session`、`SessionStore`、`session/` …)没有跟着改,本文件里说的「会话」指的就是它。新写的界面文案一律用「主机」,不要再出现「会话」;填 IP / 域名的那个字段叫「地址」,不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。协议术语「SSH 会话通道」不算。
|
||||
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
|
||||
|
||||
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。
|
||||
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。
|
||||
|
||||
已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。
|
||||
已确认的产品决定:WinSCP 式双栏文件浏览器是每个主机独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。
|
||||
|
||||
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
|
||||
|
||||
@@ -20,7 +20,7 @@ cargo build # 首次构建会编译整个 GPUI
|
||||
cargo test # 单元测试(模块内)+ tests/ui.rs(无头窗口 UI 集成测试)
|
||||
cargo test --lib # 只跑单元测试
|
||||
cargo test --test ui # 只跑 UI 集成测试
|
||||
cargo test --test ui new_session -- --nocapture # 按名称片段跑单个 UI 测试
|
||||
cargo test --test ui new_host -- --nocapture # 按名称片段跑单个 UI 测试
|
||||
cargo clippy --all-targets -- -D warnings # 必须无警告
|
||||
cargo fmt --check
|
||||
|
||||
@@ -37,37 +37,37 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
|
||||
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui.rs` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
|
||||
|
||||
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 的 `session_action!` / `group_action!` 动作);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染。
|
||||
- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option<GroupId>`,`Session.os` 是 `Option<HostOs>`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
|
||||
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。
|
||||
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 的 `host_action!` / `group_action!` 动作);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染。
|
||||
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`)、`database.rs`(`HostDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
|
||||
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。
|
||||
- `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。
|
||||
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
|
||||
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
|
||||
- `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
|
||||
- `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
|
||||
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
||||
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
||||
- `update/` — 在线升级:`feed.rs` 从 `dl.shellrs.com` 取已签名的清单和安装包(reqwest,走系统代理),`verify.rs` 先验 minisign 签名(trusted comment 绑定通道和版本)再信清单、按大小和 SHA-256 信安装包,`install*.rs` 按安装方式装(macOS 整包交换、Windows 运行 Inno 安装程序、Linux 覆盖 AppImage),`updater.rs` 的 `Updater` 实体管检查、下载和重启,工作线程的事件由 UI 定时轮询。不依赖 `workspace` 和 `settings`;`new_with_services` 里的 `Updater` 没有服务、从不联网,生产路径才 `set_services` + `start`,UI 测试经 `workspace.updater()` 注入假的。详见 `CLAUDE.md` 和 `README.md` 的「下载与更新」「发布」。
|
||||
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。
|
||||
|
||||
关键流程与不变量:
|
||||
|
||||
- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止上传,断开 / 删除会话会停止上传并保留续传数据。每会话一个运行批次,来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
|
||||
- **SFTP 独立于终端。** 主机状态综合所有终端与 SFTP 状态计算;关闭终端不停止上传,断开 / 删除主机会停止上传并保留续传数据。每主机一个运行批次,来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
|
||||
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。
|
||||
|
||||
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
|
||||
- **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。
|
||||
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,会话树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。
|
||||
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用 `password_in_use`(按查好凭据之后的登录快照 `SessionLogin` 判断)看还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。
|
||||
- **id 由内存分配。** `next_session_id` / `next_group_id` 两个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
|
||||
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
|
||||
- **持久化写穿,内存说了算。** `HostStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `HostStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删主机的 id 返回给工作区去关标签页。
|
||||
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,主机保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `HostStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_host` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,主机树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加主机名称第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。
|
||||
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用 `password_in_use`(按查好凭据之后的登录快照 `HostLogin` 判断)看还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`。
|
||||
- **id 由内存分配。** `next_host_id` / `next_group_id` 两个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1。`HostStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
|
||||
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<HostStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
|
||||
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
|
||||
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`SessionStore::recent_sessions()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把会话移到最前。
|
||||
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。
|
||||
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
|
||||
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。
|
||||
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`HostStore::recent_hosts()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把主机移到最前。
|
||||
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦主机面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。
|
||||
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`HostPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `HostPanel` 比对 `known_groups` / `seen_hosts` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
|
||||
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_hosts_are_read_back_from_the_database`)。
|
||||
- **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。
|
||||
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如已连接会话的 `Rc<HashSet<SessionId>>`)。
|
||||
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如已连接主机的 `Rc<HashSet<HostId>>`)。
|
||||
- `px(..)` 只允许出现在 API 要求 `Pixels` 的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和 `cx.theme()` token。
|
||||
|
||||
## 测试约定
|
||||
|
||||
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
|
||||
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`host-name` / `host-address` / `host-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
|
||||
@@ -6,11 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
|
||||
|
||||
**界面上叫「主机」,代码里叫 `Session`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」;它以前叫「会话」,代码标识符(`Session`、`SessionStore`、`session/` …)没有跟着改,本文件里说的「会话」指的就是它。新写的界面文案一律用「主机」,不要再出现「会话」;填 IP / 域名的那个字段叫「地址」,不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。协议术语「SSH 会话通道」不算。
|
||||
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
|
||||
|
||||
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和会话存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),会话可以「使用凭据」而不是自己填登录信息。会话还有「连接方式」:直接连接、经过别的会话做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。详见 `README.md`。
|
||||
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。详见 `README.md`。
|
||||
|
||||
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:会话「使用凭据」时不再填用户名,改凭据的用户名所有用它的会话一起变;会话对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);会话自己不再有私钥文件,要用私钥就建密钥凭据(版本 10 的迁移把原来手动选私钥的会话按「用户 + 私钥文件」各建一条密钥凭据,原来的「自动」归为「密码」);密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的会话一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些会话保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;会话对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。会话对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的会话,可多级,**只走这里列出的链**:跳板会话自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的会话用作跳板的会话:确认框写明台数,那些会话的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直Line truncated
|
||||
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
|
||||
|
||||
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
|
||||
|
||||
@@ -22,7 +22,7 @@ cargo build # 首次构建会编译整个 GPUI
|
||||
cargo test # 单元测试(模块内)+ tests/ui.rs(无头窗口 UI 集成测试)
|
||||
cargo test --lib # 只跑单元测试
|
||||
cargo test --test ui # 只跑 UI 集成测试
|
||||
cargo test --test ui new_session -- --nocapture # 按名称片段跑单个 UI 测试
|
||||
cargo test --test ui new_host -- --nocapture # 按名称片段跑单个 UI 测试
|
||||
cargo test --test cli_bin # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑)
|
||||
cargo test --lib update:: # 在线升级;装了 minisign 和 jq 时还验证发布脚本签出的清单客户端认
|
||||
cargo clippy --all-targets -- -D warnings # 必须无警告
|
||||
@@ -41,24 +41,24 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
|
||||
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui.rs` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
|
||||
|
||||
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
|
||||
- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs` / `PublicId`;`Session.group` 与 `SessionGroup.parent` 都是 `Option<GroupId>`,`Session.os` 是 `Option<HostOs>`,`Session.credential` 是 `Option<CredentialId>`,`Session.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<SessionId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Session.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 session 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`SessionLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:会话对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 session 里是因为规则和会话同库、随会话级联删除,而 session 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`SessionDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
|
||||
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),终端、SSH、SFTP 和工作区都直接用这些类型。
|
||||
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `SessionLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `SessionLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `Handle::send_ping()`(`keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`。
|
||||
- **会话对话框的「测试连接」是真登录。** 会话模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是会话自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板会话的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
|
||||
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
|
||||
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
|
||||
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
|
||||
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),终端、SSH、SFTP 和工作区都直接用这些类型。
|
||||
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `Handle::send_ping()`(`keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`。
|
||||
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
|
||||
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
|
||||
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
|
||||
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
|
||||
- `forward/` — 端口转发。规则的模型在 `session/forward.rs`,这里是运行和界面。
|
||||
- `forward/` — 端口转发。规则的模型在 `host/forward.rs`,这里是运行和界面。
|
||||
- `transport.rs`:可注入的接口 `ForwardTransport` / `ForwardTransportProvider`,命令 `ForwardCommand`(`PromptReply`、`Stop`)和事件 `ForwardEvent`(`Connecting`、`Prompt`、`Listening`、`Connections(n)`、`ConnectionFailed`、`Reconnecting`,以及两个终结事件 `Failed` / `Stopped`)。
|
||||
- `worker.rs`:真实实现 `SshForwardTransportProvider`。每次启动一条规则就是一个线程(`shellrs-forward`)加一个 current-thread runtime,结构照 `sftp/worker.rs`;本地转发是 `channel_open_direct_tcpip` 加 `copy_bidirectional`,远程转发是 `tcpip_forward` 加处理器交出来的通道,动态转发先过 SOCKS 握手。`describe_*` 把错误映射成中文。
|
||||
- `socks.rs`:SOCKS5(无认证、`CONNECT`)与 SOCKS4 / 4a 的纯函数解析(`Parsed::{Incomplete, Done, Invalid}`)加一个喂字节的 `negotiate`。没有引入 SOCKS 依赖。
|
||||
- `manager.rs`:`ForwardManager` 实体,持有每条在跑的规则的工作线程和状态 `ForwardStatus`,每 50 ms 轮询事件(不从后台线程唤醒 GPUI);自己订阅 store:规则被删(含随会话级联)就停,`ForwardSettingsChanged` / `ConnectionSettingsChanged` 就重启在跑的。
|
||||
- `manager.rs`:`ForwardManager` 实体,持有每条在跑的规则的工作线程和状态 `ForwardStatus`,每 50 ms 轮询事件(不从后台线程唤醒 GPUI);自己订阅 store:规则被删(含随主机级联)就停,`ForwardSettingsChanged` / `ConnectionSettingsChanged` 就重启在跑的。
|
||||
- `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。
|
||||
- `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。
|
||||
- `credential/` — 凭据的界面,模型在 `session/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `session::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `SessionStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。
|
||||
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-sessions` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是会话树里会话的那个菜单,两处都调用 `session::session_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
||||
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
|
||||
- `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。
|
||||
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
||||
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
|
||||
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
|
||||
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
|
||||
- `protocol.rs`:套接字上的帧:1 字节类型(`J` JSON / `1` stdout / `2` stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;`ErrorCode` 是给 Agent 判断用的稳定英文码。`Request::Activate` 不属于 `shellrs` 命令,是 ShellRS 被再次打开时发的(见下面「一个数据目录只跑一个 ShellRS」)。
|
||||
@@ -79,52 +79,52 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
关键流程与不变量:
|
||||
|
||||
- **传输队列在标签这一层,引擎一次只跑一批。** `ExplorerPanel` 持有 `TransferQueue`:确认框派发的 `ExplorerCommand::Enqueue(TransferJob)` 只是入队(请求在入队时就校验,失败直接报错不入队),`start_next_if_idle` 在引擎空闲(`engine_busy` 为假,它由发出 Upload / Download / Resume / Discard 置真、由引擎的 `Idle` 清掉)且已连接时把第一个排队中的批次交出去。引擎处理完每个命令都会发 `Idle`(完成、停止、丢弃、准备失败都一样),标签收到后才推进,所以前一批的事件不会串到下一批上。进度事件只归队首(运行中或已停止的那一批),没有队首时丢弃(例如丢弃续传进度后引擎回的空 `Completed`)。**已停止的批次挡在队首**:引擎还留着它等「继续」,开始新批次会把它换掉,所以要等用户继续、丢弃续传进度或「移出队列」(续传记录留在磁盘上)之后队列才往下走。确认框和询问框分开记录(`question_shown`):批次停止或结束时只关询问框,不碰用户正在填的下一批的确认框。`TransferProgress` 为队列补了当前文件(`current_source` / `current_bytes` / `current_total`)、只在传输阶段累计的用时 `elapsed`、最近 3 秒的速度(`sftp/speed.rs` 的 `TransferClock`,等待回答和重连时归零)、跳过和失败文件的字节 `settled_bytes`(`fraction()` 因此能到 100%,`remaining()` 按剩余字节除以速度)和重连说明 `note`(`current` 始终是路径)。队列面板和双栏在同一个 `v_resizable` 里,队列为空时那一块 `visible(false)`,双栏在元素树里的位置不变,分栏比例不丢。
|
||||
- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除会话会停止传输并保留续传数据。每个 SFTP 标签的传输引擎一次跑一个批次,其余在标签的传输队列里等,上传和下载共用(`TransferBatch`,类型统一叫 `Transfer*`,方向在 `TransferProgress::direction`),来源、端点和目标在确认时固定。SFTP 标签按 `ExplorerId` 登记(工作区的 `explorers`,`explorers_of(session)` 列出一个会话的全部 SFTP 标签),`ExplorerAction`、`CloseExplorer`、`RenameExplorer`、`CenterTab::Explorer` 和 SFTP 认证问答的 `PromptOwner::Sftp` 都按标签 id 寻址;按会话做的事(连接状态汇总、断开、删除会话 / 分组、统计要停止的传输)要遍历 `explorers_of`。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
|
||||
- **SFTP 独立于终端。** 主机状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除主机会停止传输并保留续传数据。每个 SFTP 标签的传输引擎一次跑一个批次,其余在标签的传输队列里等,上传和下载共用(`TransferBatch`,类型统一叫 `Transfer*`,方向在 `TransferProgress::direction`),来源、端点和目标在确认时固定。SFTP 标签按 `ExplorerId` 登记(工作区的 `explorers`,`explorers_of(host)` 列出一个主机的全部 SFTP 标签),`ExplorerAction`、`CloseExplorer`、`RenameExplorer`、`CenterTab::Explorer` 和 SFTP 认证问答的 `PromptOwner::Sftp` 都按标签 id 寻址;按主机做的事(连接状态汇总、断开、删除主机 / 分组、统计要停止的传输)要遍历 `explorers_of`。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
|
||||
- **下载严格按顺序写盘。** 读请求并发且可能乱序、短读返回,`download_file` 用重排缓冲区按偏移顺序写入 `.filepart`,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。
|
||||
- **文件操作从不跟随符号链接。** 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走 `trash` crate 的 NSFileManager 方式(不弹自动化权限、不放声音)。文件操作与传输批次可以并行;同一侧面板有操作在跑时(`FilePane::is_busy`)第二行工具栏和对应命令都不接受新操作,结果回来后面板重新读目录。
|
||||
- **选择归面板,不归表格。** `DataTable` 只支持单行选择,所以表格设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`(`..` 永远不进选择集),通过 `ListingContext` 快照交给委托;同 WinSCP 关掉「整行选择」,**名称单元格才是项目**:`render_td` 给出外层 `name-cell:<名称>`(`size_full`,画悬停灰底、选中底色和光标框,接单击、双击打开和右键,`FileListing::name_events`)和里面只有内容宽的 `name:<名称>`(图标加文件名,只接文件拖动)。所以在名称单元格里任何地方单击都选中这一行,但只有拖图标和文件名才是拖文件,从名称右边的空白拖是框选。行上不画任何状态:`DataTable` 在 `render_tr` 的样式之后给整行加 hover 底色、我们改不掉,所以行的第一个子元素是一块铺满的 `tokens.table` 底色(表格自己的不透明背景)把它盖住,单元格画在它上面;右键时表格给整行画的边框也在容器的右键处理里立刻清掉(`press_empty` 每次都 `clear_menu_row`)。为了连单元格的内边距一起铺满,名称列用 `name_column` 把表格的单元格内边距设成 0,名称单元格和它的列标题各自 `px_1p5()`(等于 `.small()` 表格的 6 px,文字因此和标题对齐);`Size::table_cell_padding` 不是公开 API,拿不到表格自己的值。单击、双击打开、右键都挂在名称上(`FileListing::name_events`),行上只剩拖放目标和记几何的 canvas。选中的名称一律只有底色,光标框只画在未选中的光标项上(`..`、刚用 ⌘ 取消选择的项),否则多选的最后一项会多出一圈边框。名称单元格的 `on_click` 读 `ClickEvent` 的修饰键和点击次数,直接更新面板(这是事件时刻,不是渲染时刻)。名称单元格和列标题以外都是空白处:按下落在哪里记在 `ListGeometry::pressed`(`Pressed::Empty` / `Name` / `Title`,容器在捕获阶段复位成 `Empty`,名称和标题在冒泡时改写),容器的 `on_click` 和右键按下在 `Empty` 且没按 ⌘ / Shift 时清空选择(`FilePane::press_empty`,连光标一起清)。表格底部的横向滚动条会在按下时停止冒泡,点在它上面不算空白处。键盘上的方向键 / Home / End / 翻页绑在 `"LocalFileList > DataTable"` 这一层,盖过表格自带的单行选择键;←、→ 绑 `NoAction`,因为表格在行模式下也会把它们切成列选择。
|
||||
- **文件列表快捷键按焦点路由。** 一个动作 `ExplorerShortcut(ExplorerCommand)`,侧别写进绑定本身;工作区找 focus handle 含焦点的那个 SFTP 面板(`contains_focus`),不用最近激活的标签,否则两个 SFTP 分栏并排时会发错。同一命令绑多个键时,最后注册的那个会显示在 tooltip 里(`tooltip_with_action`),所以 WinSCP 的键放最后。菜单项派发 `ExplorerAction`,按动作相等查不到绑定,菜单里不显示快捷键。
|
||||
- **框选同 WinSCP 关掉「整行选择」。** 拖文件去另一侧的 `on_drag` 只挂在名称单元格(图标加文件名,`FileListing::drag_from`)上,不挂在行上;名称上不带 ⌘ / Shift 的左键按下会在按下时就把未选中的项单独选中(`FilePane::select_pressed`,右键按下也用它),所以拖走的就是高亮的:未选中的项单独走,已选中的项带着整个选择走(拖动的载荷在 render 时按选择算好,按下前后算出的都是这一项,不会不一致)。拖动中只有指针在接收它的另一侧列表上(且可以传输)时才显示「上传 / 下载 N 个项目」并用 `DragCopy` 光标,其余地方(包括自己的列表)不显示预览、光标是 `OperationNotAllowed`(WinSCP 同):载荷带一个 `DropSpot`,接收侧容器的 `on_drag_move` 在指针落在自己范围内时记下指针位置,预览在 render 时比较它和 `window.mouse_position()`。存位置而不是布尔值,是因为 `on_drag_move` 会通知所有监听这个类型的元素(并排的另一个 SFTP 标签也会收到),「不在我这儿就清掉」会被后调用的覆盖。光标由预览里一个 canvas 在 paint 时 `set_window_cursor_style`:拖动层最后绘制,所以它的光标请求最后一个入栈、生效;测试平台不暴露光标,这点只能肉眼确认,测试只查预览 `file-drag-preview`(`aria_label` 是显示的文字)。列表容器 `("file-list", side)` 自己挂一个 `MarqueeDrag`,它带着开启它的面板的 `EntityId`:GPUI 的 `on_drag_move` 会把同一类型的拖动通知给**所有**挂了监听的元素,不管是谁开启的,所以监听里要核对 `event.drag(cx).pane == cx.entity_id()`,否则另一侧面板(以及别的 SFTP 标签)会跟着画框、改选择。所以从名称以外按下拖动就是框选(GPUI 的拖动由最上层先达到阈值的元素开启并停止冒泡,名称的拖动因此优先;滚动条在按下时就停止冒泡,不会触发框选)。容器在按下时记下位置,从列标题(`Pressed::Title`)开始的拖动不画框,其他地方都画(名称的图标和文件名有自己的文件拖动,GPUI 由它先开启,所以轮不到框选)。行高固定,每行用绝对定位的 `canvas`(**必须** `top_0().left_0()`)在 prepaint 记下「第一行顶端的位置和行高」(`ListGeometry::rows`),选框的起点按「距第一行顶端」保存,所以列表滚动时起点跟着内容走;框住哪些行由纯函数 `selection::swept_rows` / `row_at` 算,`Selection::sweep` 合并(开始时按住 ⌘ 或 Shift 则加到原有选择上)。拖到边缘用 `gpui_kit::base::AutoScroll` 滚动表格的 `vertical_scroll_handle`。拖动中 GPUI 每画一帧都会清掉各元素挂起的按下(`div.rs` 里 `has_active_drag` 时清 `pending_mouse_down`),所以松开时起点那个名称单元格不会再收到单击;拖动开始和松开落在同一帧里(快速一甩)时这条不成立,名称单元格的单击因此还用 `ends_a_drag`(按下和松开相距超过 GPUI 的 2 px 拖动阈值)排除掉;松开(`on_mouse_up` / `on_mouse_up_out`)时清掉选框并停止滚动。选框是容器里的绝对定位 `selection-rectangle`,夹在列表范围内。
|
||||
- **列表右键菜单挂在列表容器 `("file-list", side)` 上**,和会话树一样:名称的右键处理器先把没选中的项选中、把 `MenuHit::Item(名称)` 写进 `menu_hit`(右键空白处则清空选择,给空白处菜单),列标题(委托的 `render_th`)写 `MenuHit::Column(列键)`,容器在捕获阶段清空它;容器的菜单构造器按命中给出文件菜单、「大小」标题的「文件大小显示为」(其他标题给空菜单,不弹出)或空白处菜单。**不要在标题或行上另挂 `context_menu`**:菜单的右键监听不阻止事件冒泡,外层容器的菜单会一起弹出。大小格式 `explorer::FileSizeFormat` 存在 `AppSettings::file_size_format`(`settings.json`),由 `settings::apply` 设成全局量(explorer 不依赖 settings,同终端字体),菜单项派发 `SetFileSizeFormat`,工作区写设置;委托的 `context_menu` 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。
|
||||
- **列表右键菜单挂在列表容器 `("file-list", side)` 上**,和主机树一样:名称的右键处理器先把没选中的项选中、把 `MenuHit::Item(名称)` 写进 `menu_hit`(右键空白处则清空选择,给空白处菜单),列标题(委托的 `render_th`)写 `MenuHit::Column(列键)`,容器在捕获阶段清空它;容器的菜单构造器按命中给出文件菜单、「大小」标题的「文件大小显示为」(其他标题给空菜单,不弹出)或空白处菜单。**不要在标题或行上另挂 `context_menu`**:菜单的右键监听不阻止事件冒泡,外层容器的菜单会一起弹出。大小格式 `explorer::FileSizeFormat` 存在 `AppSettings::file_size_format`(`settings.json`),由 `settings::apply` 设成全局量(explorer 不依赖 settings,同终端字体),菜单项派发 `SetFileSizeFormat`,工作区写设置;委托的 `context_menu` 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。
|
||||
- **`Select` 必须放进有尺寸的容器。** 它的外层是 `size_full()`,直接放进工具栏行会盖住整行,别的按钮全点不到。
|
||||
- **SFTP 面板的工具栏永远一行高,放不下的按钮整个藏起来。** 折行会让这一侧的列表比另一侧低一截,所以工具栏行是 `flex_wrap` 加固定一行高(`h_6`,和 small 按钮、下拉框一样高)加 `overflow_hidden`:放不下的按钮折到被裁掉的第二行,整个隐藏(不会只露半个),从最右边的开始;命令在菜单和快捷键里都还有。边框和内边距在外层 `frame` 上,行高才不含它们。`sftp_controls_fit_small_window_in_light_dark_and_zoom` 查小窗口大字号下每个按钮要么完整可见要么不可见、两侧列表顶端对齐。新标签的两侧对半分(两个 `resizable_panel` 都 `flex_basis(relative(0.5))`,只管第一帧,之后分栏状态记下实际宽度),默认窗口(1280 px)开着会话栏时每侧约 500 px,第一行(目录下拉框 `rems(10.)`)约 404 px,放得下(`sftp_panes_open_half_and_half` 查按钮都可见、两侧列表顶端对齐)。改工具栏按钮或下拉框宽度时要重新算这笔账。
|
||||
- **断开时对远程侧的操作要有回应。** `ExplorerPanel::execute` 一进来先看 `needs_connection(command)`(远程侧的导航、打开、删改、属性,以及所有传输;选择、书签、复制路径、队列按钮不算):标签是「未连接」时不执行,弹出「与 会话名 的 SFTP 连接已断开」(说明是远程面板的 `problem`),确定按钮「重新连接」派发 `ExplorerCommand::Reconnect`;`can_reconnect` 为假(队列里有停止的批次,或传输正在自动重连)时只有「知道了」并说明该怎么办。为了让这些命令发得出来,远程面板在「未连接」时不禁用工具栏、菜单和路径标签(`FilePane::takes_commands`:只有「连接中」才禁用),传输按钮和拖放在「未连接」时也可用(`sync_available` 的 `transfer`)。
|
||||
- **SFTP 面板的工具栏永远一行高,放不下的按钮整个藏起来。** 折行会让这一侧的列表比另一侧低一截,所以工具栏行是 `flex_wrap` 加固定一行高(`h_6`,和 small 按钮、下拉框一样高)加 `overflow_hidden`:放不下的按钮折到被裁掉的第二行,整个隐藏(不会只露半个),从最右边的开始;命令在菜单和快捷键里都还有。边框和内边距在外层 `frame` 上,行高才不含它们。`sftp_controls_fit_small_window_in_light_dark_and_zoom` 查小窗口大字号下每个按钮要么完整可见要么不可见、两侧列表顶端对齐。新标签的两侧对半分(两个 `resizable_panel` 都 `flex_basis(relative(0.5))`,只管第一帧,之后分栏状态记下实际宽度),默认窗口(1280 px)开着侧栏时每侧约 500 px,第一行(目录下拉框 `rems(10.)`)约 404 px,放得下(`sftp_panes_open_half_and_half` 查按钮都可见、两侧列表顶端对齐)。改工具栏按钮或下拉框宽度时要重新算这笔账。
|
||||
- **断开时对远程侧的操作要有回应。** `ExplorerPanel::execute` 一进来先看 `needs_connection(command)`(远程侧的导航、打开、删改、属性,以及所有传输;选择、书签、复制路径、队列按钮不算):标签是「未连接」时不执行,弹出「与 主机名称 的 SFTP 连接已断开」(说明是远程面板的 `problem`),确定按钮「重新连接」派发 `ExplorerCommand::Reconnect`;`can_reconnect` 为假(队列里有停止的批次,或传输正在自动重连)时只有「知道了」并说明该怎么办。为了让这些命令发得出来,远程面板在「未连接」时不禁用工具栏、菜单和路径标签(`FilePane::takes_commands`:只有「连接中」才禁用),传输按钮和拖放在「未连接」时也可用(`sync_available` 的 `transfer`)。
|
||||
- **SFTP 标签自己持有分栏状态。** 双栏之间和双栏与队列之间的分隔线位置是 `ExplorerPanel` 的 `panes_split` / `queue_split`(`ResizableState` 实体,`with_state` 交给 `h_resizable` / `v_resizable`)。不给 `with_state` 时分栏用 keyed element state,标签一不渲染(切到别的标签)就丢,切回来回到初始宽度(`sftp_panes_keep_their_split_across_tab_switches`)。和 Dock 布局一样只活在内存里,不持久化。
|
||||
- **路径标签按上一帧的宽度折叠。** render 时拿不到布局宽度,所以 `path-parts` 里放一个 `canvas`,在 prepaint 记下宽度,变了就用 `on_next_frame` 通知面板重画(绘制阶段里 `notify` 会丢)。各段宽度在 render 里用 `text_system().shape_line` 按 `text_sm` 量,和实际布局一致(各段不加内边距,否则 `/ home/ tester/` 看起来像多了空格)。折叠规则是纯函数 `folded_parts`:留住根,中段换成 `…`,末尾尽量多留几级;`…` 点了去它藏起的最后一级(WinSCP 同)。「当前面板」是最近获得焦点的一侧(`on_focus_in`,必须 `defer_in`,见下条),标签重新显示时焦点回到它;界面上不标出来(路径标签两侧一样是 `list_active` 底色加普通下边框):以前给当前面板加一条 `list_active_border` 下划线,用户看它像「这一侧处于激活状态」,而它和命令实际作用的一侧对不上。
|
||||
- **焦点监听器里改渲染状态要 `defer_in`。** `on_focus_in` / `on_focus` / `on_blur` 在 `draw` 的焦点阶段同步执行,这时 `cx.notify()` 只记下脏视图、不置窗口脏也不排新帧,改动要等下一次别的原因重绘才看得见。点列表空白处(GPUI 对 `track_focus` 元素在按下时自动聚焦,没有别的处理器触发重绘)因此要点两下才切换当前面板;改为 `cx.defer_in` 在绘制结束后执行就好。测试 `sftp_clicking_empty_list_space_makes_that_pane_current_at_once` 不自己 `render_frame`,只看点击本身触发的帧。
|
||||
- **SFTP 空闲时也要发现断网,和终端同时。** 终端靠主循环一直 `channel.wait()`,russh 的 keepalive(`SshConnector` 里 30 s × 3,所有 SSH 连接共用)一关掉连接它就返回。SFTP 连接同样会被关掉,但 russh-sftp 只在下一次请求时才报 `session closed`,而且不公开关闭状态。所以 `SftpClient` 把通道流包一层 `WatchedStream`:读到结尾或出错就置位一个 `watch`,`SftpClient::closed()` 等它;工作线程空闲等命令时同时等它,一触发就 `forget` 这个连接并发 `Disconnected`。等待时只持有 `Weak` 和那个 `watch` 接收端,**不能持有 `Arc<SftpClient>`**:主动「断开」靠丢掉最后一个引用来关连接,监听攥着强引用会让 SSH 连接一直开着(`an_idle_connection_that_drops_is_reported_without_a_request` 用服务器端的 `ended` 计数守着)。`is_network_error` 也认 russh-sftp 的 `session closed`。
|
||||
- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status`(`FilePane::render_status`)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。列表没有行时表格中央的占位文字(委托的 `render_empty`,id `list-placeholder`,文字来自 `FilePane::placeholder`,经 `ListingContext::placeholder` 传进去)也按阶段说:连接中「正在连接 SFTP…」,还没读到过目录(`listed` 为假)时「正在读取目录…」,读到过之后才是「空目录」(重读一个空目录时同样等 `SLOW_LOAD` 才改口);没读成功过时是「未连接」或「无法读取目录」。非根目录总有 `..` 一行,所以「空目录」实际只出现在空的根目录。改加载状态的地方都要 `sync_listing`,否则委托里的占位文字不更新。读不了目录、远程侧断开的原因是面板的 `problem`,显示在窗口状态栏最左边、平时写「已连接 会话名」的 `status-connection`:SFTP 标签在前时,工作区用 `ExplorerPanel::status`(这个标签自己的连接状态,不是会话汇总的,加上 `problem`,远程侧优先)生成 `WorkspaceStatus::Explorer`,未连接时写「未连接 会话名:原因」,已连接但读不了目录时只写原因,两种都是 `danger` 红字;会话(终端标签)断开时同样是红字。`problem` 变化时面板发 `FilePaneEvent::ProblemChanged`,标签转成 `ExplorerPanelEvent::StatusChanged`,工作区只在那个标签在前时 `notify`(不观察整个标签,传输进度每 50 ms 一次)。
|
||||
- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status`(`FilePane::render_status`)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。列表没有行时表格中央的占位文字(委托的 `render_empty`,id `list-placeholder`,文字来自 `FilePane::placeholder`,经 `ListingContext::placeholder` 传进去)也按阶段说:连接中「正在连接 SFTP…」,还没读到过目录(`listed` 为假)时「正在读取目录…」,读到过之后才是「空目录」(重读一个空目录时同样等 `SLOW_LOAD` 才改口);没读成功过时是「未连接」或「无法读取目录」。非根目录总有 `..` 一行,所以「空目录」实际只出现在空的根目录。改加载状态的地方都要 `sync_listing`,否则委托里的占位文字不更新。读不了目录、远程侧断开的原因是面板的 `problem`,显示在窗口状态栏最左边、平时写「已连接 主机名称」的 `status-connection`:SFTP 标签在前时,工作区用 `ExplorerPanel::status`(这个标签自己的连接状态,不是主机汇总的,加上 `problem`,远程侧优先)生成 `WorkspaceStatus::Explorer`,未连接时写「未连接 主机名称:原因」,已连接但读不了目录时只写原因,两种都是 `danger` 红字;主机(终端标签)断开时同样是红字。`problem` 变化时面板发 `FilePaneEvent::ProblemChanged`,标签转成 `ExplorerPanelEvent::StatusChanged`,工作区只在那个标签在前时 `notify`(不观察整个标签,传输进度每 50 ms 一次)。
|
||||
- **「打开目录/书签」的选中项是推导出来的。** 列表里被选中的就是与目录框(按面板规则展开 `~`、相对路径并去掉末尾分隔符后)相同的书签,没有另存选中状态,和 WinSCP 的 `DirectoryEditChange` 一致:单击书签只是把它写进目录框。书签的增删和排序都派发 `ExplorerCommand`(`AddBookmark { path: Some(..) }`、`RemoveBookmark`、`MoveBookmark`),改动立即写库,取消对话框也不回滚(WinSCP 同)。对话框打开后在 `open_dialog` 同步返回之后立刻 `focus` 目录框并全选,这样能生效(`Root::open_dialog` 是同步聚焦对话框宿主的,之后同一更新里的聚焦会覆盖它);延迟到下一帧的聚焦才会失效。
|
||||
- **书签按会话、按侧持久化。** `bookmarks` 表对 `sessions` 有 `ON DELETE CASCADE`;`remove_unnotified` / `remove_group_unnotified` 必须同时丢掉内存里被删会话的书签。`BookmarkSide` 放在 session 模块,因为 session 不能依赖 explorer。
|
||||
- **书签按主机、按侧持久化。** `bookmarks` 表对 `hosts` 有 `ON DELETE CASCADE`;`remove_unnotified` / `remove_group_unnotified` 必须同时丢掉内存里被删主机的书签。`BookmarkSide` 放在 host 模块,因为 host 不能依赖 explorer。
|
||||
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串;`FakeLocalDirectory` 只记录本地操作,不真的改文件。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。
|
||||
|
||||
- **端口转发独立于终端和 SFTP:规则在 store,运行状态在管理器。**
|
||||
- `SessionStore` 持有规则(`forwards`,写穿到 `forwards` 表,对 `sessions` 有 `ON DELETE CASCADE`,`remove_unnotified` 同时丢掉内存里的规则,复制会话不复制规则);`ForwardManager` 只持有「在跑」这件事。规则没有运行状态字段,启动应用时全部是停止的,除了 `auto_start` 的(`start_automatic`,工作区构造末尾调用)。
|
||||
- 转发不进 `refresh_session_connection_state`,`on_disconnect_session` 不碰它,`cancel_prompts_for_session` 也跳过 `PromptOwner::Forward`:会话的「断开连接」只管标签。删除会话 / 分组时工作区不用显式停转发,管理器看到规则从 store 消失就停。
|
||||
- 认证问答走和终端、SFTP 同一套对话框,归属是 `PromptOwner::Forward(ForwardId, 代次)`,代次是管理器每次启动递增的编号;`StatusChanged` 时工作区丢掉过期代次的问答。转发可能是自动开启的,屏幕上没有任何东西说明谁在问,所以对话框开头多一行「端口转发“名称”正在通过 会话名 连接。」(`forward_prompt_origin`)。取消问答等于停止(`manager.stop`),不算失败、不弹通知。
|
||||
- `HostStore` 持有规则(`forwards`,写穿到 `forwards` 表,对 `hosts` 有 `ON DELETE CASCADE`,`remove_unnotified` 同时丢掉内存里的规则,复制主机不复制规则);`ForwardManager` 只持有「在跑」这件事。规则没有运行状态字段,启动应用时全部是停止的,除了 `auto_start` 的(`start_automatic`,工作区构造末尾调用)。
|
||||
- 转发不进 `refresh_host_connection_state`,`on_disconnect_host` 不碰它,`cancel_prompts_for_host` 也跳过 `PromptOwner::Forward`:主机的「断开连接」只管标签。删除主机 / 分组时工作区不用显式停转发,管理器看到规则从 store 消失就停。
|
||||
- 认证问答走和终端、SFTP 同一套对话框,归属是 `PromptOwner::Forward(ForwardId, 代次)`,代次是管理器每次启动递增的编号;`StatusChanged` 时工作区丢掉过期代次的问答。转发可能是自动开启的,屏幕上没有任何东西说明谁在问,所以对话框开头多一行「端口转发“名称”正在通过 主机名称 连接。」(`forward_prompt_origin`)。取消问答等于停止(`manager.stop`),不算失败、不弹通知。
|
||||
- 转发自己失败(`ForwardManagerEvent::Failed`)弹错误通知,因为转发列表可能没在显示。单个连接转发不了(`ForwardEvent::ConnectionFailed`,例如服务器连不上目标)只记成这一行的 `problem`,转发继续跑;连接数变化只 `notify`、不发 `StatusChanged`,繁忙的 SOCKS 代理上它一直在变。
|
||||
- **转发发现断线靠处理器被丢弃,不靠 `Handler::disconnected`。** russh 0.63.3 在套接字 shutdown 失败时会跳过 `disconnected`(`client/mod.rs` 的 `Session::run`)。`SshClientHandler` 由会话任务持有,任何退出路径都会丢掉它,所以 `connect_forwarding` 把一个 `mpsc` 发送端放进处理器,工作线程 `forwarded.recv()` 读到 `None` 就是连接没了(SSH keepalive 30 s × 3,和终端同时发现)。同一个通道也承载远程转发:`server_channel_open_forwarded_tcpip` 把通道连同 `ChannelOpenHandle` 交给工作线程,连上本机目标之后才 `accept`,连不上就 `reject(ConnectFailed)`,对端看到的是拒绝而不是接受后挂断。别的连接(终端、SFTP、exec)没有这个发送端,服务器主动开来的 forwarded-tcpip 通道一律拒绝。
|
||||
- **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。
|
||||
- **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。
|
||||
- **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。
|
||||
- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `SessionPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
|
||||
- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `HostPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
|
||||
- **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。
|
||||
- **`ListItem` 里会增长的文字要零宽加裁剪。** `ListItem` 把内容包在两层 `w_full` 里、`suffix` 是它们的兄弟,整行靠内容的最小宽度收缩来给 `suffix` 让位。`flex_1().min_w_0()` 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 `suffix`(状态和启停按钮)挤出列表。转发行的文字列因此是 `w_0().flex_grow(1.).overflow_x_hidden()`(`the_forward_dialog_and_a_long_row_fit_the_smallest_window` 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:`RadioGroup::horizontal` 的那一行是 `flex_wrap`,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 `flex_1().min_w_0().self_stretch()`(那一行是 `items_center`,不拉伸的话高度不齐),卡片里那句用途同样是 `w_0().flex_grow(1.).overflow_x_hidden()`,放不下时在卡片内折行。`Radio` 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(`RADIO_DOT_COLUMN`:small 圆点 `size_3p5` 加 `gap_x_2`)拉回到圆点下面,占满卡片的整个宽度。对话框宽 `rems(42.)` 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 `Radio` 的圆点尺寸或间距时 `RADIO_DOT_COLUMN` 要跟着改,`the_three_kinds_of_forward_share_one_row` 查用途这一行的两端离卡片边缘的距离。
|
||||
- **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。
|
||||
- **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。
|
||||
- **改 schema 要升版本并加一步迁移。** `SCHEMA_VERSION` 是 12。`migrate` 对版本 0 建完整的 `SCHEMA`,对旧版本按 `STEPS` 一步接一步升级(7→8 建 `forwards` 表,8→9 建 `credentials` 和 `session_credentials` 表,9→10 是只改数据的 `Step::Code`:`keys_into_credentials` 把会话自己的私钥文件变成密钥凭据、`auto` 变成 `password`,表定义不变,`sessions.key_path` 列留着、一律为空,因为删列要重建表;10→11 建连接方式的 `session_jumps`(`session_id`、`position`、`jump_id`,后者 `ON DELETE SET NULL`)和 `session_proxies` 表:有跳板行就是跳板(全是 NULL 也是),有代理行就是代理,都没有是直连;11→12 建会话备注的 `session_notes` 表,只有填了备注的会话有一行),一个库可以连升几级;一步改不了表就用 `Step::Sql`,要在 Rust 里算(随机 `keychain_id`、取文件名)就用 `Step::Code`,两种都在一个事务里,最后写 `user_version`;已经发出去的库只改 `SCHEMA` 是拿不到新表的。新表的 DDL 写成字面量宏(`forwards_table!`、`credential_tables!`、`route_tables!`、`notes_table!`),完整 schema 和迁移步骤共用同一段文字;`a_version_7_database_gains_the_forwards_table_and_keeps_its_rows`、`a_version_8_database_gains_the_credential_tables_and_keeps_its_rows`、`a_version_9_database_moves_host_keys_into_credentials` 、`a_version_10_database_gains_the_route_tables_and_keeps_its_rows` 与 `a_version_11_database_gains_the_notes_table_and_keeps_its_rows` 用冻结的旧 schema 验证升级后的表定义与新库逐字相同。**不要给已有的表 `ALTER TABLE ADD COLUMN`**:SQLite 会把新列拼在原来的右括号前面(`\n, 列定义)`),新库的 `SCHEMA` 只有照抄这种格式才能逐字相同;要加字段就像会话和凭据的关联那样另建一张表。
|
||||
- **表结构(`SCHEMA_VERSION` 是 13)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件;`STEPS` 目前是空的,`Step` 枚举留着给下一次改表用。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。
|
||||
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path())`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate` 后直接退出。
|
||||
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
|
||||
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
|
||||
- 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
|
||||
- **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动检查并下载更新」存在 `AppSettings::update`,工作区在 `observe_in(&settings)` 里 `sync_updater`,`update` 模块不依赖 `settings`。动作是 `CheckForUpdates` / `DownloadUpdate` / `ShowUpdate` / `RestartToUpdate` / `OpenDownloadPage`,处理器在 `workspace/updates.rs`;对话框里「重启会中断什么」由工作区数(`restart_impact`:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 `cx.set_restart_path` + `cx.restart()`,它会照常跑 `on_app_quit`(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。**Windows 不在退出过程中起进程**:gpui 退出时在 App 仍被借用的状态下跑 `on_app_quit`,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 `update::start_handed_over()`,由 `main` 在 `run()` 返回之后启动(Windows 的事件循环结束后 `run` 会返回)。更新前写 `updates/applied.json`(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 `app::updates_dir()`(用户缓存目录;设了 `SHELLRS_DATA_DIR` 时在数据目录下)。
|
||||
- **发布走 tag。** 推 `v<版本>`(和 `Cargo.toml` 一致,`CHANGELOG.md` 里要有 `## [版本]` 一节)触发 `.github/workflows/release.yml`:分平台构建打包(`packaging/macos/bundle.sh`、`packaging/windows/shellrs.iss`、`packaging/linux/appimage.sh`),在需要人工批准的 `release` 环境里上传 R2、建 GitHub Release,**最后**才用 `packaging/make-manifest.sh` 生成并上传清单、清 CDN 缓存。清单格式见 `packaging/manifest.example.json`(`the_example_manifest_parses` 守着它和解析器一致,`the_release_script_signs_what_the_client_accepts` 守着脚本和验签一致)。一次性准备和 secrets 见 `README.md` 的「发布」。
|
||||
- **库只往前升,旧版本不碰新库。** `migrate` 遇到 `user_version > SCHEMA_VERSION` 直接报错、不动文件(`open_store` 退回内存 store 并提示);迁移已有的库之前先 `VACUUM INTO '<库>.v<旧版本>.bak'`(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。
|
||||
- **库只往前升,旧版本不碰新库。** `migrate` 遇到 `user_version > SCHEMA_VERSION`(或早于第一步)直接报错、不动文件(`open_store` 退回内存 store 并提示);迁移已有的库之前先 `VACUUM INTO '<库>.v<旧版本>.bak'`(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。
|
||||
- **窗口还在搭的时候不能直接弹通知。** 通知层属于窗口的 `Root`,而 `Root` 要等 `open_window` 的构造闭包返回才挂上;在那之前 `window.push_notification` 会 panic(「window first layer should be a gpui_component::Root」),应用启动即崩溃。`main` 里读库 / 读设置的问题、`Workspace::new` 里外部 CLI 启动失败,都用 `workspace::notify_once_open`(`window.defer`)延后到窗口建好。`cx.emit` 出去的事件本来就在构造结束后才送达,订阅里弹通知没有这个问题(自动开启的转发启动失败就是这样报的)。
|
||||
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
|
||||
- **设置写穿到 JSON,由工作区落到窗口上。** 设置是应用级的,不进会话数据库,存在 `settings.json`(`app::settings_path`),整文件先写临时文件再改名替换。`SettingsStore` 和 `SessionStore` 一样内存说了算:`update` 改内存 → 写文件 → `notify`,写失败发 `SettingsStoreEvent::PersistFailed`,工作区弹错误通知;`update_unnotified` 是给单元测试的纯内存一半。文件缺字段用默认值,读不出来就用默认设置并在启动时提示,文件留到下一次改动时覆盖。设置页的下拉只写 store(下拉的 setter 拿不到 `Window`);工作区 `observe_in` store 调 `settings::apply`,另外 `observe_window_appearance` 让「跟随系统」跟着系统切换,构造时在第一帧之前先应用一次。标题栏的主题开关(`ToggleTheme`)也只是把「应用外观」写成当前的反面,不再直接改主题,所以两处永远一致并且会保存。界面语言目前只影响 gpui-kit 自己的文案(`set_locale`),ShellRS 的文案还是中文,所以默认是「简体中文」而不是「跟随系统」;翻译完成后再改默认值。locale 是进程级全局量,UI 测试并行运行,所以 UI 测试不切换语言,只由单元测试覆盖 `locale` 的解析。
|
||||
- **设置写穿到 JSON,由工作区落到窗口上。** 设置是应用级的,不进主机数据库,存在 `settings.json`(`app::settings_path`),整文件先写临时文件再改名替换。`SettingsStore` 和 `HostStore` 一样内存说了算:`update` 改内存 → 写文件 → `notify`,写失败发 `SettingsStoreEvent::PersistFailed`,工作区弹错误通知;`update_unnotified` 是给单元测试的纯内存一半。文件缺字段用默认值,读不出来就用默认设置并在启动时提示,文件留到下一次改动时覆盖。设置页的下拉只写 store(下拉的 setter 拿不到 `Window`);工作区 `observe_in` store 调 `settings::apply`,另外 `observe_window_appearance` 让「跟随系统」跟着系统切换,构造时在第一帧之前先应用一次。标题栏的主题开关(`ToggleTheme`)也只是把「应用外观」写成当前的反面,不再直接改主题,所以两处永远一致并且会保存。界面语言目前只影响 gpui-kit 自己的文案(`set_locale`),ShellRS 的文案还是中文,所以默认是「简体中文」而不是「跟随系统」;翻译完成后再改默认值。locale 是进程级全局量,UI 测试并行运行,所以 UI 测试不切换语言,只由单元测试覆盖 `locale` 的解析。
|
||||
- **外部 CLI 经由正在运行的应用执行。** 命令本身不读钥匙串、不开数据库,只把请求交给应用:所以不会多弹钥匙串授权框,也不会和「内存说了算」、内存里分配 id 冲突。
|
||||
- **服务端**:生产路径 `Workspace::new` 用终端同一个 `SshConnector`(共享 known_hosts 锁)和 SFTP 同一个 provider 启动 `CliServer`;`new_with_services`(UI 测试)不启服务,测试绝不监听真实套接字。工作区在 store / 设置变化时 `sync_cli_server`:会话快照(`CliTarget::all`,带分组全路径)和「启用外部 CLI」开关。
|
||||
- **服务端**:生产路径 `Workspace::new` 用终端同一个 `SshConnector`(共享 known_hosts 锁)和 SFTP 同一个 provider 启动 `CliServer`;`new_with_services`(UI 测试)不启服务,测试绝不监听真实套接字。工作区在 store / 设置变化时 `sync_cli_server`:主机快照(`CliTarget::all`,带分组全路径)和「启用外部 CLI」开关。
|
||||
- **一直监听,按开关拒绝**:开关关着时回 `not_enabled`,Agent 才分得清「没运行」和「没开」。套接字 `0600`,每个连接再用 `peer_cred` 核对 uid。残留的套接字文件:连得上说明另一个实例在跑(启动失败并弹通知),连不上就删掉接管。
|
||||
- **Windows 用命名管道,两端互相确认。** 管道名是整机的命名空间,不在用户自己的目录里,所以:
|
||||
- 服务端每个实例都带 DACL `D:P(A;;GA;;;<当前用户 SID>)` 和 `PIPE_REJECT_REMOTE_CLIENTS`,第一个实例带 `FILE_FLAG_FIRST_PIPE_INSTANCE`(名字已被占用就启动失败,报 `AddrInUse`)。DACL 在创建时原子生效,所以不像 Unix 那样再查对方的身份(查进程属主会误伤以管理员身份运行的 Agent)。
|
||||
@@ -138,16 +138,16 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **传输无人值守**:`SshCliBackend::transfer` 先等 `Connected` 再发请求;连接前的 `Prompt` 一律回 `Cancel` 并按提示种类给出错误码;`Question` 按固定策略回答(冲突覆盖并勾选全部、续传一律重新开始、出错跳过并记为失败);以 `Idle` 为结束。请求用 `UploadRequest::scp` / `DownloadRequest::scp`:worker 连上后按 scp 规则解析目标(`scp_remote_target` / `scp_local_target`,目标目录必须存在,远程还要 `stat` 确认,因为有的服务器 realpath 不存在的路径也成功),并让新建文件保留源文件的权限位(上传整套 `0o777`,下载只补可执行位);SFTP 标签的请求不受影响。批次准备失败时 worker 也发 `Idle`。
|
||||
- **安装集成的路径保护**:`CliIntegration` 没有路径(UI 测试默认)时页面显示「暂不支持」、没有安装按钮,测试不会写到真实的 `~`;需要时用 `workspace.cli_integration()` 注入临时目录。安装 / 移除是动作(`InstallCliCommand`、`RemoveCliCommand`、`InstallAgentSkill(AgentKind)`、`RemoveAgentSkill(AgentKind)`、`RefreshCliIntegration`、`CopyAgentSkill`),由工作区在后台线程执行后 `refresh`。
|
||||
- **终端字体走全局量。** 终端模块不能依赖 settings,所以 `settings::apply` 把字体设成 `terminal::TerminalFont` 全局量并 `window.refresh()`,`TerminalView` 在 render(字体族、字号)和 prepaint(行高 `row_height`,按设备像素取整)里读它;格子变了,下一帧 prepaint 就按新尺寸 resize,已打开的终端立即生效。默认值就是加设置之前的样子:主题的等宽字体(macOS 上是 Menlo)、13 px、行高 1.54(13 × 1.54 取整后正好是原来的 20 px 行)。字体族存 `None` 表示跟随主题默认,下拉里选默认那一项也存 `None`。文件里的字体族只有装着才用(`is_font_installed`,进程内读一次字体列表;GPUI 找不到字体族时会换成别的字体),否则退回默认,文件里的值保留。数字在 `AppSettings::normalized` 里夹到范围并取整(字号整像素、行高两位小数),读文件和每次修改都过一遍;gpui-kit 的数字框用 `f64::to_string` 显示,步进 0.1 会显示 `1.7000000000000002`,store 取整后数字框下一次渲染会同步成取整后的值。字号用下拉不用数字框:数字框每敲一个字符就夹到最小值,输入 14 时敲下「1」就变成 8。字体下拉只列等宽字体:`monospace_font_families` 要加载每个字体族(macOS 上约 150 ms),在后台线程跑一次、进程内缓存,结果回来前下拉里只有默认字体;判定是「i」和「M」一样宽且窄于 0.8 em,后一条排除 Webdings 这种方块符号字体。预览 `TerminalFontPreview` 用和终端一样的排法(每格一个字形、宽字符后面补一个空格格子、终端的行高和配色),不是普通文字排版,否则中文的宽度和终端里对不上。
|
||||
- **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。
|
||||
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,会话树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。会话树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。
|
||||
- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `SessionLogin`(`SessionStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 `route: LoginRoute`——每台跳板会话的登录也查好了,`JumpLogin::Host { name, login }` 或已删除的 `JumpLogin::Deleted`;跳板自己的 `route` 一律是直连;会话表单测试连接时用 `SessionStore::route_login` 把表单上的 `Route` 解析成同样的东西),不是 `Session`。`SessionLogin` 只含影响登录的字段并派生 `PartialEq`(`JumpLogin` 手写 `PartialEq`、不比 `name`,跳板改名不让后面的会话重连),会话 `update` 和 `update_credential` 都在改之前取一遍**全部会话**的快照(`logins`),改完按「前后快照不相等」逐台发 `ConnectionSettingsChanged`(`emit_changed_logins`),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的会话也会重连;删除会话不发(同删除凭据,已打开的连接不断)。引用凭据的会话在 `Session.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`),store 在保存会话(`normalized`)、改凭据(同一个事务里改 `sessions.username`)和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `session.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。
|
||||
- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `SessionStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或会话在用时原地覆盖:路径不变,用它的会话不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。
|
||||
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的会话不占着端点的密码条目,会话从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:会话对话框用第二个 `SecretFields` 预填和提交,没有会话再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。
|
||||
- **id 由内存分配。** `next_session_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
|
||||
- **对外只给 `PublicId`,不给 `SessionId`。** 会话右键「复制 ID」复制的是 `Session.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `sessions.public_id`(可为空,唯一性由索引 `sessions_public_id` 保证)。新建和复制会话时由 `SessionStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建会话时必须带上原来的 `public_id`,`update_session` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。
|
||||
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
|
||||
- **持久化写穿,内存说了算。** `HostStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `HostStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删主机的 id 返回给工作区去关标签页。
|
||||
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,主机保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `HostStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_host` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,主机树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加主机名称第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。主机树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。
|
||||
- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `HostLogin`(`HostStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 `route: LoginRoute`——每台跳板主机的登录也查好了,`JumpLogin::Host { name, login }` 或已删除的 `JumpLogin::Deleted`;跳板自己的 `route` 一律是直连;主机表单测试连接时用 `HostStore::route_login` 把表单上的 `Route` 解析成同样的东西),不是 `Host`。`HostLogin` 只含影响登录的字段并派生 `PartialEq`(`JumpLogin` 手写 `PartialEq`、不比 `name`,跳板改名不让后面的主机重连),主机 `update` 和 `update_credential` 都在改之前取一遍**全部主机**的快照(`logins`),改完按「前后快照不相等」逐台发 `ConnectionSettingsChanged`(`emit_changed_logins`),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的主机也会重连;删除主机不发(同删除凭据,已打开的连接不断)。引用凭据的主机在内存的 `Host.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`;库里这一行的 `username` 是 NULL、`auth` 是 `credential`),store 在保存主机(`normalized`)、改凭据和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `host.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。
|
||||
- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `HostStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或主机在用时原地覆盖:路径不变,用它的主机不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。
|
||||
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。
|
||||
- **id 由内存分配。** `next_host_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`HostStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
|
||||
- **对外只给 `PublicId`,不给 `HostId`。** 主机右键「复制 ID」复制的是 `Host.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `hosts.public_id`(可为空,`UNIQUE`)。新建和复制主机时由 `HostStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建主机时必须带上原来的 `public_id`,`update_host` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。
|
||||
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<HostStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
|
||||
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
|
||||
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer` / `CloseLocalTerminal` / `CloseSettings`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`SessionStore::recent_sessions()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把会话移到最前。
|
||||
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer` / `CloseLocalTerminal` / `CloseSettings`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`HostStore::recent_hosts()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把主机移到最前。
|
||||
- **终端内容区的右键菜单与查找、清屏。**
|
||||
- **菜单**:`TerminalView` 自己给出复制、粘贴、查找、清屏。面板通过 `set_menu_items` 在下面追加一段:远程终端追加打开 SFTP、重新连接、断开连接(`DisconnectTerminal` 只断这一个标签);本地终端不追加。追加段的构造器在菜单打开时运行,拿到的是事先读好的 `TerminalLifecycle`,因为这时 `TerminalView` 正在更新,读它会重入。
|
||||
- **动作**:`FindInTerminal` / `FindNextInTerminal` / `FindPreviousInTerminal` / `DismissTerminalFind` / `ClearTerminal` 和复制、粘贴一样,由工作区通过 `active_terminal` 转给当前标签的 `TerminalView`。
|
||||
@@ -158,21 +158,21 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- 焦点匹配按回滚历史的增长量跟着输出上移,再用 `regex_search_right` 核对,对不上就丢掉。计数不随新输出刷新。
|
||||
- **查找条**必须是终端 `div` 的兄弟元素,不能是子元素:否则输入框里敲的键会冒泡到终端的 `on_key_down`,被发给 shell。它还要 `.occlude()`,否则点它会被下面终端的鼠标处理器抢走焦点。
|
||||
- **清屏**只在本地模拟器里做:把光标所在的逻辑行(含折行)挪到第 0 行,清空回滚历史,远端不知情。`ALT_SCREEN` 下什么也不做,菜单项置灰。
|
||||
- **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。双击标题派发 `ToggleSessionPanel`(显示 / 隐藏左侧会话栏,同 ⌘B),也挂在标题上,所有中间区标签共用;第一下单击照常选中标签,关闭按钮的单击停止冒泡,双击它不会切换侧栏。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。中间区各面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 `custom_title`)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(`RenamableTab::default_title`:终端是会话名,SFTP 是「会话名 · SFTP」)时回到默认,默认名随会话改名。SFTP 标签的菜单(`explorer_panel.rs` 的 `TabMenu`)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑主机…」;标签栏按钮和远程终端一样是「SFTP」(`("open-sftp", id)`)和「重新连接」(`("reconnect-sftp", id)`)。SFTP 的「重新连接」是 `ExplorerCommand::Reconnect`:已连接时先 `disconnect` 再发 `Resume`(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。
|
||||
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效,要预先聚焦某个字段只能在 `open_dialog` 返回后的同一次更新里同步调用(见 `open_directory_dialog.rs`)。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_session_stay_closable`)。
|
||||
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
|
||||
- **会话行的 tooltip 开在行的右侧。** 悬停会话行 500 ms 后,行右侧显示 `user@host:port`(`session_tooltip`,不显示系统),配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字)。只有这一个 tooltip 这样:gpui-kit 的 tooltip 和菜单、下拉共用 `popover` 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip(`Button::tooltip_placement` 那套)是 crate 私有的,普通元素的 `.tooltip()` 只会贴着鼠标弹、盖住下面的行,所以 `SessionPanel` 自己持有一个 `gpui_kit::base::TooltipOverlay`(`row_tooltip`,作为面板的子元素渲染,面板收起就一起消失;动画 `animate_row_tooltip` 仿 gpui-kit 自己的)。行在 `on_hover` 里带着行的边界 `request_show(...placement(Placement::Right))`,任意鼠标按下立即 `hide`,拖动中不显示。**只有 tooltip 的当前主人才能收起它**(`row_tooltip_owner`):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 `request_hide` 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(`the_row_tooltip_follows_the_pointer_down_the_list`)。按键会收起 tooltip 是 GPUI 的 `HoverListenerMode::InputModalityAware`:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 `canvas` 在 prepaint 记下,**必须**带 `top_0().left_0()`:`ListItem` 的内容盒不是 `relative`,不设偏移的 canvas(gpui-base 的 `ElementExt::on_prepaint` 就是这样)会落到内容下方,tooltip 随之偏下将近一行。
|
||||
- **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `SessionPanel` 把 `.context_menu(..)` 挂在 `#session-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc<Cell<Option<SessionNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。开始页的列表 `recent-session-list` 照样挂在容器上(右键行同时选中该行,空白处得到空菜单、不弹出);开始页是 `deferred` 画的,但 `Deferred` 的子元素在主布局阶段就做 `request_layout`,菜单抢焦点仍早于任何 prepaint,不会触发上面的断言。
|
||||
- **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。双击标题派发 `ToggleHostPanel`(显示 / 隐藏左侧栏,同 ⌘B),也挂在标题上,所有中间区标签共用;第一下单击照常选中标签,关闭按钮的单击停止冒泡,双击它不会切换侧栏。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。中间区各面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 `custom_title`)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(`RenamableTab::default_title`:终端是主机名称,SFTP 是「主机名称 · SFTP」)时回到默认,默认名随主机改名。SFTP 标签的菜单(`explorer_panel.rs` 的 `TabMenu`)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑主机…」;标签栏按钮和远程终端一样是「SFTP」(`("open-sftp", id)`)和「重新连接」(`("reconnect-sftp", id)`)。SFTP 的「重新连接」是 `ExplorerCommand::Reconnect`:已连接时先 `disconnect` 再发 `Resume`(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。
|
||||
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦主机面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效,要预先聚焦某个字段只能在 `open_dialog` 返回后的同一次更新里同步调用(见 `open_directory_dialog.rs`)。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_host_stay_closable`)。
|
||||
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`HostPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `HostPanel` 比对 `known_groups` / `seen_hosts` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
|
||||
- **主机行的 tooltip 开在行的右侧。** 悬停主机行 500 ms 后,行右侧显示 `user@host:port`(`host_tooltip`,不显示系统),配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字)。只有这一个 tooltip 这样:gpui-kit 的 tooltip 和菜单、下拉共用 `popover` 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip(`Button::tooltip_placement` 那套)是 crate 私有的,普通元素的 `.tooltip()` 只会贴着鼠标弹、盖住下面的行,所以 `HostPanel` 自己持有一个 `gpui_kit::base::TooltipOverlay`(`row_tooltip`,作为面板的子元素渲染,面板收起就一起消失;动画 `animate_row_tooltip` 仿 gpui-kit 自己的)。行在 `on_hover` 里带着行的边界 `request_show(...placement(Placement::Right))`,任意鼠标按下立即 `hide`,拖动中不显示。**只有 tooltip 的当前主人才能收起它**(`row_tooltip_owner`):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 `request_hide` 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(`the_row_tooltip_follows_the_pointer_down_the_list`)。按键会收起 tooltip 是 GPUI 的 `HoverListenerMode::InputModalityAware`:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 `canvas` 在 prepaint 记下,**必须**带 `top_0().left_0()`:`ListItem` 的内容盒不是 `relative`,不设偏移的 canvas(gpui-base 的 `ElementExt::on_prepaint` 就是这样)会落到内容下方,tooltip 随之偏下将近一行。
|
||||
- **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `HostPanel` 把 `.context_menu(..)` 挂在 `#host-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc<Cell<Option<HostNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。开始页的列表 `recent-host-list` 照样挂在容器上(右键行同时选中该行,空白处得到空菜单、不弹出);开始页是 `deferred` 画的,但 `Deferred` 的子元素在主布局阶段就做 `request_layout`,菜单抢焦点仍早于任何 prepaint,不会触发上面的断言。
|
||||
- **表单里的几选一用 `shared::SegmentedControl`。** 用户给的样式:背景色的轨道加 `border` 细边框,选中的一段是 `accent` 底色、比轨道深一级的细边框(`border` 混入 8% 的 `foreground`,浅色主题约 neutral-300,深色主题反过来变亮)和轻阴影的块(截图里的灰太深,`secondary_active` 是 neutral-300,用户要浅一些;`accent` 浅色主题是 neutral-100,深色是 neutral-800),没选中的是 `muted_foreground` 字、悬停时半透明的 `accent` 底,比选中的淡;各段等宽铺满轨道(`flex_1`),高度是中号控件的 `h_8`,选中块的圆角按 2 px 内缩与轨道同心。行为全来自 gpui-base 的 `Radio` / `RadioGroup`(点击、回车 / 空格、每段一个 Tab 停靠点、无障碍角色),这里只管外观;每段的元素 id 是它的序号,和 gpui-component 的 `RadioGroup` 一样,所以测试照旧 `within(组).click(1usize)`。gpui-base 的 `RadioGroup` 是 `RenderOnce`,不能 `test_support()`,测试里找不到轨道本身,只能量各段。主机对话框的认证方式和连接方式、凭据对话框的类型 / 私钥来源 / 算法都用它;端口转发的三张类型卡片带说明和示意图,仍是 gpui-component 的 `RadioGroup`。
|
||||
- **会话对话框和转发对话框一样从窗口高度的 5% 开始、最高占 90%**,选了跳板或代理、框里的内容多起来时正文滚动、按钮不动。
|
||||
- **主机对话框和转发对话框一样从窗口高度的 5% 开始、最高占 90%**,选了跳板或代理、框里的内容多起来时正文滚动、按钮不动。
|
||||
- **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `window.open_dialog` 都要 `.overlay_closable(false)`(默认是 `true`,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(`a_click_beside_a_dialog_does_not_close_it` 查主机、分组、端口转发和凭据四个)。`open_alert_dialog` 开的确认框本来就不会被点外面关掉。
|
||||
- **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`DialogAction`)和回车都是 `window.dispatch_action(Confirm)`,先到焦点元素;gpui-base 的 `Select` 在收起状态下收到 `Confirm` 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」、主机对话框的「凭据」「分组」都没有绕开(`ForwardForm::focus_bind_port` 只在打开新建对话框时聚焦一次,代码里没有订阅 `SelectEvent`;「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这些下拉之后要先点一下别的字段再点 `commit`,否则后面第一次点击会被吃掉。
|
||||
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。
|
||||
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_hosts_are_read_back_from_the_database`)。
|
||||
- **通知层要 `deferred` 并高过对话框。** gpui-base 的对话框(连同半透明背景)是用 `deferred`(优先级 0)画的,开始页也是;通知层若作为普通子元素,就会画在对话框背景底下,只能隐约看到。所以工作区把 `Root::render_notification_layer` 包成 `deferred(..).with_priority(NOTIFICATION_PRIORITY)`(= 1,低于菜单等弹出层的 `POPUP_PRIORITY` = 100)。无头测试看不到绘制顺序(被盖住的元素照样能点中),这条只能靠肉眼确认。
|
||||
- **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。
|
||||
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如会话面板里各会话系统的 `Rc<HashMap<SessionId, HostOs>>`)。
|
||||
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如主机面板里各主机系统的 `Rc<HashMap<HostId, HostOs>>`)。
|
||||
- `px(..)` 只允许出现在 API 要求 `Pixels` 的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和 `cx.theme()` token。
|
||||
|
||||
## 测试约定
|
||||
|
||||
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(会话面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-sessions` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-password`、「无密码」下面的说明 `session-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 Line truncated
|
||||
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、主机行的 tooltip `host-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)Line truncated
|
||||
@@ -16,7 +16,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
|
||||
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
|
||||
|
||||
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。旧版本里「自动」的主机升级后归为「密码」,自己选了私钥文件的主机升级后改用按「用户 + 私钥文件」自动建好的密钥凭据。
|
||||
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。
|
||||
|
||||
主机对话框「分组」下面的「连接方式」决定怎么连到这台主机,终端、SFTP、端口转发、外部 CLI 和「测试连接」都照它走:
|
||||
|
||||
@@ -117,7 +117,7 @@ shellrs download <ID> <远程路径> <本地路径>
|
||||
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。
|
||||
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
|
||||
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`session_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里,多行或带引号的命令用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
|
||||
|
||||
## 标签页
|
||||
|
||||
+32
-32
@@ -6,15 +6,15 @@ use gpui_kit::*;
|
||||
use crate::{
|
||||
cli::AgentKind,
|
||||
explorer::{ExplorerId, FileSizeFormat},
|
||||
session::{CredentialId, ForwardId, GroupId, NodeDrop, SessionId, SessionNode},
|
||||
host::{CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop},
|
||||
terminal::{LocalTerminalId, RemoteTerminalId},
|
||||
};
|
||||
|
||||
gpui_kit::actions!(
|
||||
shellrs,
|
||||
[
|
||||
/// Open the new-session dialog for a session at the root of the tree.
|
||||
NewSession,
|
||||
/// Open the new-host dialog for a host at the root of the tree.
|
||||
NewHost,
|
||||
/// Open the new-group dialog for a top-level group.
|
||||
NewGroup,
|
||||
/// Open a new local login-shell terminal.
|
||||
@@ -35,9 +35,9 @@ gpui_kit::actions!(
|
||||
/// prompt line.
|
||||
ClearTerminal,
|
||||
/// Show or hide the left dock, whichever list it is showing.
|
||||
ToggleSessionPanel,
|
||||
/// Show the session list in the left dock.
|
||||
ShowSessions,
|
||||
ToggleHostPanel,
|
||||
/// Show the host list in the left dock.
|
||||
ShowHosts,
|
||||
/// Show the port-forwarding list in the left dock.
|
||||
ShowForwards,
|
||||
/// Open the new-forward dialog.
|
||||
@@ -67,11 +67,11 @@ gpui_kit::actions!(
|
||||
/// Move keyboard focus to the search field of the list the left dock
|
||||
/// is showing.
|
||||
FocusSearch,
|
||||
/// Connect the session selected in the focused session list.
|
||||
/// Connect the host selected in the focused host list.
|
||||
ConnectSelected,
|
||||
/// Expand every group in the session tree.
|
||||
/// Expand every group in the host tree.
|
||||
ExpandAllGroups,
|
||||
/// Collapse every group in the session tree.
|
||||
/// Collapse every group in the host tree.
|
||||
CollapseAllGroups,
|
||||
/// Close the center tab displayed most recently (the one ⌘W acts on).
|
||||
CloseActiveTab,
|
||||
@@ -121,29 +121,29 @@ macro_rules! id_actions {
|
||||
}
|
||||
|
||||
id_actions! {
|
||||
/// Open a new terminal connection for a session.
|
||||
ConnectSession(SessionId);
|
||||
/// Mark a session disconnected.
|
||||
DisconnectSession(SessionId);
|
||||
/// Open a new SFTP tab for a session, like a new terminal connection.
|
||||
OpenExplorer(SessionId);
|
||||
/// Open the edit-session dialog for a session.
|
||||
EditSession(SessionId);
|
||||
/// Duplicate a session in the same group.
|
||||
DuplicateSession(SessionId);
|
||||
/// Ask for confirmation, then delete a session.
|
||||
DeleteSession(SessionId);
|
||||
/// Copy a session's host field (an IP address or a host name) to the
|
||||
/// Open a new terminal connection for a host.
|
||||
ConnectHost(HostId);
|
||||
/// Mark a host disconnected.
|
||||
DisconnectHost(HostId);
|
||||
/// Open a new SFTP tab for a host, like a new terminal connection.
|
||||
OpenExplorer(HostId);
|
||||
/// Open the edit-host dialog for a host.
|
||||
EditHost(HostId);
|
||||
/// Duplicate a host in the same group.
|
||||
DuplicateHost(HostId);
|
||||
/// Ask for confirmation, then delete a host.
|
||||
DeleteHost(HostId);
|
||||
/// Copy a host's address (an IP address or a host name) to the
|
||||
/// clipboard.
|
||||
CopySessionHost(SessionId);
|
||||
/// Copy a session's public id to the clipboard, for another tool to
|
||||
CopyHostAddress(HostId);
|
||||
/// Copy a host's public id to the clipboard, for another tool to
|
||||
/// name the machine by.
|
||||
CopySessionId(SessionId);
|
||||
CopyHostId(HostId);
|
||||
|
||||
/// Open a terminal connection for every session in this group's subtree.
|
||||
/// Open a terminal connection for every host in this group's subtree.
|
||||
ConnectGroup(GroupId);
|
||||
/// Open the new-session dialog with this group pre-selected.
|
||||
NewSessionInGroup(GroupId);
|
||||
/// Open the new-host dialog with this group pre-selected.
|
||||
NewHostInGroup(GroupId);
|
||||
/// Open the new-group dialog for a group nested inside this one.
|
||||
NewChildGroup(GroupId);
|
||||
/// Open the rename-group dialog.
|
||||
@@ -153,7 +153,7 @@ id_actions! {
|
||||
|
||||
/// Close one remote terminal connection and its tab.
|
||||
CloseTerminal(RemoteTerminalId);
|
||||
/// Reconnect one remote terminal using the session's latest settings.
|
||||
/// Reconnect one remote terminal using the host's latest settings.
|
||||
ReconnectTerminal(RemoteTerminalId);
|
||||
/// Disconnect one remote terminal, keeping its tab to reconnect from.
|
||||
DisconnectTerminal(RemoteTerminalId);
|
||||
@@ -197,11 +197,11 @@ id_actions! {
|
||||
DeleteCredential(CredentialId);
|
||||
}
|
||||
|
||||
/// Move a session-tree row by dropping it beside a peer or into a group.
|
||||
/// Move a host-tree row by dropping it beside a peer or into a group.
|
||||
#[derive(Action, Clone, PartialEq, Eq)]
|
||||
#[action(namespace = shellrs, no_json)]
|
||||
pub struct MoveSessionNode {
|
||||
pub source: SessionNode,
|
||||
pub struct MoveHostNode {
|
||||
pub source: HostNode,
|
||||
pub destination: NodeDrop,
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -54,8 +54,8 @@ icon_assets!(
|
||||
/// Names of the icons in `ExtraIcons`, usable anywhere an `Icon` is built.
|
||||
pub use gpui_kit::assets::IconName as CatalogIcon;
|
||||
|
||||
/// Operating-system marks for the session tree, one file per
|
||||
/// [`crate::session::HostOs`] variant.
|
||||
/// Operating-system marks for the host tree, one file per
|
||||
/// [`crate::host::HostOs`] variant.
|
||||
///
|
||||
/// These are brand logos, not Lucide glyphs, because an approximation cannot
|
||||
/// say "this is a Debian box". The artwork comes from Simple Icons (CC0); the
|
||||
@@ -127,7 +127,7 @@ impl AssetSource for AppAssets {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppAssets;
|
||||
use crate::session::HostOs;
|
||||
use crate::host::HostOs;
|
||||
use gpui_kit::AssetSource as _;
|
||||
|
||||
#[test]
|
||||
|
||||
+12
-12
@@ -17,9 +17,9 @@ use gpui_kit::*;
|
||||
|
||||
use crate::terminal::{TERMINAL_FIND_KEY_CONTEXT, TERMINAL_KEY_CONTEXT, terminal_key_bindings};
|
||||
|
||||
/// Key context of the session panel, for bindings that only apply there.
|
||||
pub const SESSION_PANEL_CONTEXT: &str = "SessionPanel";
|
||||
pub const RECENT_SESSIONS_CONTEXT: &str = "RecentSessions";
|
||||
/// Key context of the host panel, for bindings that only apply there.
|
||||
pub const HOST_PANEL_CONTEXT: &str = "HostPanel";
|
||||
pub const RECENT_HOSTS_CONTEXT: &str = "RecentHosts";
|
||||
/// Key context of the port-forwarding list.
|
||||
pub const FORWARD_PANEL_CONTEXT: &str = "ForwardPanel";
|
||||
/// Key context of the credential list.
|
||||
@@ -48,9 +48,9 @@ pub(crate) fn deepen_list_hover(cx: &mut App) {
|
||||
Theme::sync_base(cx);
|
||||
}
|
||||
|
||||
/// Give the session tree three times the list hover's contrast against its sidebar.
|
||||
/// The recent-session list keeps the shared hover color unchanged.
|
||||
pub(crate) fn session_tree_selection_color(theme: &Theme) -> Hsla {
|
||||
/// Give the host tree three times the list hover's contrast against its sidebar.
|
||||
/// The recent-host list keeps the shared hover color unchanged.
|
||||
pub(crate) fn host_tree_selection_color(theme: &Theme) -> Hsla {
|
||||
let sidebar = theme.sidebar;
|
||||
let hover = sidebar.blend(theme.list_hover);
|
||||
let mut selected = hover.alpha(1.0);
|
||||
@@ -66,9 +66,9 @@ fn key_bindings() -> Vec<KeyBinding> {
|
||||
|
||||
let primary = |key: &str| format!("{PRIMARY}-{key}");
|
||||
let mut bindings = vec![
|
||||
KeyBinding::new(&primary("n"), NewSession, None),
|
||||
KeyBinding::new(&primary("n"), NewHost, None),
|
||||
KeyBinding::new(&primary("shift-n"), NewGroup, None),
|
||||
KeyBinding::new(&primary("b"), ToggleSessionPanel, None),
|
||||
KeyBinding::new(&primary("b"), ToggleHostPanel, None),
|
||||
KeyBinding::new(&primary("k"), FocusSearch, None),
|
||||
KeyBinding::new(&primary("t"), NewLocalTerminal, None),
|
||||
KeyBinding::new(&primary("w"), CloseActiveTab, None),
|
||||
@@ -78,8 +78,8 @@ fn key_bindings() -> Vec<KeyBinding> {
|
||||
KeyBinding::new(&primary("0"), ZoomReset, None),
|
||||
KeyBinding::new(&primary("q"), Quit, None),
|
||||
KeyBinding::new("shift-escape", ToggleZoom, None),
|
||||
KeyBinding::new("enter", ConnectSelected, Some(SESSION_PANEL_CONTEXT)),
|
||||
KeyBinding::new("enter", ConnectSelected, Some(RECENT_SESSIONS_CONTEXT)),
|
||||
KeyBinding::new("enter", ConnectSelected, Some(HOST_PANEL_CONTEXT)),
|
||||
KeyBinding::new("enter", ConnectSelected, Some(RECENT_HOSTS_CONTEXT)),
|
||||
KeyBinding::new("enter", ToggleSelectedForward, Some(FORWARD_PANEL_CONTEXT)),
|
||||
KeyBinding::new("up", SelectPreviousForward, Some(FORWARD_PANEL_CONTEXT)),
|
||||
KeyBinding::new("down", SelectNextForward, Some(FORWARD_PANEL_CONTEXT)),
|
||||
@@ -103,7 +103,7 @@ fn key_bindings() -> Vec<KeyBinding> {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
KeyBinding::new("ctrl-shift-v", PasteTerminal, Some(TERMINAL_KEY_CONTEXT)),
|
||||
// In a terminal ⌘K clears, as in other macOS terminals; everywhere
|
||||
// else it still focuses the session search. Inside a terminal, Ctrl
|
||||
// else it still focuses the host search. Inside a terminal, Ctrl
|
||||
// with a letter belongs to the shell, so other platforms add Shift, as
|
||||
// they do for copy and paste.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -186,7 +186,7 @@ fn file_list_key_bindings() -> Vec<KeyBinding> {
|
||||
),
|
||||
bind("alt-left", ExplorerCommand::Back { remote }, context),
|
||||
bind("alt-right", ExplorerCommand::Forward { remote }, context),
|
||||
// WinSCP's Ctrl+B already toggles the session panel here.
|
||||
// WinSCP's Ctrl+B already toggles the host panel here.
|
||||
bind(
|
||||
&format!("{PRIMARY}-d"),
|
||||
ExplorerCommand::AddBookmark { remote, path: None },
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ pub fn data_dir() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// The SQLite file holding sessions and groups. The parent directory is
|
||||
/// The SQLite file holding hosts and groups. The parent directory is
|
||||
/// created if it does not exist yet.
|
||||
pub fn database_path() -> std::io::Result<PathBuf> {
|
||||
let dir = data_dir();
|
||||
|
||||
+16
-16
@@ -5,7 +5,7 @@ description: This skill should be used when the user asks to "connect to a serve
|
||||
|
||||
# ShellRS CLI
|
||||
|
||||
Use the `shellrs` CLI to list the user's saved SSH sessions, run one-off commands, and transfer files through the user's running ShellRS desktop app. ShellRS holds the credentials and connects with the saved configuration.
|
||||
Use the `shellrs` CLI to list the user's saved SSH hosts, run one-off commands, and transfer files through the user's running ShellRS desktop app. ShellRS holds the credentials and connects with the saved configuration.
|
||||
|
||||
Prefer `shellrs` over running `ssh`, `scp`, or `sftp` directly.
|
||||
Run `shellrs --help` or `shellrs <command> --help` for details. `list` prints JSON when piped or given `--json`.
|
||||
@@ -14,18 +14,18 @@ Run `shellrs --help` or `shellrs <command> --help` for details. `list` prints JS
|
||||
|
||||
```bash
|
||||
shellrs list --query <keyword> --json
|
||||
shellrs exec <session-id> "<command>"
|
||||
shellrs upload <session-id> <local-path> <remote-path>
|
||||
shellrs download <session-id> <remote-path> <local-path>
|
||||
shellrs exec <host-id> "<command>"
|
||||
shellrs upload <host-id> <local-path> <remote-path>
|
||||
shellrs download <host-id> <remote-path> <local-path>
|
||||
```
|
||||
|
||||
`<session-id>` is the 16-character `id` from `shellrs list` (the same ID that ShellRS copies with 复制 ID).
|
||||
`<host-id>` is the 16-character `id` from `shellrs list` (the same ID that ShellRS copies with 复制 ID).
|
||||
|
||||
For a simple command, pass one complete remote shell command string as `<command>`.
|
||||
Pass a command through stdin when it contains quotes, pipes, redirects, JSON, `$`, backticks, or nested shell code, and quote the heredoc delimiter so the local shell leaves it alone:
|
||||
|
||||
```bash
|
||||
shellrs exec <session-id> --stdin <<'EOF'
|
||||
shellrs exec <host-id> --stdin <<'EOF'
|
||||
<command>
|
||||
EOF
|
||||
```
|
||||
@@ -33,10 +33,10 @@ EOF
|
||||
## File Transfer
|
||||
|
||||
```bash
|
||||
shellrs upload <session-id> ./dist /tmp/dist
|
||||
shellrs upload <session-id> ./app.tar.gz /tmp/release.tar.gz
|
||||
shellrs download <session-id> /var/log/nginx ./logs/nginx
|
||||
shellrs download <session-id> /tmp/app.tar.gz ./latest.tar.gz
|
||||
shellrs upload <host-id> ./dist /tmp/dist
|
||||
shellrs upload <host-id> ./app.tar.gz /tmp/release.tar.gz
|
||||
shellrs download <host-id> /var/log/nginx ./logs/nginx
|
||||
shellrs download <host-id> /tmp/app.tar.gz ./latest.tar.gz
|
||||
```
|
||||
|
||||
## Windows
|
||||
@@ -48,10 +48,10 @@ In PowerShell, pass multi-line commands and commands containing quotes through s
|
||||
```powershell
|
||||
@'
|
||||
<command>
|
||||
'@ | shellrs exec <session-id> --stdin
|
||||
'@ | shellrs exec <host-id> --stdin
|
||||
```
|
||||
|
||||
Before running `shellrs` in PowerShell, make it read and write UTF-8, or non-ASCII text (such as Chinese session names and remote output) turns into `?` or mojibake:
|
||||
Before running `shellrs` in PowerShell, make it read and write UTF-8, or non-ASCII text (such as the Chinese names of saved hosts and remote output) turns into `?` or mojibake:
|
||||
|
||||
```powershell
|
||||
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
|
||||
@@ -62,10 +62,10 @@ The exit code is in `$LASTEXITCODE`.
|
||||
## Rules
|
||||
|
||||
- Do not ask the user for SSH passwords, private keys, or passphrases. ShellRS holds those credentials.
|
||||
- `shellrs exec` takes a session id and one complete remote shell command string, opens a temporary SSH connection, runs the command, and closes the connection. Its stdout and stderr are the remote command's, and its exit code is the remote command's exit code.
|
||||
- `shellrs exec` takes a host id and one complete remote shell command string, opens a temporary SSH connection, runs the command, and closes the connection. Its stdout and stderr are the remote command's, and its exit code is the remote command's exit code.
|
||||
- The remote command gets no stdin, so interactive programs (editors, pagers, password prompts) will not work. Use non-interactive flags such as `--yes`, `-y`, or `--no-pager`.
|
||||
- Long-running tasks should use tmux, nohup, or systemd on the remote host.
|
||||
- `shellrs upload` takes a session id, a local file or folder path, and a remote destination path. `shellrs download` takes a session id, a remote file or folder path, and a local destination path. Folders are copied recursively, and existing files are overwritten.
|
||||
- `shellrs upload` takes a host id, a local file or folder path, and a remote destination path. `shellrs download` takes a host id, a remote file or folder path, and a local destination path. Folders are copied recursively, and existing files are overwritten.
|
||||
- Destination paths follow scp: if the destination exists and is a directory, the source keeps its name inside that directory; otherwise the destination path is the final file or folder path, and its parent directory must exist.
|
||||
|
||||
## Errors
|
||||
@@ -74,7 +74,7 @@ Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. E
|
||||
|
||||
- `not_running`: ShellRS is not open. Ask the user to open ShellRS.
|
||||
- `not_enabled`: ask the user to turn on 启用外部 CLI in ShellRS under 设置 → 外部 CLI.
|
||||
- `session_not_found`: run `shellrs list` again and use an `id` from its output.
|
||||
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the session once in ShellRS and save the password.
|
||||
- `host_not_found`: run `shellrs list` again and use an `id` from its output.
|
||||
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password.
|
||||
- `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it.
|
||||
- `connect_failed` saying there is no permission to connect to ShellRS: the agent's sandbox blocks local inter-process communication. Ask the user to allow it, or run the command outside the sandbox.
|
||||
+15
-16
@@ -11,10 +11,10 @@ use std::{
|
||||
use unicode_width::UnicodeWidthStr as _;
|
||||
|
||||
use super::protocol::{
|
||||
Envelope, ErrorCode, FrameKind, PROTOCOL_VERSION, Reply, Request, SessionInfo,
|
||||
TransferCounters, TransferSummary, parse_json, read_frame, write_json,
|
||||
Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request, TransferCounters,
|
||||
TransferSummary, parse_json, read_frame, write_json,
|
||||
};
|
||||
use crate::session::HostOs;
|
||||
use crate::host::HostOs;
|
||||
|
||||
/// Exit code when the command could not do what was asked at all, as ssh
|
||||
/// uses it.
|
||||
@@ -193,8 +193,8 @@ where
|
||||
progress.show(console.stderr, &progress_line(counters));
|
||||
}
|
||||
}
|
||||
Reply::Sessions { sessions } => {
|
||||
print_sessions(&sessions, console)?;
|
||||
Reply::Hosts { hosts } => {
|
||||
print_hosts(&hosts, console)?;
|
||||
return Ok(0);
|
||||
}
|
||||
Reply::TransferDone(summary) => return Ok(print_summary(&summary, console)?),
|
||||
@@ -238,24 +238,23 @@ impl ProgressLine {
|
||||
}
|
||||
}
|
||||
|
||||
fn print_sessions(sessions: &[SessionInfo], console: &mut Console) -> io::Result<()> {
|
||||
fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
|
||||
if console.json {
|
||||
serde_json::to_writer_pretty(&mut *console.stdout, sessions).map_err(io::Error::other)?;
|
||||
serde_json::to_writer_pretty(&mut *console.stdout, hosts).map_err(io::Error::other)?;
|
||||
return writeln!(console.stdout);
|
||||
}
|
||||
if sessions.is_empty() {
|
||||
if hosts.is_empty() {
|
||||
return writeln!(console.stderr, "没有匹配的主机");
|
||||
}
|
||||
let rows: Vec<[String; 5]> = sessions
|
||||
let rows: Vec<[String; 5]> = hosts
|
||||
.iter()
|
||||
.map(|session| {
|
||||
.map(|host| {
|
||||
[
|
||||
session.id.clone(),
|
||||
session.name.clone(),
|
||||
session.group.clone().unwrap_or_default(),
|
||||
session.address(),
|
||||
session
|
||||
.os
|
||||
host.id.clone(),
|
||||
host.name.clone(),
|
||||
host.group.clone().unwrap_or_default(),
|
||||
host.address(),
|
||||
host.os
|
||||
.as_deref()
|
||||
.and_then(HostOs::from_stored)
|
||||
.map(|os| os.label().to_string())
|
||||
|
||||
+15
-18
@@ -1,5 +1,5 @@
|
||||
//! The external CLI: the `shellrs` command AI agents use to run commands
|
||||
//! and move files on the user's saved sessions. The command holds no
|
||||
//! and move files on the user's saved hosts. The command holds no
|
||||
//! credentials and opens no database: it hands each request to the running
|
||||
//! app over a local socket, and the app connects with what it has saved.
|
||||
|
||||
@@ -32,17 +32,17 @@ pub use install::{
|
||||
update_outdated_binary, update_outdated_skills,
|
||||
};
|
||||
pub use integration::{CliIntegration, IntegrationStatus};
|
||||
pub use protocol::{CliError, ErrorCode, Request, SessionInfo, TransferCounters, TransferSummary};
|
||||
pub use protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
|
||||
pub use server::{CliBackend, CliServer, CliTarget};
|
||||
|
||||
const AFTER_HELP: &str = "\
|
||||
Sessions are named by the 16-character ID that `shellrs list` prints (the one ShellRS copies with 复制 ID).
|
||||
Hosts are named by the 16-character ID that `shellrs list` prints (the one ShellRS copies with 复制 ID).
|
||||
ShellRS must be running, with 设置 → 外部 CLI → 启用外部 CLI turned on.
|
||||
|
||||
Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;
|
||||
255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).";
|
||||
|
||||
/// Run commands and move files on the SSH sessions saved in ShellRS.
|
||||
/// Run commands and move files on the SSH hosts saved in ShellRS.
|
||||
///
|
||||
/// ShellRS holds the passwords and keys and makes the connections; this
|
||||
/// command never sees them.
|
||||
@@ -55,22 +55,22 @@ struct Cli {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// List the saved sessions.
|
||||
/// List the saved hosts.
|
||||
List {
|
||||
/// Only sessions whose name, host, user, group or ID matches.
|
||||
/// Only hosts whose name, host, user, group or ID matches.
|
||||
#[arg(short, long)]
|
||||
query: Option<String>,
|
||||
/// Print JSON. Implied when stdout is not a terminal.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Run one command on a session's host and print its output.
|
||||
/// Run one command on a saved host and print its output.
|
||||
///
|
||||
/// Opens a temporary connection, runs the command in the login shell,
|
||||
/// and closes it. The command gets no stdin. The exit code is the
|
||||
/// remote command's.
|
||||
Exec {
|
||||
/// Session ID, from `shellrs list`.
|
||||
/// Host ID, from `shellrs list`.
|
||||
id: String,
|
||||
/// One complete remote shell command, quoted as one argument.
|
||||
#[arg(required_unless_present = "stdin")]
|
||||
@@ -80,14 +80,14 @@ enum Command {
|
||||
#[arg(long, conflicts_with = "command")]
|
||||
stdin: bool,
|
||||
},
|
||||
/// Copy a local file or folder to a session's host.
|
||||
/// Copy a local file or folder to a saved host.
|
||||
///
|
||||
/// Like scp: when the destination is an existing directory the source
|
||||
/// goes inside it under its own name; otherwise the destination is the
|
||||
/// copy's path. Folders are copied recursively; existing files are
|
||||
/// overwritten.
|
||||
Upload {
|
||||
/// Session ID, from `shellrs list`.
|
||||
/// Host ID, from `shellrs list`.
|
||||
id: String,
|
||||
/// Local file or folder.
|
||||
local: PathBuf,
|
||||
@@ -97,11 +97,11 @@ enum Command {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Copy a file or folder from a session's host to this machine.
|
||||
/// Copy a file or folder from a saved host to this machine.
|
||||
///
|
||||
/// Destination rules are scp's, as for `upload`.
|
||||
Download {
|
||||
/// Session ID, from `shellrs list`.
|
||||
/// Host ID, from `shellrs list`.
|
||||
id: String,
|
||||
/// Remote file or folder. `~` is the login directory.
|
||||
remote: String,
|
||||
@@ -167,10 +167,7 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
|
||||
if command.trim().is_empty() {
|
||||
return Err(console.error(ErrorCode::BadRequest, "命令不能为空"));
|
||||
}
|
||||
Request::Exec {
|
||||
session: id,
|
||||
command,
|
||||
}
|
||||
Request::Exec { host: id, command }
|
||||
}
|
||||
Command::Upload {
|
||||
id,
|
||||
@@ -180,7 +177,7 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
|
||||
} => {
|
||||
console.json |= json;
|
||||
Request::Upload {
|
||||
session: id,
|
||||
host: id,
|
||||
source: absolute(local, console)?,
|
||||
destination: remote,
|
||||
}
|
||||
@@ -193,7 +190,7 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
|
||||
} => {
|
||||
console.json |= json;
|
||||
Request::Download {
|
||||
session: id,
|
||||
host: id,
|
||||
source: remote,
|
||||
destination: absolute(local, console)?,
|
||||
}
|
||||
|
||||
+14
-14
@@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
/// Bumped when a request or reply changes shape. The command and the app
|
||||
/// come from the same build, so they differ only while an older copy still
|
||||
/// runs, or on Windows while the copy on the PATH has not been updated.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
pub const PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
/// Frames larger than this are refused, so a confused peer cannot make the
|
||||
/// other side allocate without bound.
|
||||
@@ -109,18 +109,18 @@ pub enum Request {
|
||||
query: Option<String>,
|
||||
},
|
||||
Exec {
|
||||
session: String,
|
||||
host: String,
|
||||
command: String,
|
||||
},
|
||||
/// `source` is absolute: the app's working directory is not the
|
||||
/// caller's.
|
||||
Upload {
|
||||
session: String,
|
||||
host: String,
|
||||
source: PathBuf,
|
||||
destination: String,
|
||||
},
|
||||
Download {
|
||||
session: String,
|
||||
host: String,
|
||||
source: String,
|
||||
destination: PathBuf,
|
||||
},
|
||||
@@ -133,8 +133,8 @@ pub enum Request {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Reply {
|
||||
Sessions {
|
||||
sessions: Vec<SessionInfo>,
|
||||
Hosts {
|
||||
hosts: Vec<HostInfo>,
|
||||
},
|
||||
/// How far a transfer has come.
|
||||
Progress(TransferCounters),
|
||||
@@ -151,10 +151,10 @@ pub enum Reply {
|
||||
},
|
||||
}
|
||||
|
||||
/// A saved session as the CLI lists it.
|
||||
/// A saved host as the CLI lists it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
/// The session's public ID, the one 复制 ID copies.
|
||||
pub struct HostInfo {
|
||||
/// The host's public ID, the one 复制 ID copies.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The full group path, such as `生产/数据库`; `None` at the root.
|
||||
@@ -166,7 +166,7 @@ pub struct SessionInfo {
|
||||
pub os: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionInfo {
|
||||
impl HostInfo {
|
||||
pub fn address(&self) -> String {
|
||||
format!("{}@{}:{}", self.user, self.host, self.port)
|
||||
}
|
||||
@@ -200,7 +200,7 @@ pub enum ErrorCode {
|
||||
/// 启用外部 CLI is off.
|
||||
NotEnabled,
|
||||
VersionMismatch,
|
||||
SessionNotFound,
|
||||
HostNotFound,
|
||||
HostKeyUnknown,
|
||||
HostKeyChanged,
|
||||
MissingCredential,
|
||||
@@ -215,7 +215,7 @@ impl ErrorCode {
|
||||
ErrorCode::NotRunning => "not_running",
|
||||
ErrorCode::NotEnabled => "not_enabled",
|
||||
ErrorCode::VersionMismatch => "version_mismatch",
|
||||
ErrorCode::SessionNotFound => "session_not_found",
|
||||
ErrorCode::HostNotFound => "host_not_found",
|
||||
ErrorCode::HostKeyUnknown => "host_key_unknown",
|
||||
ErrorCode::HostKeyChanged => "host_key_changed",
|
||||
ErrorCode::MissingCredential => "missing_credential",
|
||||
@@ -254,7 +254,7 @@ mod tests {
|
||||
&Envelope {
|
||||
version: PROTOCOL_VERSION,
|
||||
request: Request::Exec {
|
||||
session: "VmLkf1snMOuPKJ07".into(),
|
||||
host: "VmLkf1snMOuPKJ07".into(),
|
||||
command: "uname -a".into(),
|
||||
},
|
||||
},
|
||||
@@ -269,7 +269,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
envelope.request,
|
||||
Request::Exec {
|
||||
session: "VmLkf1snMOuPKJ07".into(),
|
||||
host: "VmLkf1snMOuPKJ07".into(),
|
||||
command: "uname -a".into(),
|
||||
}
|
||||
);
|
||||
|
||||
+39
-39
@@ -16,16 +16,16 @@ use std::{
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::protocol::{
|
||||
CliError, Envelope, ErrorCode, FrameKind, PROTOCOL_VERSION, Reply, Request, SessionInfo,
|
||||
CliError, Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request,
|
||||
TransferCounters, TransferSummary, parse_json, read_frame, write_frame, write_json,
|
||||
};
|
||||
use crate::session::{Session, SessionLogin, SessionStore, matches_query};
|
||||
use crate::host::{Host, HostLogin, HostStore, matches_query};
|
||||
use crate::ssh::ExecStream;
|
||||
|
||||
/// Does what a CLI request asks. Every method blocks: each request has a
|
||||
/// thread of its own.
|
||||
pub trait CliBackend: Send + Sync + 'static {
|
||||
/// Run `command` on the session's host, passing its output on as it
|
||||
/// Run `command` on the host, passing its output on as it
|
||||
/// comes; the remote exit code. When `output` fails, the caller is gone
|
||||
/// and the command should be abandoned.
|
||||
fn exec(
|
||||
@@ -57,65 +57,65 @@ pub trait CliBackend: Send + Sync + 'static {
|
||||
) -> Result<TransferSummary, CliError>;
|
||||
}
|
||||
|
||||
/// A saved session as the CLI sees it, with the login it connects with
|
||||
/// A saved host as the CLI sees it, with the login it connects with
|
||||
/// resolved when the target list was built.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CliTarget {
|
||||
info: SessionInfo,
|
||||
session: Session,
|
||||
login: SessionLogin,
|
||||
info: HostInfo,
|
||||
host: Host,
|
||||
login: HostLogin,
|
||||
}
|
||||
|
||||
impl CliTarget {
|
||||
/// `group` is the session's full group path, if it has one.
|
||||
pub fn new(session: &Session, login: SessionLogin, group: Option<String>) -> Self {
|
||||
/// `group` is the host's full group path, if it has one.
|
||||
pub fn new(host: &Host, login: HostLogin, group: Option<String>) -> Self {
|
||||
Self {
|
||||
info: SessionInfo {
|
||||
id: session.public_id.to_string(),
|
||||
name: session.name.to_string(),
|
||||
info: HostInfo {
|
||||
id: host.public_id.to_string(),
|
||||
name: host.name.to_string(),
|
||||
group,
|
||||
user: session.user.to_string(),
|
||||
host: session.host.to_string(),
|
||||
port: session.port,
|
||||
os: session.os.map(|os| os.as_str().to_string()),
|
||||
user: host.user.to_string(),
|
||||
host: host.address.to_string(),
|
||||
port: host.port,
|
||||
os: host.os.map(|os| os.as_str().to_string()),
|
||||
},
|
||||
session: session.clone(),
|
||||
host: host.clone(),
|
||||
login,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session(&self) -> &Session {
|
||||
&self.session
|
||||
pub fn host(&self) -> &Host {
|
||||
&self.host
|
||||
}
|
||||
|
||||
/// How a request logs in to the session's host.
|
||||
pub fn login(&self) -> &SessionLogin {
|
||||
/// How a request logs in to the host.
|
||||
pub fn login(&self) -> &HostLogin {
|
||||
&self.login
|
||||
}
|
||||
|
||||
/// Every session in `store`, with its group path.
|
||||
pub fn all(store: &SessionStore) -> Vec<Self> {
|
||||
/// Every host in `store`, with its group path.
|
||||
pub fn all(store: &HostStore) -> Vec<Self> {
|
||||
store
|
||||
.sessions()
|
||||
.hosts()
|
||||
.iter()
|
||||
.map(|session| {
|
||||
let names = session
|
||||
.map(|host| {
|
||||
let names = host
|
||||
.group
|
||||
.map(|id| store.group_names(id))
|
||||
.unwrap_or_default();
|
||||
Self::new(
|
||||
session,
|
||||
store.login_of(session),
|
||||
host,
|
||||
store.login_of(host),
|
||||
(!names.is_empty()).then(|| names.join("/")),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The session tree's search, plus the group path and the ID.
|
||||
/// The host tree's search, plus the group path and the ID.
|
||||
fn matches(&self, query: &str) -> bool {
|
||||
let needle = query.trim().to_lowercase();
|
||||
matches_query(&self.session, query)
|
||||
matches_query(&self.host, query)
|
||||
|| self
|
||||
.info
|
||||
.group
|
||||
@@ -445,15 +445,15 @@ fn respond(
|
||||
.targets
|
||||
.read()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let sessions = targets
|
||||
let hosts = targets
|
||||
.iter()
|
||||
.filter(|target| query.as_deref().is_none_or(|query| target.matches(query)))
|
||||
.map(|target| target.info.clone())
|
||||
.collect();
|
||||
Ok(Reply::Sessions { sessions })
|
||||
Ok(Reply::Hosts { hosts })
|
||||
}
|
||||
Request::Exec { session, command } => {
|
||||
let target = find(shared, &session)?;
|
||||
Request::Exec { host, command } => {
|
||||
let target = find(shared, &host)?;
|
||||
let code = shared
|
||||
.backend
|
||||
.exec(&target, &command, &mut |stream, bytes| {
|
||||
@@ -466,11 +466,11 @@ fn respond(
|
||||
Ok(Reply::Exit { code })
|
||||
}
|
||||
Request::Upload {
|
||||
session,
|
||||
host,
|
||||
source,
|
||||
destination,
|
||||
} => {
|
||||
let target = find(shared, &session)?;
|
||||
let target = find(shared, &host)?;
|
||||
if !source.is_absolute() {
|
||||
return Err(not_absolute(&source));
|
||||
}
|
||||
@@ -482,11 +482,11 @@ fn respond(
|
||||
.map(Reply::TransferDone)
|
||||
}
|
||||
Request::Download {
|
||||
session,
|
||||
host,
|
||||
source,
|
||||
destination,
|
||||
} => {
|
||||
let target = find(shared, &session)?;
|
||||
let target = find(shared, &host)?;
|
||||
if !destination.is_absolute() {
|
||||
return Err(not_absolute(&destination));
|
||||
}
|
||||
@@ -512,7 +512,7 @@ fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CliError::new(
|
||||
ErrorCode::SessionNotFound,
|
||||
ErrorCode::HostNotFound,
|
||||
format!("没有 ID 为 {id} 的主机:请用 shellrs list 查看"),
|
||||
)
|
||||
})
|
||||
|
||||
+29
-34
@@ -10,14 +10,12 @@ use std::{
|
||||
use clap::Parser as _;
|
||||
|
||||
use super::client::{self, Console};
|
||||
use super::protocol::{
|
||||
CliError, ErrorCode, Request, SessionInfo, TransferCounters, TransferSummary,
|
||||
};
|
||||
use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
|
||||
use super::server::{CliBackend, CliServer, CliTarget};
|
||||
use super::{Cli, Command};
|
||||
use super::{ConsoleText, normalize_command};
|
||||
use crate::app::cli_endpoint;
|
||||
use crate::session::{AuthKind, GroupId, Session, SessionDraft, SessionId, SessionLogin};
|
||||
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin};
|
||||
use crate::ssh::ExecStream;
|
||||
|
||||
/// Records what it was asked and answers from a script.
|
||||
@@ -36,7 +34,7 @@ impl CliBackend for FakeBackend {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("exec {} {command}", target.session().name));
|
||||
.push(format!("exec {} {command}", target.host().name));
|
||||
if command == "unreachable" {
|
||||
return Err(CliError::new(ErrorCode::HostKeyUnknown, "尚未信任"));
|
||||
}
|
||||
@@ -54,7 +52,7 @@ impl CliBackend for FakeBackend {
|
||||
) -> Result<TransferSummary, CliError> {
|
||||
self.calls.lock().unwrap().push(format!(
|
||||
"upload {} {} {destination}",
|
||||
target.session().name,
|
||||
target.host().name,
|
||||
source.display()
|
||||
));
|
||||
progress(TransferCounters {
|
||||
@@ -84,10 +82,10 @@ impl CliBackend for FakeBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn session(id: u64, name: &str, host: &str) -> Session {
|
||||
Session::new(
|
||||
SessionId(id),
|
||||
SessionDraft::new(name, host, 22, "root", AuthKind::Password, Some(GroupId(1))),
|
||||
fn host(id: u64, name: &str, host: &str) -> Host {
|
||||
Host::new(
|
||||
HostId(id),
|
||||
HostDraft::new(name, host, 22, "root", AuthKind::Password, Some(GroupId(1))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +94,7 @@ struct Fixture {
|
||||
socket: PathBuf,
|
||||
server: CliServer,
|
||||
backend: Arc<FakeBackend>,
|
||||
web: Session,
|
||||
web: Host,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
@@ -104,11 +102,11 @@ fn fixture() -> Fixture {
|
||||
let socket = cli_endpoint(dir.path());
|
||||
let backend = Arc::new(FakeBackend::default());
|
||||
let server = CliServer::start(socket.clone(), backend.clone()).unwrap();
|
||||
let web = session(1, "web-01", "10.0.1.12");
|
||||
let db = session(2, "db-01", "10.0.2.5");
|
||||
let web = host(1, "web-01", "10.0.1.12");
|
||||
let db = host(2, "db-01", "10.0.2.5");
|
||||
server.set_targets(vec![
|
||||
CliTarget::new(&web, SessionLogin::of(&web, None), Some("生产".into())),
|
||||
CliTarget::new(&db, SessionLogin::of(&db, None), Some("生产/数据库".into())),
|
||||
CliTarget::new(&web, HostLogin::of(&web, None), Some("生产".into())),
|
||||
CliTarget::new(&db, HostLogin::of(&db, None), Some("生产/数据库".into())),
|
||||
]);
|
||||
server.set_enabled(true);
|
||||
Fixture {
|
||||
@@ -151,8 +149,8 @@ fn local(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(name)
|
||||
}
|
||||
|
||||
fn id(session: &Session) -> String {
|
||||
session.public_id.to_string()
|
||||
fn id(host: &Host) -> String {
|
||||
host.public_id.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -167,11 +165,8 @@ fn listing_filters_by_name_host_group_or_id() {
|
||||
true,
|
||||
);
|
||||
assert_eq!(code, 0);
|
||||
let sessions: Vec<SessionInfo> = serde_json::from_str(&stdout).unwrap();
|
||||
sessions
|
||||
.into_iter()
|
||||
.map(|session| session.name)
|
||||
.collect::<Vec<_>>()
|
||||
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
|
||||
hosts.into_iter().map(|host| host.name).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(list(None), ["web-01", "db-01"]);
|
||||
assert_eq!(list(Some("WEB")), ["web-01"]);
|
||||
@@ -181,10 +176,10 @@ fn listing_filters_by_name_host_group_or_id() {
|
||||
assert!(list(Some("nothing")).is_empty());
|
||||
|
||||
let (_, stdout, _) = run(&fixture.socket, Request::List { query: None }, true);
|
||||
let sessions: Vec<SessionInfo> = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(sessions[0].id, id(&fixture.web));
|
||||
assert_eq!(sessions[0].group.as_deref(), Some("生产"));
|
||||
assert_eq!(sessions[0].address(), "root@10.0.1.12:22");
|
||||
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(hosts[0].id, id(&fixture.web));
|
||||
assert_eq!(hosts[0].group.as_deref(), Some("生产"));
|
||||
assert_eq!(hosts[0].address(), "root@10.0.1.12:22");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -210,7 +205,7 @@ fn exec_passes_output_through_as_bytes_and_exits_with_the_remote_code() {
|
||||
let (code, stdout, stderr) = run(
|
||||
&fixture.socket,
|
||||
Request::Exec {
|
||||
session: id(&fixture.web),
|
||||
host: id(&fixture.web),
|
||||
command: "uname -a".into(),
|
||||
},
|
||||
false,
|
||||
@@ -230,7 +225,7 @@ fn failures_carry_their_code_and_exit_255() {
|
||||
let (code, _, stderr) = run(
|
||||
&fixture.socket,
|
||||
Request::Exec {
|
||||
session: id(&fixture.web),
|
||||
host: id(&fixture.web),
|
||||
command: "unreachable".into(),
|
||||
},
|
||||
false,
|
||||
@@ -245,7 +240,7 @@ fn failures_carry_their_code_and_exit_255() {
|
||||
let (code, _, stderr) = run(
|
||||
&fixture.socket,
|
||||
Request::Exec {
|
||||
session: "nobody".into(),
|
||||
host: "nobody".into(),
|
||||
command: "true".into(),
|
||||
},
|
||||
false,
|
||||
@@ -254,14 +249,14 @@ fn failures_carry_their_code_and_exit_255() {
|
||||
assert!(
|
||||
String::from_utf8(stderr)
|
||||
.unwrap()
|
||||
.contains("[session_not_found]")
|
||||
.contains("[host_not_found]")
|
||||
);
|
||||
|
||||
// A path relative to the command is meaningless to the app.
|
||||
let (code, _, stderr) = run(
|
||||
&fixture.socket,
|
||||
Request::Upload {
|
||||
session: id(&fixture.web),
|
||||
host: id(&fixture.web),
|
||||
source: "relative/file".into(),
|
||||
destination: "/tmp".into(),
|
||||
},
|
||||
@@ -283,7 +278,7 @@ fn with_the_switch_off_nothing_reaches_the_backend() {
|
||||
let (code, stdout, stderr) = run(
|
||||
&fixture.socket,
|
||||
Request::Exec {
|
||||
session: id(&fixture.web),
|
||||
host: id(&fixture.web),
|
||||
command: "true".into(),
|
||||
},
|
||||
false,
|
||||
@@ -370,7 +365,7 @@ fn a_transfer_reports_its_summary_and_exits_1_when_items_failed() {
|
||||
let (code, stdout, _) = run(
|
||||
&fixture.socket,
|
||||
Request::Upload {
|
||||
session: id(&fixture.web),
|
||||
host: id(&fixture.web),
|
||||
source: local("dist"),
|
||||
destination: "~/dist".into(),
|
||||
},
|
||||
@@ -392,7 +387,7 @@ fn progress_is_rewritten_in_place_without_escape_sequences() {
|
||||
let (code, _, stderr) = run_on(
|
||||
&fixture.socket,
|
||||
Request::Upload {
|
||||
session: id(&fixture.web),
|
||||
host: id(&fixture.web),
|
||||
source: local("dist"),
|
||||
destination: "~/dist".into(),
|
||||
},
|
||||
|
||||
+6
-6
@@ -252,10 +252,10 @@ impl std::fmt::Debug for ConnectionPromptReply {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a connection test logs in with: the session form's current values,
|
||||
/// What a connection test logs in with: the host form's current values,
|
||||
/// saved or not. Secrets stay zeroized and never print.
|
||||
pub struct LoginTest {
|
||||
login: crate::session::SessionLogin,
|
||||
login: crate::host::HostLogin,
|
||||
/// The secrets typed into the form, for a login typed there. `None` for
|
||||
/// a login through a credential, which uses what the credential saved.
|
||||
typed: Option<TypedSecrets>,
|
||||
@@ -273,7 +273,7 @@ struct TypedSecrets {
|
||||
impl LoginTest {
|
||||
/// A login typed into the form. The form decides its password and its
|
||||
/// key's passphrase: an empty field means none, whatever is saved.
|
||||
pub fn typed(login: crate::session::SessionLogin) -> Self {
|
||||
pub fn typed(login: crate::host::HostLogin) -> Self {
|
||||
Self {
|
||||
login,
|
||||
typed: Some(TypedSecrets::default()),
|
||||
@@ -282,7 +282,7 @@ impl LoginTest {
|
||||
}
|
||||
|
||||
/// A login through a saved credential, with the secrets saved for it.
|
||||
pub fn saved(login: crate::session::SessionLogin) -> Self {
|
||||
pub fn saved(login: crate::host::HostLogin) -> Self {
|
||||
Self {
|
||||
login,
|
||||
typed: None,
|
||||
@@ -313,7 +313,7 @@ impl LoginTest {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn login(&self) -> &crate::session::SessionLogin {
|
||||
pub fn login(&self) -> &crate::host::HostLogin {
|
||||
&self.login
|
||||
}
|
||||
pub fn host(&self) -> &str {
|
||||
@@ -365,7 +365,7 @@ impl std::fmt::Debug for LoginTest {
|
||||
/// until a person decides; `false` also covers "nobody is there to ask".
|
||||
pub type TrustCallback = Box<dyn Fn(UnknownHostPrompt) -> bool + Send + Sync>;
|
||||
|
||||
/// Logs in once with a [`LoginTest`] and hangs up, so the session form can
|
||||
/// Logs in once with a [`LoginTest`] and hangs up, so the host form can
|
||||
/// check its values before saving them.
|
||||
pub trait ConnectionTester: Send + Sync + 'static {
|
||||
/// Blocks: call it on a thread of its own. `Err` holds the reason, worded
|
||||
|
||||
@@ -13,11 +13,11 @@ use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::session::{
|
||||
CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, GeneratedKey, KeyAlgorithm,
|
||||
PastedKey, PastedKeyError, SecretFields, SessionStore, read_public_key,
|
||||
use crate::host::{
|
||||
CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, GeneratedKey, HostStore,
|
||||
KeyAlgorithm, PastedKey, PastedKeyError, SecretFields, read_public_key,
|
||||
};
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::shared::{Segment, SegmentedControl, commit_footer, form_error};
|
||||
|
||||
/// Where the SSH agent is found, as the form explains it.
|
||||
@@ -81,7 +81,7 @@ enum PublicKeyView {
|
||||
|
||||
/// The body of the new/edit credential dialog.
|
||||
pub struct CredentialForm {
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
editing: Option<CredentialId>,
|
||||
kind: CredentialKind,
|
||||
name: Entity<InputState>,
|
||||
@@ -119,7 +119,7 @@ pub struct CredentialForm {
|
||||
impl CredentialForm {
|
||||
pub fn new(
|
||||
dialog: CredentialDialog,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -131,11 +131,11 @@ impl CredentialForm {
|
||||
let read = store.read(cx);
|
||||
let credential = editing.and_then(|id| read.credential(id)).cloned();
|
||||
let using: Vec<_> = editing
|
||||
.map(|id| read.sessions_using(id).collect())
|
||||
.map(|id| read.hosts_using(id).collect())
|
||||
.unwrap_or_default();
|
||||
let connected = using
|
||||
.iter()
|
||||
.filter(|session| session.state.is_connected())
|
||||
.filter(|host| host.state.is_connected())
|
||||
.count();
|
||||
let kept_key = credential
|
||||
.as_ref()
|
||||
@@ -849,7 +849,7 @@ impl Render for CredentialForm {
|
||||
/// the edit dialog of an existing one.
|
||||
pub fn open_credential_dialog(
|
||||
dialog: CredentialDialog,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
|
||||
@@ -19,15 +19,15 @@ use crate::app::{
|
||||
EditCredential, EditSelectedCredential, GenerateCredentialKey, NewCredential,
|
||||
SelectNextCredential, SelectPreviousCredential,
|
||||
};
|
||||
use crate::session::{CredentialId, CredentialKind, SessionStore, matches_credential_query};
|
||||
use crate::host::{CredentialId, CredentialKind, HostStore, matches_credential_query};
|
||||
|
||||
/// The credential list the left dock shows in place of the sessions: every
|
||||
/// The credential list the left dock shows in place of the hosts: every
|
||||
/// saved login and how many hosts use it.
|
||||
///
|
||||
/// The credentials live in the shared `SessionStore`; this panel observes it
|
||||
/// The credentials live in the shared `HostStore`; this panel observes it
|
||||
/// and owns only its selection and search.
|
||||
pub struct CredentialPanel {
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
/// The workspace's focus handle: actions dispatched on it reach the
|
||||
/// workspace handlers whatever is focused, which after a dialog closes
|
||||
/// may be nothing at all.
|
||||
@@ -60,7 +60,7 @@ struct CredentialRow {
|
||||
|
||||
impl CredentialPanel {
|
||||
pub fn new(
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
target: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -156,7 +156,7 @@ impl CredentialPanel {
|
||||
.iter()
|
||||
.filter(|credential| matches_credential_query(credential, &self.query))
|
||||
.map(|credential| {
|
||||
let hosts = store.sessions_using(credential.id).count();
|
||||
let hosts = store.hosts_using(credential.id).count();
|
||||
let summary = credential.summary(store.key_dir());
|
||||
let detail = match hosts {
|
||||
0 => summary,
|
||||
@@ -293,7 +293,7 @@ impl CredentialPanel {
|
||||
.confirmed(selected)
|
||||
.aria_selected(selected)
|
||||
.when(selected, |item| {
|
||||
item.bg(crate::app::session_tree_selection_color(theme))
|
||||
item.bg(crate::app::host_tree_selection_color(theme))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -435,7 +435,7 @@ impl Render for CredentialPanel {
|
||||
)
|
||||
.child(
|
||||
// The menu hangs off the list, not off the rows, like the
|
||||
// session tree's and the forward list's.
|
||||
// host tree's and the forward list's.
|
||||
div()
|
||||
.id("credential-list")
|
||||
.test_support()
|
||||
|
||||
@@ -1,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;
|
||||
|
||||
@@ -5,11 +5,11 @@ use super::{
|
||||
use crate::app::ExplorerDispatch as _;
|
||||
use crate::{
|
||||
app::{
|
||||
CatalogIcon, CenterTab, CloseExplorer, CopySessionHost, ExplorerAction, ExplorerCommand,
|
||||
CatalogIcon, CenterTab, CloseExplorer, CopyHostAddress, ExplorerAction, ExplorerCommand,
|
||||
OpenExplorer, RenameExplorer,
|
||||
},
|
||||
connection::{ConnectionPrompt, ConnectionPromptReply},
|
||||
session::{BookmarkSide, ConnectionState, SessionId, SessionStore},
|
||||
host::{BookmarkSide, ConnectionState, HostId, HostStore},
|
||||
sftp::{
|
||||
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider,
|
||||
SharedSftpTransportProvider, TransferDirection, TransferPhase, TransferQuestion,
|
||||
@@ -37,19 +37,19 @@ static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ExplorerPanelEvent {
|
||||
Activated(ExplorerId),
|
||||
Closed(ExplorerId, SessionId),
|
||||
StateChanged(ExplorerId, SessionId),
|
||||
Closed(ExplorerId, HostId),
|
||||
StateChanged(ExplorerId, HostId),
|
||||
/// What the window's status line says for this tab changed without the
|
||||
/// connection changing: a directory could not be read, or can again.
|
||||
StatusChanged(ExplorerId),
|
||||
PromptRequested(ExplorerId, SessionId, u64, ConnectionPrompt),
|
||||
PromptRequested(ExplorerId, HostId, u64, ConnectionPrompt),
|
||||
}
|
||||
pub struct ExplorerPanel {
|
||||
id: ExplorerId,
|
||||
session_id: SessionId,
|
||||
host_id: HostId,
|
||||
generation: u64,
|
||||
endpoint: String,
|
||||
pub(super) store: Entity<SessionStore>,
|
||||
pub(super) store: Entity<HostStore>,
|
||||
local: Entity<FilePane>,
|
||||
pub(super) remote: Entity<FilePane>,
|
||||
pub(super) local_provider: SharedLocalDirectoryProvider,
|
||||
@@ -89,8 +89,8 @@ impl ExplorerPanel {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: ExplorerId,
|
||||
session_id: SessionId,
|
||||
store: Entity<SessionStore>,
|
||||
host_id: HostId,
|
||||
store: Entity<HostStore>,
|
||||
provider: SharedSftpTransportProvider,
|
||||
local_provider: SharedLocalDirectoryProvider,
|
||||
dispatch: FocusHandle,
|
||||
@@ -99,8 +99,8 @@ impl ExplorerPanel {
|
||||
) -> Self {
|
||||
let login = store
|
||||
.read(cx)
|
||||
.login(session_id)
|
||||
.expect("workspace checked session");
|
||||
.login(host_id)
|
||||
.expect("workspace checked host");
|
||||
let endpoint = login.endpoint();
|
||||
let home = local_provider.home().to_string_lossy().into_owned();
|
||||
let places = local_provider
|
||||
@@ -112,7 +112,7 @@ impl ExplorerPanel {
|
||||
FilePane::new(
|
||||
PaneSide::Local,
|
||||
id,
|
||||
session_id,
|
||||
host_id,
|
||||
home.clone(),
|
||||
places,
|
||||
store.clone(),
|
||||
@@ -125,7 +125,7 @@ impl ExplorerPanel {
|
||||
FilePane::new(
|
||||
PaneSide::Remote,
|
||||
id,
|
||||
session_id,
|
||||
host_id,
|
||||
String::new(),
|
||||
Vec::new(),
|
||||
store.clone(),
|
||||
@@ -200,7 +200,7 @@ impl ExplorerPanel {
|
||||
];
|
||||
Self {
|
||||
id,
|
||||
session_id,
|
||||
host_id,
|
||||
generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed),
|
||||
endpoint,
|
||||
store,
|
||||
@@ -230,8 +230,8 @@ impl ExplorerPanel {
|
||||
pub fn id(&self) -> ExplorerId {
|
||||
self.id
|
||||
}
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
pub fn host_id(&self) -> HostId {
|
||||
self.host_id
|
||||
}
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
@@ -283,8 +283,8 @@ impl ExplorerPanel {
|
||||
let name = self
|
||||
.store
|
||||
.read(cx)
|
||||
.session(self.session_id)
|
||||
.map_or_else(|| "服务器".into(), |session| session.name.clone());
|
||||
.host(self.host_id)
|
||||
.map_or_else(|| "服务器".into(), |host| host.name.clone());
|
||||
let reason = self
|
||||
.remote
|
||||
.read(cx)
|
||||
@@ -405,7 +405,7 @@ impl ExplorerPanel {
|
||||
.update(cx, |pane, cx| pane.disconnected("SFTP 已断开".into(), cx));
|
||||
self.close_transfer_dialog(window, cx);
|
||||
self.sync_available(cx);
|
||||
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.session_id));
|
||||
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id));
|
||||
cx.notify();
|
||||
}
|
||||
/// Close this tab's transfer confirmation or question, if one is open.
|
||||
@@ -445,25 +445,25 @@ impl ExplorerPanel {
|
||||
SftpEvent::Connecting => {
|
||||
self.generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
self.state = ConnectionState::Connecting;
|
||||
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.session_id));
|
||||
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id));
|
||||
}
|
||||
SftpEvent::Connected { home } => {
|
||||
self.state = ConnectionState::Connected;
|
||||
self.remote
|
||||
.update(cx, |pane, _| pane.set_home(home.to_string()));
|
||||
self.reload(true, window, cx);
|
||||
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.session_id));
|
||||
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id));
|
||||
}
|
||||
SftpEvent::Disconnected(message) => {
|
||||
self.state = ConnectionState::Disconnected;
|
||||
self.abandon_remote_operations(cx);
|
||||
self.remote
|
||||
.update(cx, |pane, cx| pane.disconnected(message, cx));
|
||||
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.session_id));
|
||||
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id));
|
||||
}
|
||||
SftpEvent::Prompt(prompt) => cx.emit(ExplorerPanelEvent::PromptRequested(
|
||||
self.id,
|
||||
self.session_id,
|
||||
self.host_id,
|
||||
self.generation,
|
||||
prompt,
|
||||
)),
|
||||
@@ -613,17 +613,17 @@ impl ExplorerPanel {
|
||||
let path = path
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.pane(*remote).read(cx).path());
|
||||
let (id, side) = (self.session_id, BookmarkSide::from_remote(*remote));
|
||||
let (id, side) = (self.host_id, BookmarkSide::from_remote(*remote));
|
||||
self.store
|
||||
.update(cx, |store, cx| store.add_bookmark(id, side, &path, cx));
|
||||
}
|
||||
ExplorerCommand::RemoveBookmark { remote, path } => {
|
||||
let (id, side) = (self.session_id, BookmarkSide::from_remote(*remote));
|
||||
let (id, side) = (self.host_id, BookmarkSide::from_remote(*remote));
|
||||
self.store
|
||||
.update(cx, |store, cx| store.remove_bookmark(id, side, path, cx));
|
||||
}
|
||||
ExplorerCommand::MoveBookmark { remote, path, to } => {
|
||||
let (id, side) = (self.session_id, BookmarkSide::from_remote(*remote));
|
||||
let (id, side) = (self.host_id, BookmarkSide::from_remote(*remote));
|
||||
self.store
|
||||
.update(cx, |store, cx| store.move_bookmark(id, side, path, *to, cx));
|
||||
}
|
||||
@@ -806,9 +806,9 @@ impl BasePanel for ExplorerPanel {
|
||||
}
|
||||
fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if active {
|
||||
let session_id = self.session_id;
|
||||
let host_id = self.host_id;
|
||||
self.store
|
||||
.update(cx, |store, cx| store.set_active(Some(session_id), cx));
|
||||
.update(cx, |store, cx| store.set_active(Some(host_id), cx));
|
||||
// Focus a file list, not the panel root, so list shortcuts work
|
||||
// without a click first. Both stay inside this panel.
|
||||
let focus = self.pane(self.last_remote).read(cx).focus_handle(cx);
|
||||
@@ -823,19 +823,19 @@ impl BasePanel for ExplorerPanel {
|
||||
self.disconnect(window, cx);
|
||||
self.send(SftpCommand::Shutdown);
|
||||
self.tab_group = None;
|
||||
cx.emit(ExplorerPanelEvent::Closed(self.id, self.session_id));
|
||||
cx.emit(ExplorerPanelEvent::Closed(self.id, self.host_id));
|
||||
}
|
||||
}
|
||||
impl Panel for ExplorerPanel {
|
||||
fn title(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let session = self.store.read(cx).session(self.session_id);
|
||||
let name = session
|
||||
let host = self.store.read(cx).host(self.host_id);
|
||||
let name = host
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_else(|| "SFTP".into());
|
||||
let os = session.and_then(|s| s.os);
|
||||
let os = host.and_then(|s| s.os);
|
||||
let id = self.id;
|
||||
let menu = self.tab_menu(cx);
|
||||
// The host's mark, as on the session's terminal tabs.
|
||||
// The host's mark, as on the host's terminal tabs.
|
||||
let mark = HostMark::new(("explorer-tab-os", id.0), name, os).small();
|
||||
ClosableTabTitle::new(("explorer-tab", id.0), mark, self.tab_title(cx))
|
||||
.closable(("close-explorer", id.0), Box::new(CloseExplorer(id)))
|
||||
@@ -851,14 +851,14 @@ impl Panel for ExplorerPanel {
|
||||
}
|
||||
/// A terminal tab's buttons that apply here too.
|
||||
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
|
||||
let (id, session_id) = (self.id, self.session_id);
|
||||
let (id, host_id) = (self.id, self.host_id);
|
||||
Some(vec![
|
||||
Button::new(("open-sftp", id.0))
|
||||
.icon(Icon::new(CatalogIcon::FolderTree))
|
||||
.label("SFTP")
|
||||
.tooltip("打开 SFTP 文件浏览")
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(Box::new(OpenExplorer(session_id)), cx)
|
||||
window.dispatch_action(Box::new(OpenExplorer(host_id)), cx)
|
||||
}),
|
||||
Button::new(("reconnect-sftp", id.0))
|
||||
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||
@@ -881,7 +881,7 @@ impl RenamableTab for ExplorerPanel {
|
||||
let name = self
|
||||
.store
|
||||
.read(cx)
|
||||
.session(self.session_id)
|
||||
.host(self.host_id)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_else(|| "SFTP".into());
|
||||
format!("{name} · SFTP").into()
|
||||
@@ -958,7 +958,7 @@ pub struct ExplorerStatus {
|
||||
#[derive(Clone)]
|
||||
struct TabMenu {
|
||||
id: ExplorerId,
|
||||
session_id: SessionId,
|
||||
host_id: HostId,
|
||||
host_is_ip: bool,
|
||||
can_reconnect: bool,
|
||||
group: Option<WeakEntity<TabGroup>>,
|
||||
@@ -969,12 +969,12 @@ impl ExplorerPanel {
|
||||
fn tab_menu(&self, cx: &Context<Self>) -> TabMenu {
|
||||
TabMenu {
|
||||
id: self.id,
|
||||
session_id: self.session_id,
|
||||
host_id: self.host_id,
|
||||
host_is_ip: self
|
||||
.store
|
||||
.read(cx)
|
||||
.session(self.session_id)
|
||||
.is_some_and(|session| session.host_is_ip()),
|
||||
.host(self.host_id)
|
||||
.is_some_and(|host| host.address_is_ip()),
|
||||
can_reconnect: self.can_reconnect(),
|
||||
group: self.tab_group.clone(),
|
||||
panel: cx.entity_id(),
|
||||
@@ -985,7 +985,7 @@ impl ExplorerPanel {
|
||||
impl TabMenu {
|
||||
/// A terminal tab's commands that apply to an SFTP tab, then closing.
|
||||
fn build(&self, menu: PopupMenu, cx: &App) -> PopupMenu {
|
||||
let (id, session_id) = (self.id, self.session_id);
|
||||
let (id, host_id) = (self.id, self.host_id);
|
||||
let copy_host = if self.host_is_ip {
|
||||
"复制 IP 地址"
|
||||
} else {
|
||||
@@ -1000,12 +1000,12 @@ impl TabMenu {
|
||||
.menu_with_icon(
|
||||
"打开 SFTP",
|
||||
Icon::new(CatalogIcon::FolderTree),
|
||||
Box::new(OpenExplorer(session_id)),
|
||||
Box::new(OpenExplorer(host_id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
copy_host,
|
||||
Icon::new(IconName::Copy),
|
||||
Box::new(CopySessionHost(session_id)),
|
||||
Box::new(CopyHostAddress(host_id)),
|
||||
)
|
||||
.menu_with_icon_and_disabled(
|
||||
"重新连接",
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::{
|
||||
CatalogIcon, ExplorerAction, ExplorerCommand, ExplorerShortcut, LOCAL_FILE_LIST_CONTEXT,
|
||||
REMOTE_FILE_LIST_CONTEXT,
|
||||
},
|
||||
session::{BookmarkSide, ConnectionState, SessionId, SessionStore},
|
||||
host::{BookmarkSide, ConnectionState, HostId, HostStore},
|
||||
sftp::{DirectoryListing, SharedLocalDirectoryProvider},
|
||||
};
|
||||
use gpui_kit::component::{
|
||||
@@ -163,8 +163,8 @@ pub struct FilePane {
|
||||
pub(super) side: PaneSide,
|
||||
/// The SFTP tab this pane belongs to, which its commands address.
|
||||
explorer: ExplorerId,
|
||||
/// The session whose bookmarks it shows.
|
||||
session_id: SessionId,
|
||||
/// The host whose bookmarks it shows.
|
||||
host_id: HostId,
|
||||
pub(super) path: String,
|
||||
home: String,
|
||||
/// Well-known local places for the 目录列表 select; empty for remote.
|
||||
@@ -205,7 +205,7 @@ pub struct FilePane {
|
||||
transfer_enabled: bool,
|
||||
/// A file operation on this pane is running.
|
||||
busy: bool,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
dispatch: FocusHandle,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
load_task: Option<Task<()>>,
|
||||
@@ -215,10 +215,10 @@ impl FilePane {
|
||||
pub fn new(
|
||||
side: PaneSide,
|
||||
explorer: ExplorerId,
|
||||
session_id: SessionId,
|
||||
host_id: HostId,
|
||||
home: String,
|
||||
places: Vec<(SharedString, String)>,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
dispatch: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -257,7 +257,7 @@ impl FilePane {
|
||||
let mut pane = Self {
|
||||
side,
|
||||
explorer,
|
||||
session_id,
|
||||
host_id,
|
||||
path: home.clone(),
|
||||
home,
|
||||
places,
|
||||
@@ -365,7 +365,7 @@ impl FilePane {
|
||||
pub fn bookmarks(&self, cx: &App) -> Vec<String> {
|
||||
self.store
|
||||
.read(cx)
|
||||
.bookmarks(self.session_id, self.side.bookmark_side())
|
||||
.bookmarks(self.host_id, self.side.bookmark_side())
|
||||
.to_vec()
|
||||
}
|
||||
pub fn back_target(&self) -> Option<String> {
|
||||
@@ -1226,7 +1226,7 @@ impl Render for FilePane {
|
||||
)
|
||||
})
|
||||
// One menu for rows, column titles and empty space, on the
|
||||
// container: see `SessionPanel` for why menus stay off
|
||||
// container: see `HostPanel` for why menus stay off
|
||||
// virtual rows. Two menus would also both open, since a
|
||||
// menu does not stop the click reaching the one around it.
|
||||
.context_menu(move |menu, window, cx| {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 打开目录/书签, after WinSCP's Open directory dialog: a directory to go to,
|
||||
//! and this session's bookmarks for the pane. Picking a bookmark fills in the
|
||||
//! and this host's bookmarks for the pane. Picking a bookmark fills in the
|
||||
//! directory; the bookmark matching the directory is the one selected. Changes
|
||||
//! to the bookmarks are saved as they are made, even if the dialog is then
|
||||
//! cancelled, as in WinSCP.
|
||||
@@ -8,7 +8,7 @@ use super::{ExplorerId, ExplorerPanel, expand_path};
|
||||
use crate::app::ExplorerDispatch as _;
|
||||
use crate::{
|
||||
app::{CatalogIcon, ExplorerAction, ExplorerCommand},
|
||||
session::{BookmarkSide, SessionId, SessionStore},
|
||||
host::{BookmarkSide, HostId, HostStore},
|
||||
shared::{commit_footer, form_error},
|
||||
};
|
||||
use gpui_kit::component::{
|
||||
@@ -24,11 +24,11 @@ use gpui_kit::*;
|
||||
|
||||
struct OpenDirectoryForm {
|
||||
remote: bool,
|
||||
/// The tab the commands address, and the session whose bookmarks these are.
|
||||
/// The tab the commands address, and the host whose bookmarks these are.
|
||||
explorer: ExplorerId,
|
||||
session: SessionId,
|
||||
host: HostId,
|
||||
dispatch: FocusHandle,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
input: Entity<InputState>,
|
||||
list_focus: FocusHandle,
|
||||
/// The pane's directory and home, which `~` and relative paths use.
|
||||
@@ -42,7 +42,7 @@ impl OpenDirectoryForm {
|
||||
fn bookmarks(&self, cx: &App) -> Vec<String> {
|
||||
self.store
|
||||
.read(cx)
|
||||
.bookmarks(self.session, BookmarkSide::from_remote(self.remote))
|
||||
.bookmarks(self.host, BookmarkSide::from_remote(self.remote))
|
||||
.to_vec()
|
||||
}
|
||||
/// The typed directory, resolved the way the pane resolves it; empty
|
||||
@@ -349,7 +349,7 @@ impl ExplorerPanel {
|
||||
let form = cx.new(|cx| OpenDirectoryForm {
|
||||
remote,
|
||||
explorer: self.id(),
|
||||
session: self.session_id(),
|
||||
host: self.host_id(),
|
||||
dispatch: self.dispatch.clone(),
|
||||
store: self.store.clone(),
|
||||
input: input.clone(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,9 +15,8 @@ use super::{
|
||||
diagram::{ForwardDiagram, explain, purpose, ssh_flag, typical_use},
|
||||
forward_panel::kind_icon,
|
||||
};
|
||||
use crate::session::{
|
||||
DEFAULT_BIND_HOST, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, SessionId,
|
||||
SessionStore,
|
||||
use crate::host::{
|
||||
DEFAULT_BIND_HOST, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, HostId, HostStore,
|
||||
};
|
||||
use crate::shared::{commit_footer, form_error, parse_port};
|
||||
|
||||
@@ -42,7 +41,7 @@ const RADIO_DOT_COLUMN: f32 = 1.375;
|
||||
/// The kind cards, the diagram and the sentence under it are all driven by
|
||||
/// the same fields, so what the picture shows is what would be saved.
|
||||
pub struct ForwardForm {
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
editing: Option<ForwardId>,
|
||||
/// Whether the rule being edited is running, in which case saving a
|
||||
/// change to what it does restarts it.
|
||||
@@ -52,9 +51,9 @@ pub struct ForwardForm {
|
||||
/// the new entrance.
|
||||
flow: u64,
|
||||
name: Entity<InputState>,
|
||||
session: Entity<SelectState<Vec<SharedString>>>,
|
||||
/// Parallel to the session select's rows.
|
||||
session_ids: Vec<SessionId>,
|
||||
host: Entity<SelectState<Vec<SharedString>>>,
|
||||
/// Parallel to the host select's rows.
|
||||
host_ids: Vec<HostId>,
|
||||
bind_host: Entity<InputState>,
|
||||
bind_port: Entity<InputState>,
|
||||
target_host: Entity<InputState>,
|
||||
@@ -68,36 +67,31 @@ impl ForwardForm {
|
||||
pub fn new(
|
||||
editing: Option<ForwardId>,
|
||||
editing_active: bool,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let (draft, session_ids, session_labels) = {
|
||||
let (draft, host_ids, host_labels) = {
|
||||
let read = store.read(cx);
|
||||
let draft = editing
|
||||
.and_then(|id| read.forward(id))
|
||||
.map(|rule| rule.draft());
|
||||
let sessions = read.sessions();
|
||||
let hosts = read.hosts();
|
||||
(
|
||||
draft,
|
||||
sessions
|
||||
hosts.iter().map(|host| host.id).collect::<Vec<_>>(),
|
||||
hosts
|
||||
.iter()
|
||||
.map(|session| session.id)
|
||||
.collect::<Vec<_>>(),
|
||||
sessions
|
||||
.iter()
|
||||
.map(|session| {
|
||||
SharedString::from(format!("{}({})", session.name, session.address()))
|
||||
})
|
||||
.map(|host| SharedString::from(format!("{}({})", host.name, host.endpoint())))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
let kind = draft.as_ref().map(|draft| draft.kind).unwrap_or_default();
|
||||
// A new rule names no host: which server a port is opened through is
|
||||
// for the user to pick, not for a default to pick for them.
|
||||
let session_ix = draft
|
||||
let host_ix = draft
|
||||
.as_ref()
|
||||
.and_then(|draft| session_ids.iter().position(|id| *id == draft.session));
|
||||
.and_then(|draft| host_ids.iter().position(|id| *id == draft.host));
|
||||
let bind = draft.as_ref().map(|draft| draft.bind.clone());
|
||||
let target = draft.as_ref().and_then(|draft| draft.target.clone());
|
||||
let port_text = |port: Option<u16>| port.map(|port| port.to_string()).unwrap_or_default();
|
||||
@@ -107,9 +101,8 @@ impl ForwardForm {
|
||||
.placeholder("可选,留空显示转发摘要")
|
||||
.default_value(draft.as_ref().map(|d| d.name.clone()).unwrap_or_default())
|
||||
});
|
||||
let session = cx.new(|cx| {
|
||||
SelectState::new(session_labels, session_ix.map(IndexPath::new), window, cx)
|
||||
.searchable(true)
|
||||
let host = cx.new(|cx| {
|
||||
SelectState::new(host_labels, host_ix.map(IndexPath::new), window, cx).searchable(true)
|
||||
});
|
||||
let bind_host = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -162,8 +155,8 @@ impl ForwardForm {
|
||||
kind,
|
||||
flow: 0,
|
||||
name,
|
||||
session,
|
||||
session_ids,
|
||||
host,
|
||||
host_ids,
|
||||
bind_host,
|
||||
bind_port,
|
||||
target_host,
|
||||
@@ -199,18 +192,18 @@ impl ForwardForm {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn selected_session(&self, cx: &App) -> Option<usize> {
|
||||
self.session
|
||||
fn selected_host(&self, cx: &App) -> Option<usize> {
|
||||
self.host
|
||||
.read(cx)
|
||||
.selected_index(cx)
|
||||
.map(|ix| ix.row)
|
||||
.filter(|ix| *ix < self.session_ids.len())
|
||||
.filter(|ix| *ix < self.host_ids.len())
|
||||
}
|
||||
|
||||
/// What the form holds right now, not yet checked. A port that is not a
|
||||
/// number reads as 0, which no rule may have.
|
||||
fn draft(&self, cx: &App) -> Option<ForwardDraft> {
|
||||
let session = *self.session_ids.get(self.selected_session(cx)?)?;
|
||||
let host = *self.host_ids.get(self.selected_host(cx)?)?;
|
||||
let endpoint = |host: &Entity<InputState>, port: &Entity<InputState>| {
|
||||
ForwardEndpoint::new(
|
||||
host.read(cx).value().trim().to_string(),
|
||||
@@ -224,7 +217,7 @@ impl ForwardForm {
|
||||
Some(
|
||||
ForwardDraft::new(
|
||||
self.kind,
|
||||
session,
|
||||
host,
|
||||
endpoint(&self.bind_host, &self.bind_port),
|
||||
target,
|
||||
)
|
||||
@@ -253,7 +246,7 @@ impl ForwardForm {
|
||||
None => store.insert_forward(draft, cx).is_some(),
|
||||
});
|
||||
if !saved {
|
||||
// The session, or the rule itself, was deleted meanwhile.
|
||||
// The host, or the rule itself, was deleted meanwhile.
|
||||
self.error = Some("所选主机已不存在,请重新选择".into());
|
||||
cx.notify();
|
||||
return false;
|
||||
@@ -387,8 +380,8 @@ impl ForwardForm {
|
||||
.required(true)
|
||||
.col_span(4)
|
||||
.child(
|
||||
Select::new(&self.session)
|
||||
.id("forward-session")
|
||||
Select::new(&self.host)
|
||||
.id("forward-host")
|
||||
.placeholder("请选择主机")
|
||||
.small(),
|
||||
),
|
||||
@@ -528,7 +521,7 @@ impl Render for ForwardForm {
|
||||
pub fn open_forward_dialog(
|
||||
editing: Option<ForwardId>,
|
||||
editing_active: bool,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
|
||||
@@ -17,19 +17,19 @@ use gpui_kit::*;
|
||||
|
||||
use super::{ForwardManager, ForwardStatus};
|
||||
use crate::app::{
|
||||
CatalogIcon, DeleteForward, EditForward, FORWARD_PANEL_CONTEXT, NewForward, NewSession,
|
||||
CatalogIcon, DeleteForward, EditForward, FORWARD_PANEL_CONTEXT, NewForward, NewHost,
|
||||
SelectNextForward, SelectPreviousForward, StartForward, StopForward, ToggleSelectedForward,
|
||||
};
|
||||
use crate::session::{ForwardId, ForwardKind, SessionStore, matches_forward_query};
|
||||
use crate::host::{ForwardId, ForwardKind, HostStore, matches_forward_query};
|
||||
|
||||
/// The port-forwarding list the left dock shows in place of the sessions:
|
||||
/// The port-forwarding list the left dock shows in place of the hosts:
|
||||
/// every rule, whether it is running, and the switch that starts or stops it.
|
||||
///
|
||||
/// The rules live in the shared `SessionStore` and what each is doing in the
|
||||
/// The rules live in the shared `HostStore` and what each is doing in the
|
||||
/// `ForwardManager`; this panel observes both and owns only its selection
|
||||
/// and search.
|
||||
pub struct ForwardPanel {
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
manager: Entity<ForwardManager>,
|
||||
/// The workspace's focus handle: actions dispatched on it reach the
|
||||
/// workspace handlers whatever is focused, which after a dialog closes
|
||||
@@ -63,7 +63,7 @@ struct ForwardRow {
|
||||
|
||||
impl ForwardPanel {
|
||||
pub fn new(
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
manager: Entity<ForwardManager>,
|
||||
target: FocusHandle,
|
||||
window: &mut Window,
|
||||
@@ -162,18 +162,18 @@ impl ForwardPanel {
|
||||
.forwards()
|
||||
.iter()
|
||||
.filter_map(|rule| {
|
||||
let session = store
|
||||
.session(rule.session)
|
||||
.map(|session| session.name.clone())
|
||||
let host = store
|
||||
.host(rule.host)
|
||||
.map(|host| host.name.clone())
|
||||
.unwrap_or_default();
|
||||
matches_forward_query(rule, &session, &self.query).then(|| ForwardRow {
|
||||
matches_forward_query(rule, &host, &self.query).then(|| ForwardRow {
|
||||
id: rule.id,
|
||||
kind: rule.kind,
|
||||
title: rule.title(),
|
||||
detail: if rule.name.is_empty() {
|
||||
format!("{} · {session}", rule.kind.label())
|
||||
format!("{} · {host}", rule.kind.label())
|
||||
} else {
|
||||
format!("{} · {} · {session}", rule.kind.label(), rule.summary())
|
||||
format!("{} · {} · {host}", rule.kind.label(), rule.summary())
|
||||
}
|
||||
.into(),
|
||||
status: manager.status(rule.id),
|
||||
@@ -236,17 +236,17 @@ impl ForwardPanel {
|
||||
let target = self.target.clone();
|
||||
let (title, hint, button) = if !store.forwards().is_empty() {
|
||||
("没有匹配的端口转发", None, None)
|
||||
} else if store.sessions().is_empty() {
|
||||
} else if store.hosts().is_empty() {
|
||||
(
|
||||
"还没有主机",
|
||||
Some("端口转发经由一台主机的 SSH 连接,先新建主机。"),
|
||||
Some(
|
||||
Button::new("forward-empty-new-session")
|
||||
Button::new("forward-empty-new-host")
|
||||
.small()
|
||||
.icon(IconName::Plus)
|
||||
.label("新建主机…")
|
||||
.on_click(move |_, window, cx| {
|
||||
target.dispatch_action(&NewSession, window, cx)
|
||||
target.dispatch_action(&NewHost, window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -313,7 +313,7 @@ impl ForwardPanel {
|
||||
.confirmed(selected)
|
||||
.aria_selected(selected)
|
||||
.when(selected, |item| {
|
||||
item.bg(crate::app::session_tree_selection_color(theme))
|
||||
item.bg(crate::app::host_tree_selection_color(theme))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -539,7 +539,7 @@ impl Render for ForwardPanel {
|
||||
)
|
||||
.child(
|
||||
// The menu hangs off the list, not off the rows, like the
|
||||
// session tree's: capture clears the hit before a
|
||||
// host tree's: capture clears the hit before a
|
||||
// right-clicked row writes itself back, and the menu is
|
||||
// built after both.
|
||||
div()
|
||||
|
||||
+28
-31
@@ -8,7 +8,7 @@ use gpui_kit::{Context, Entity, EventEmitter, SharedString, Subscription};
|
||||
use super::{ForwardCommand, ForwardEvent, SharedForwardTransportProvider};
|
||||
use crate::{
|
||||
connection::{ConnectionPrompt, ConnectionPromptReply},
|
||||
session::{ForwardId, SessionId, SessionStore, SessionStoreEvent},
|
||||
host::{ForwardId, HostId, HostStore, HostStoreEvent},
|
||||
};
|
||||
|
||||
/// How often the workers' events are collected. Worker threads never wake a
|
||||
@@ -66,7 +66,7 @@ pub enum ForwardManagerEvent {
|
||||
StatusChanged(ForwardId),
|
||||
/// Logging in needs an answer. The number is the run the question
|
||||
/// belongs to: an answer for an earlier run of the rule is stale.
|
||||
PromptRequested(ForwardId, SessionId, u64, ConnectionPrompt),
|
||||
PromptRequested(ForwardId, HostId, u64, ConnectionPrompt),
|
||||
/// A forward ended on its own, which the user may not be looking at.
|
||||
Failed(ForwardId, SharedString),
|
||||
}
|
||||
@@ -75,7 +75,7 @@ pub enum ForwardManagerEvent {
|
||||
struct Link {
|
||||
/// Tells this run's questions from those of an earlier one.
|
||||
generation: u64,
|
||||
session: SessionId,
|
||||
host: HostId,
|
||||
commands: Sender<ForwardCommand>,
|
||||
events: Receiver<ForwardEvent>,
|
||||
status: ForwardStatus,
|
||||
@@ -85,10 +85,10 @@ struct Link {
|
||||
stopping: Option<bool>,
|
||||
}
|
||||
|
||||
/// Owns every running forward. The rules themselves live in the session
|
||||
/// Owns every running forward. The rules themselves live in the host
|
||||
/// store; this holds only what a run adds: a worker and its status.
|
||||
pub struct ForwardManager {
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
provider: SharedForwardTransportProvider,
|
||||
links: HashMap<ForwardId, Link>,
|
||||
/// Why a rule's last run ended, until it starts again or is deleted.
|
||||
@@ -104,32 +104,29 @@ impl EventEmitter<ForwardManagerEvent> for ForwardManager {}
|
||||
|
||||
impl ForwardManager {
|
||||
pub fn new(
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
provider: SharedForwardTransportProvider,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let subscriptions = vec![
|
||||
cx.observe(&store, |this, _, cx| this.forget_removed(cx)),
|
||||
cx.subscribe(
|
||||
&store,
|
||||
|this, _, event: &SessionStoreEvent, cx| match event {
|
||||
// The rule listens or connects somewhere else now.
|
||||
SessionStoreEvent::ForwardSettingsChanged(id) => this.restart(*id, cx),
|
||||
// The server moved, or logs in differently.
|
||||
SessionStoreEvent::ConnectionSettingsChanged(session) => {
|
||||
let running: Vec<_> = this
|
||||
.links
|
||||
.iter()
|
||||
.filter(|(_, link)| link.session == *session)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
for id in running {
|
||||
this.restart(id, cx);
|
||||
}
|
||||
cx.subscribe(&store, |this, _, event: &HostStoreEvent, cx| match event {
|
||||
// The rule listens or connects somewhere else now.
|
||||
HostStoreEvent::ForwardSettingsChanged(id) => this.restart(*id, cx),
|
||||
// The server moved, or logs in differently.
|
||||
HostStoreEvent::ConnectionSettingsChanged(host) => {
|
||||
let running: Vec<_> = this
|
||||
.links
|
||||
.iter()
|
||||
.filter(|(_, link)| link.host == *host)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
for id in running {
|
||||
this.restart(id, cx);
|
||||
}
|
||||
SessionStoreEvent::PersistFailed(_) => {}
|
||||
},
|
||||
),
|
||||
}
|
||||
HostStoreEvent::PersistFailed(_) => {}
|
||||
}),
|
||||
];
|
||||
Self {
|
||||
store,
|
||||
@@ -210,10 +207,10 @@ impl ForwardManager {
|
||||
let Some(rule) = store.forward(id) else {
|
||||
return;
|
||||
};
|
||||
let Some(login) = store.login(rule.session) else {
|
||||
let Some(login) = store.login(rule.host) else {
|
||||
return;
|
||||
};
|
||||
let session_id = rule.session;
|
||||
let host_id = rule.host;
|
||||
let transport = self.provider.create(rule, &login);
|
||||
let (commands, command_receiver) = async_channel::unbounded();
|
||||
let (event_sender, events) = async_channel::unbounded();
|
||||
@@ -232,7 +229,7 @@ impl ForwardManager {
|
||||
id,
|
||||
Link {
|
||||
generation,
|
||||
session: session_id,
|
||||
host: host_id,
|
||||
commands,
|
||||
events,
|
||||
status: ForwardStatus::Connecting,
|
||||
@@ -289,7 +286,7 @@ impl ForwardManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rules deleted from the store, on their own or with their session,
|
||||
/// Rules deleted from the store, on their own or with their host,
|
||||
/// stop running and leave nothing behind.
|
||||
fn forget_removed(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
@@ -380,9 +377,9 @@ impl ForwardManager {
|
||||
let status = match event {
|
||||
ForwardEvent::Connecting => ForwardStatus::Connecting,
|
||||
ForwardEvent::Prompt(prompt) => {
|
||||
let (session, generation) = (link.session, link.generation);
|
||||
let (host, generation) = (link.host, link.generation);
|
||||
cx.emit(ForwardManagerEvent::PromptRequested(
|
||||
id, session, generation, prompt,
|
||||
id, host, generation, prompt,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -21,11 +21,11 @@ use tokio::{
|
||||
use super::*;
|
||||
use crate::{
|
||||
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret},
|
||||
secrets::{InMemorySecretStore, SecretRef, SecretStore as _},
|
||||
session::{
|
||||
AuthKind, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, Session,
|
||||
SessionDraft, SessionId, SessionLogin,
|
||||
host::{
|
||||
AuthKind, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, Host,
|
||||
HostDraft, HostId, HostLogin,
|
||||
},
|
||||
secrets::{InMemorySecretStore, SecretRef, SecretStore as _},
|
||||
ssh::{SshConnectionConfig, SshConnector, SshPrompts},
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ struct Policy {
|
||||
connections: AtomicUsize,
|
||||
ended: AtomicUsize,
|
||||
/// The logged-in connections, so a test can hang up on them.
|
||||
sessions: Mutex<Vec<server::Handle>>,
|
||||
logged_in: Mutex<Vec<server::Handle>>,
|
||||
}
|
||||
|
||||
struct TestServer {
|
||||
@@ -91,7 +91,7 @@ impl server::Handler for Handler {
|
||||
}
|
||||
|
||||
async fn auth_succeeded(&mut self, session: &mut server::Session) -> Result<(), Self::Error> {
|
||||
self.policy.sessions.lock().unwrap().push(session.handle());
|
||||
self.policy.logged_in.lock().unwrap().push(session.handle());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -191,9 +191,9 @@ impl Drop for Running {
|
||||
impl Running {
|
||||
/// End every logged-in connection from the server's side.
|
||||
async fn hang_up(&self) {
|
||||
let sessions: Vec<_> = self.policy.sessions.lock().unwrap().drain(..).collect();
|
||||
for session in sessions {
|
||||
let _ = session
|
||||
let logged_in: Vec<_> = self.policy.logged_in.lock().unwrap().drain(..).collect();
|
||||
for handle in logged_in {
|
||||
let _ = handle
|
||||
.disconnect(russh::Disconnect::ByApplication, "bye".into(), "en".into())
|
||||
.await;
|
||||
}
|
||||
@@ -292,10 +292,10 @@ impl Fixture {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn session(&self) -> Session {
|
||||
Session::new(
|
||||
SessionId(1),
|
||||
SessionDraft::new(
|
||||
fn host(&self) -> Host {
|
||||
Host::new(
|
||||
HostId(1),
|
||||
HostDraft::new(
|
||||
"fixture",
|
||||
"127.0.0.1",
|
||||
self.ssh_port,
|
||||
@@ -323,12 +323,12 @@ impl Fixture {
|
||||
ForwardId(1),
|
||||
ForwardDraft::new(
|
||||
kind,
|
||||
SessionId(1),
|
||||
HostId(1),
|
||||
ForwardEndpoint::new("127.0.0.1", bind),
|
||||
target.map(|port| ForwardEndpoint::new("127.0.0.1", port)),
|
||||
),
|
||||
);
|
||||
let transport = provider.create(&rule, &SessionLogin::of(&self.session(), None));
|
||||
let transport = provider.create(&rule, &HostLogin::of(&self.host(), None));
|
||||
let (commands, command_receiver) = async_channel::unbounded();
|
||||
let (event_sender, events) = async_channel::unbounded();
|
||||
let thread = std::thread::spawn(move || transport.run(command_receiver, event_sender));
|
||||
@@ -848,7 +848,7 @@ fn a_connection_that_forwards_nothing_refuses_channels_from_the_server() {
|
||||
// A terminal's or an SFTP tab's kind of connection.
|
||||
let (_shutdown, shutdown) = watch::channel(false);
|
||||
let prompts = Arc::new(SshPrompts::new(Arc::new(|_| false), shutdown).non_interactive());
|
||||
let config = SshConnectionConfig::from(&SessionLogin::of(&fixture.session(), None));
|
||||
let config = SshConnectionConfig::from(&HostLogin::of(&fixture.host(), None));
|
||||
let (handle, _) = fixture.connector.connect(&config, prompts).await.unwrap();
|
||||
let remote = free_port();
|
||||
handle
|
||||
|
||||
@@ -7,7 +7,7 @@ use async_channel::{Receiver, Sender};
|
||||
|
||||
use crate::{
|
||||
connection::{ConnectionPrompt, ConnectionPromptReply},
|
||||
session::{ForwardRule, SessionLogin},
|
||||
host::{ForwardRule, HostLogin},
|
||||
};
|
||||
|
||||
/// What the list tells a running forward.
|
||||
@@ -56,9 +56,9 @@ pub trait ForwardTransport: Send + 'static {
|
||||
}
|
||||
|
||||
pub trait ForwardTransportProvider: Send + Sync + 'static {
|
||||
/// A transport for `rule` as it stands now, logging in to its session
|
||||
/// A transport for `rule` as it stands now, logging in to its host
|
||||
/// with `login`.
|
||||
fn create(&self, rule: &ForwardRule, login: &SessionLogin) -> Box<dyn ForwardTransport>;
|
||||
fn create(&self, rule: &ForwardRule, login: &HostLogin) -> Box<dyn ForwardTransport>;
|
||||
}
|
||||
|
||||
pub type SharedForwardTransportProvider = Arc<dyn ForwardTransportProvider>;
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! One run is one thread with its own current-thread runtime, like an SFTP
|
||||
//! worker: SSH handles are bound to the runtime that made them. The run owns
|
||||
//! its own connection, apart from any terminal or SFTP tab of the session.
|
||||
//! its own connection, apart from any terminal or SFTP tab of the host.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
@@ -26,7 +26,7 @@ use super::{
|
||||
};
|
||||
use crate::{
|
||||
connection::{ConnectionPrompt, ConnectionPromptKind},
|
||||
session::{ForwardEndpoint, ForwardKind, ForwardRule, SessionLogin},
|
||||
host::{ForwardEndpoint, ForwardKind, ForwardRule, HostLogin},
|
||||
ssh::{
|
||||
ForwardedTcpip, MissingCredential, SshConnectionConfig, SshConnector, SshHandle,
|
||||
SshPrompts, describe_login_error, is_network_error,
|
||||
@@ -78,7 +78,7 @@ impl SshForwardTransportProvider {
|
||||
}
|
||||
|
||||
impl ForwardTransportProvider for SshForwardTransportProvider {
|
||||
fn create(&self, rule: &ForwardRule, login: &SessionLogin) -> Box<dyn ForwardTransport> {
|
||||
fn create(&self, rule: &ForwardRule, login: &HostLogin) -> Box<dyn ForwardTransport> {
|
||||
Box::new(SshForwardTransport {
|
||||
connector: self.connector.clone(),
|
||||
config: SshConnectionConfig::from(login),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Saved logins that hosts share: a user name with a password, a private key
|
||||
//! file or the SSH agent. They live with the sessions because hosts refer to
|
||||
//! file or the SSH agent. They live with the hosts because hosts refer to
|
||||
//! them and they are stored in the same database; the `credential` module
|
||||
//! that lists and edits them depends on this one, never the reverse.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,12 @@
|
||||
//! Port-forwarding rules. They live with the sessions because they are stored
|
||||
//! in the same database and go with their session when it is deleted; the
|
||||
//! Port-forwarding rules. They live with the hosts because they are stored
|
||||
//! in the same database and go with their host when it is deleted; the
|
||||
//! `forward` module that runs them depends on this one, never the reverse.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use gpui_kit::SharedString;
|
||||
|
||||
use super::SessionId;
|
||||
use super::HostId;
|
||||
|
||||
/// Stable identity of a forwarding rule. Never reused within a process.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
@@ -150,8 +150,8 @@ pub struct ForwardDraft {
|
||||
/// Optional. An empty name shows the rule's summary instead.
|
||||
pub name: SharedString,
|
||||
pub kind: ForwardKind,
|
||||
/// The session whose server the forward goes through.
|
||||
pub session: SessionId,
|
||||
/// The host whose server the forward goes through.
|
||||
pub host: HostId,
|
||||
/// Where the forward listens: on this machine for local and dynamic
|
||||
/// forwards, on the server for a remote one.
|
||||
pub bind: ForwardEndpoint,
|
||||
@@ -164,14 +164,14 @@ pub struct ForwardDraft {
|
||||
impl ForwardDraft {
|
||||
pub fn new(
|
||||
kind: ForwardKind,
|
||||
session: SessionId,
|
||||
host: HostId,
|
||||
bind: ForwardEndpoint,
|
||||
target: Option<ForwardEndpoint>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: SharedString::default(),
|
||||
kind,
|
||||
session,
|
||||
host,
|
||||
bind,
|
||||
target,
|
||||
auto_start: false,
|
||||
@@ -227,7 +227,7 @@ pub struct ForwardRule {
|
||||
pub id: ForwardId,
|
||||
pub name: SharedString,
|
||||
pub kind: ForwardKind,
|
||||
pub session: SessionId,
|
||||
pub host: HostId,
|
||||
pub bind: ForwardEndpoint,
|
||||
pub target: Option<ForwardEndpoint>,
|
||||
pub auto_start: bool,
|
||||
@@ -241,7 +241,7 @@ impl ForwardRule {
|
||||
id,
|
||||
name: draft.name,
|
||||
kind: draft.kind,
|
||||
session: draft.session,
|
||||
host: draft.host,
|
||||
bind: draft.bind,
|
||||
target: draft.target,
|
||||
auto_start: draft.auto_start,
|
||||
@@ -254,7 +254,7 @@ impl ForwardRule {
|
||||
ForwardDraft {
|
||||
name: self.name.clone(),
|
||||
kind: self.kind,
|
||||
session: self.session,
|
||||
host: self.host,
|
||||
bind: self.bind.clone(),
|
||||
target: self.target.clone(),
|
||||
auto_start: self.auto_start,
|
||||
@@ -290,15 +290,15 @@ impl ForwardRule {
|
||||
/// opposed to its name or whether it starts with the application.
|
||||
pub fn needs_restart(&self, draft: &ForwardDraft) -> bool {
|
||||
self.kind != draft.kind
|
||||
|| self.session != draft.session
|
||||
|| self.host != draft.host
|
||||
|| self.bind != draft.bind
|
||||
|| self.target != draft.target
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a rule matches what was typed into the forward list's search box:
|
||||
/// its name, its addresses, or the name of the session it goes through.
|
||||
pub fn matches_forward_query(rule: &ForwardRule, session_name: &str, query: &str) -> bool {
|
||||
/// its name, its addresses, or the name of the host it goes through.
|
||||
pub fn matches_forward_query(rule: &ForwardRule, host_name: &str, query: &str) -> bool {
|
||||
let query = query.trim().to_lowercase();
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
@@ -307,7 +307,7 @@ pub fn matches_forward_query(rule: &ForwardRule, session_name: &str, query: &str
|
||||
rule.name.to_string(),
|
||||
rule.summary(),
|
||||
rule.kind.label().to_string(),
|
||||
session_name.to_string(),
|
||||
host_name.to_string(),
|
||||
]
|
||||
.iter()
|
||||
.any(|text| text.to_lowercase().contains(&query))
|
||||
@@ -320,7 +320,7 @@ mod tests {
|
||||
fn local() -> ForwardDraft {
|
||||
ForwardDraft::new(
|
||||
ForwardKind::Local,
|
||||
SessionId(1),
|
||||
HostId(1),
|
||||
ForwardEndpoint::new(DEFAULT_BIND_HOST, 8080),
|
||||
Some(ForwardEndpoint::new("db.internal", 3306)),
|
||||
)
|
||||
@@ -413,16 +413,16 @@ mod tests {
|
||||
let mut other_port = local();
|
||||
other_port.bind.port = 8081;
|
||||
assert!(rule.needs_restart(&other_port));
|
||||
let mut other_session = local();
|
||||
other_session.session = SessionId(2);
|
||||
assert!(rule.needs_restart(&other_session));
|
||||
let mut through_another = local();
|
||||
through_another.host = HostId(2);
|
||||
assert!(rule.needs_restart(&through_another));
|
||||
let mut other_target = local();
|
||||
other_target.target = Some(ForwardEndpoint::new("db.internal", 5432));
|
||||
assert!(rule.needs_restart(&other_target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_looks_at_the_name_the_addresses_and_the_session() {
|
||||
fn the_search_looks_at_the_name_the_addresses_and_the_host() {
|
||||
let rule = ForwardRule::new(ForwardId(1), local().with_name("数据库"));
|
||||
assert!(matches_forward_query(&rule, "web-01", ""));
|
||||
assert!(matches_forward_query(&rule, "web-01", "数据"));
|
||||
@@ -8,7 +8,7 @@ use gpui_kit::component::{
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::{DeleteHandler, Dependents, GroupDraft, GroupId, SessionStore, group_options};
|
||||
use super::{DeleteHandler, Dependents, GroupDraft, GroupId, HostStore, group_options};
|
||||
use crate::shared::{commit_footer, confirm_delete, form_error};
|
||||
|
||||
/// The label of the row that stands for "no parent" / "no group".
|
||||
@@ -17,7 +17,7 @@ pub const ROOT_LABEL: &str = "(顶层)";
|
||||
/// The body of the new/rename group dialog. Owns the field states and
|
||||
/// validates on commit; the store is only touched when validation passes.
|
||||
pub struct GroupForm {
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
editing: Option<GroupId>,
|
||||
name: Entity<InputState>,
|
||||
parent: Entity<SelectState<Vec<SharedString>>>,
|
||||
@@ -30,7 +30,7 @@ impl GroupForm {
|
||||
pub fn new(
|
||||
editing: Option<GroupId>,
|
||||
default_parent: Option<GroupId>,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -151,7 +151,7 @@ impl Render for GroupForm {
|
||||
pub fn open_group_dialog(
|
||||
editing: Option<GroupId>,
|
||||
default_parent: Option<GroupId>,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
@@ -187,12 +187,12 @@ pub fn open_group_dialog(
|
||||
}
|
||||
|
||||
/// Ask before deleting a group. Deleting one takes its subgroups and every
|
||||
/// session inside them, and those sessions' port forwards, so the counts go
|
||||
/// host inside them, and those hosts' port forwards, so the counts go
|
||||
/// in the description, with the hosts elsewhere that jump through them.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn confirm_delete_group(
|
||||
name: &str,
|
||||
sessions: usize,
|
||||
hosts: usize,
|
||||
subgroups: usize,
|
||||
affected: (bool, usize),
|
||||
dependents: Dependents,
|
||||
@@ -205,7 +205,7 @@ pub fn confirm_delete_group(
|
||||
jump_users,
|
||||
} = dependents;
|
||||
let (closes_tabs, uploads) = affected;
|
||||
let mut description = describe_contents(sessions, subgroups, closes_tabs);
|
||||
let mut description = describe_contents(hosts, subgroups, closes_tabs);
|
||||
if uploads > 0 {
|
||||
description = Some(
|
||||
format!(
|
||||
@@ -238,13 +238,13 @@ pub fn confirm_delete_group(
|
||||
|
||||
/// What the delete dialog says about everything that goes with the group.
|
||||
/// `None` for an empty group with nothing open.
|
||||
fn describe_contents(sessions: usize, subgroups: usize, closes_tabs: bool) -> Option<SharedString> {
|
||||
let contents = match (sessions, subgroups) {
|
||||
fn describe_contents(hosts: usize, subgroups: usize, closes_tabs: bool) -> Option<SharedString> {
|
||||
let contents = match (hosts, subgroups) {
|
||||
(0, 0) => None,
|
||||
(0, subgroups) => Some(format!("将同时删除其中的 {subgroups} 个子分组。")),
|
||||
(sessions, 0) => Some(format!("将同时删除其中的 {sessions} 台主机。")),
|
||||
(sessions, subgroups) => Some(format!(
|
||||
"将同时删除其中的 {sessions} 台主机和 {subgroups} 个子分组。"
|
||||
(hosts, 0) => Some(format!("将同时删除其中的 {hosts} 台主机。")),
|
||||
(hosts, subgroups) => Some(format!(
|
||||
"将同时删除其中的 {hosts} 台主机和 {subgroups} 个子分组。"
|
||||
)),
|
||||
};
|
||||
let tabs = closes_tabs.then_some("已打开的终端和 SFTP 标签会一并关闭。");
|
||||
@@ -22,14 +22,13 @@ use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, Unknow
|
||||
|
||||
use super::secret_fields::SecretFields;
|
||||
use super::{
|
||||
AuthKind, Credential, CredentialId, CredentialKind, DEFAULT_USER, GroupId, ProxyKind,
|
||||
ProxySettings, Route, Session, SessionDraft, SessionId, SessionLogin, SessionStore,
|
||||
group_options,
|
||||
AuthKind, Credential, CredentialId, CredentialKind, DEFAULT_USER, GroupId, Host, HostDraft,
|
||||
HostId, HostLogin, HostStore, ProxyKind, ProxySettings, Route, group_options,
|
||||
};
|
||||
pub use crate::shared::DeleteHandler;
|
||||
use crate::shared::{Segment, SegmentedControl, confirm_delete, form_error, parse_port};
|
||||
|
||||
/// The label of the row that puts a session at the root of the tree.
|
||||
/// The label of the row that puts a host at the root of the tree.
|
||||
pub const NO_GROUP_LABEL: &str = "(无分组)";
|
||||
|
||||
/// Where the dialog's top sits and how tall it may grow, as fractions of
|
||||
@@ -119,24 +118,24 @@ const PROXY_NOTE: &str = "目标地址由代理服务器解析;代理不需要
|
||||
/// A host the form offers as a jump host, as it was when the form opened.
|
||||
#[derive(Clone)]
|
||||
struct JumpHost {
|
||||
id: SessionId,
|
||||
id: HostId,
|
||||
name: SharedString,
|
||||
/// `host:port`.
|
||||
address: SharedString,
|
||||
}
|
||||
|
||||
impl JumpHost {
|
||||
fn of(session: &Session) -> Self {
|
||||
fn of(host: &Host) -> Self {
|
||||
Self {
|
||||
id: session.id,
|
||||
name: session.name.clone(),
|
||||
address: format!("{}:{}", session.host, session.port).into(),
|
||||
id: host.id,
|
||||
name: host.name.clone(),
|
||||
address: format!("{}:{}", host.address, host.port).into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchableListItem for JumpHost {
|
||||
type Value = SessionId;
|
||||
type Value = HostId;
|
||||
|
||||
/// The name and the address, both of which the search looks in.
|
||||
fn title(&self) -> SharedString {
|
||||
@@ -156,20 +155,20 @@ impl SearchableListItem for JumpHost {
|
||||
)
|
||||
}
|
||||
|
||||
fn value(&self) -> &SessionId {
|
||||
fn value(&self) -> &HostId {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
type JumpPicker = ComboboxState<SearchableVec<JumpHost>>;
|
||||
|
||||
/// The body of the new/edit session dialog. Owns the field states and
|
||||
/// The body of the new/edit host dialog. Owns the field states and
|
||||
/// validates on commit; the store is only touched when validation passes.
|
||||
pub struct SessionForm {
|
||||
store: Entity<SessionStore>,
|
||||
editing: Option<SessionId>,
|
||||
pub struct HostForm {
|
||||
store: Entity<HostStore>,
|
||||
editing: Option<HostId>,
|
||||
name: Entity<InputState>,
|
||||
host: Entity<InputState>,
|
||||
address: Entity<InputState>,
|
||||
port: Entity<InputState>,
|
||||
source: AuthSource,
|
||||
user: Entity<InputState>,
|
||||
@@ -188,7 +187,7 @@ pub struct SessionForm {
|
||||
group_ids: Vec<Option<GroupId>>,
|
||||
route: RouteChoice,
|
||||
/// The jump hosts, in order; `None` is one that has been deleted.
|
||||
hops: Vec<Option<SessionId>>,
|
||||
hops: Vec<Option<HostId>>,
|
||||
/// Every other host, as it was when the form opened: what can be a
|
||||
/// jump host, and what the hops are called.
|
||||
jump_hosts: Vec<JumpHost>,
|
||||
@@ -208,11 +207,11 @@ pub struct SessionForm {
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SessionForm {
|
||||
impl HostForm {
|
||||
pub fn new(
|
||||
editing: Option<SessionId>,
|
||||
editing: Option<HostId>,
|
||||
preselect_group: Option<GroupId>,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
tester: SharedConnectionTester,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -221,22 +220,22 @@ impl SessionForm {
|
||||
let (draft, options, credentials, key_dir, editing_connected, jump_hosts) = {
|
||||
let read = store.read(cx);
|
||||
(
|
||||
editing.and_then(|id| read.session(id)).map(Session::draft),
|
||||
editing.and_then(|id| read.host(id)).map(Host::draft),
|
||||
group_options(read.groups(), &[]),
|
||||
read.credentials().to_vec(),
|
||||
read.key_dir().map(Path::to_path_buf),
|
||||
editing
|
||||
.and_then(|id| read.session(id))
|
||||
.is_some_and(|session| session.state != super::ConnectionState::Disconnected),
|
||||
read.sessions()
|
||||
.and_then(|id| read.host(id))
|
||||
.is_some_and(|host| host.state != super::ConnectionState::Disconnected),
|
||||
read.hosts()
|
||||
.iter()
|
||||
.filter(|session| Some(session.id) != editing)
|
||||
.filter(|host| Some(host.id) != editing)
|
||||
.map(JumpHost::of)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
// A session with no group sits at the root of the tree, which is where
|
||||
// every session starts when the database is still empty.
|
||||
// A host with no group sits at the root of the tree, which is where
|
||||
// every host starts when the database is still empty.
|
||||
let mut group_ids: Vec<Option<GroupId>> = vec![None];
|
||||
let mut group_names: Vec<SharedString> = vec![NO_GROUP_LABEL.into()];
|
||||
for (id, path) in options {
|
||||
@@ -244,7 +243,7 @@ impl SessionForm {
|
||||
group_names.push(path);
|
||||
}
|
||||
let draft = draft.unwrap_or_else(|| {
|
||||
SessionDraft::new(
|
||||
HostDraft::new(
|
||||
"",
|
||||
"",
|
||||
22,
|
||||
@@ -259,10 +258,10 @@ impl SessionForm {
|
||||
.placeholder("例如 web-01")
|
||||
.default_value(draft.name.clone())
|
||||
});
|
||||
let host = cx.new(|cx| {
|
||||
let address = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("主机名或 IP 地址")
|
||||
.default_value(draft.host.clone())
|
||||
.default_value(draft.address.clone())
|
||||
});
|
||||
let port = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -401,7 +400,7 @@ impl SessionForm {
|
||||
store,
|
||||
editing,
|
||||
name,
|
||||
host,
|
||||
address,
|
||||
port,
|
||||
source: match (credential_ix, draft.auth) {
|
||||
(Some(_), _) => AuthSource::Credential,
|
||||
@@ -451,7 +450,7 @@ impl SessionForm {
|
||||
|
||||
/// Put `id` at the end of the jump hosts, and take it off the hosts that
|
||||
/// can still be added.
|
||||
fn add_hop(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn add_hop(&mut self, id: HostId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.hops.contains(&Some(id)) {
|
||||
self.hops.push(Some(id));
|
||||
self.error = None;
|
||||
@@ -522,12 +521,12 @@ impl SessionForm {
|
||||
|
||||
/// The address and port, or why they will not do.
|
||||
fn endpoint(&self, cx: &App) -> Result<(String, u16), &'static str> {
|
||||
let host = self.host.read(cx).value().trim().to_string();
|
||||
let address = self.address.read(cx).value().trim().to_string();
|
||||
let port = parse_port(self.port.read(cx).value().trim());
|
||||
match (host.is_empty(), port) {
|
||||
match (address.is_empty(), port) {
|
||||
(true, _) => Err("请输入地址"),
|
||||
(_, None) => Err("端口必须是 1 到 65535 之间的数字"),
|
||||
(_, Some(port)) => Ok((host, port)),
|
||||
(_, Some(port)) => Ok((address, port)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +540,7 @@ impl SessionForm {
|
||||
None => {
|
||||
let credential = self.selected_credential(cx).ok_or("请选择凭据")?;
|
||||
LoginTest::saved(
|
||||
SessionLogin::with_credential(host, port, credential).with_route(route_login),
|
||||
HostLogin::with_credential(host, port, credential).with_route(route_login),
|
||||
)
|
||||
}
|
||||
Some(auth) => {
|
||||
@@ -550,7 +549,7 @@ impl SessionForm {
|
||||
return Err("请输入用户名");
|
||||
}
|
||||
let mut request = LoginTest::typed(
|
||||
SessionLogin::manual(host, port, user, auth).with_route(route_login),
|
||||
HostLogin::manual(host, port, user, auth).with_route(route_login),
|
||||
);
|
||||
// Only what the chosen way uses, which is also what the form
|
||||
// shows.
|
||||
@@ -692,7 +691,7 @@ impl SessionForm {
|
||||
let mut password_change = None;
|
||||
let draft = match login {
|
||||
CommittedLogin::Saved { credential, user } => {
|
||||
SessionDraft::new(name, host, port, user, AuthKind::default(), group)
|
||||
HostDraft::new(name, host, port, user, AuthKind::default(), group)
|
||||
.with_credential(credential)
|
||||
}
|
||||
CommittedLogin::Own(auth) => {
|
||||
@@ -702,7 +701,7 @@ impl SessionForm {
|
||||
} else {
|
||||
user
|
||||
};
|
||||
let draft = SessionDraft::new(name, host, port, user, auth, group);
|
||||
let draft = HostDraft::new(name, host, port, user, auth, group);
|
||||
if auth == AuthKind::Password
|
||||
&& let Some(change) = self.fields.read(cx).password_change(cx)
|
||||
{
|
||||
@@ -735,7 +734,7 @@ impl SessionForm {
|
||||
}
|
||||
}
|
||||
// `update` above may have dropped the entry for the endpoint the
|
||||
// session just left; this writes the one it moved to.
|
||||
// host just left; this writes the one it moved to.
|
||||
for (secret, change) in password_change.into_iter().chain(proxy_change) {
|
||||
store.save_secret(secret, change, cx);
|
||||
}
|
||||
@@ -756,14 +755,14 @@ impl SessionForm {
|
||||
Field::new()
|
||||
.label("用户名")
|
||||
.col_span(4)
|
||||
.child(Input::new(&self.user).id("session-user").small()),
|
||||
.child(Input::new(&self.user).id("host-user").small()),
|
||||
)
|
||||
.when(auth == AuthKind::Password, |form| {
|
||||
form.child(
|
||||
Field::new()
|
||||
.label("密码")
|
||||
.col_span(4)
|
||||
.child(self.fields.read(cx).password_input("session-password")),
|
||||
.child(self.fields.read(cx).password_input("host-password")),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -783,7 +782,7 @@ impl SessionForm {
|
||||
.col_span(4)
|
||||
.child(
|
||||
Select::new(&self.credential)
|
||||
.id("session-credential")
|
||||
.id("host-credential")
|
||||
.placeholder("请选择凭据")
|
||||
.search_placeholder("搜索凭据")
|
||||
.empty(|_, cx| {
|
||||
@@ -799,7 +798,7 @@ impl SessionForm {
|
||||
.when_some(summary, |field, summary: SharedString| {
|
||||
field.description_fn(move |_, _| {
|
||||
div()
|
||||
.id("session-credential-summary")
|
||||
.id("host-credential-summary")
|
||||
.test_support()
|
||||
.aria_label(summary.clone())
|
||||
.child(summary.clone())
|
||||
@@ -808,11 +807,11 @@ impl SessionForm {
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionForm {
|
||||
impl HostForm {
|
||||
/// 「连接方式」: the choice, and below it what the choice needs.
|
||||
fn route_field(&self, cx: &mut Context<Self>) -> Field {
|
||||
let route = self.route;
|
||||
let choice = SegmentedControl::new("session-route")
|
||||
let choice = SegmentedControl::new("host-route")
|
||||
.selected_index(RouteChoice::ALL.iter().position(|each| *each == route))
|
||||
.on_change(cx.listener(|this, ix: &usize, _, cx| {
|
||||
if let Some(route) = RouteChoice::ALL.get(*ix) {
|
||||
@@ -849,7 +848,7 @@ impl SessionForm {
|
||||
fn jump_box(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (muted, danger) = (theme.muted_foreground, theme.danger);
|
||||
let name_of = |hop: &Option<SessionId>| {
|
||||
let name_of = |hop: &Option<HostId>| {
|
||||
hop.and_then(|id| self.jump_hosts.iter().find(|host| host.id == id))
|
||||
};
|
||||
|
||||
@@ -892,7 +891,7 @@ impl SessionForm {
|
||||
.join(" → ");
|
||||
let stop_count = stops.len();
|
||||
let chain = h_flex()
|
||||
.id("session-route-chain")
|
||||
.id("host-route-chain")
|
||||
.test_support()
|
||||
.aria_label(chain_label)
|
||||
.flex_wrap()
|
||||
@@ -1008,7 +1007,7 @@ impl SessionForm {
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id("session-route-note")
|
||||
.id("host-route-note")
|
||||
.test_support()
|
||||
.aria_label(JUMP_NOTE)
|
||||
.text_sm()
|
||||
@@ -1030,47 +1029,40 @@ impl SessionForm {
|
||||
Form::new()
|
||||
.columns(4)
|
||||
.child(
|
||||
Field::new().label("代理类型").col_span(4).child(
|
||||
Select::new(&self.proxy_kind)
|
||||
.id("session-proxy-kind")
|
||||
.small(),
|
||||
),
|
||||
Field::new()
|
||||
.label("代理类型")
|
||||
.col_span(4)
|
||||
.child(Select::new(&self.proxy_kind).id("host-proxy-kind").small()),
|
||||
)
|
||||
.child(
|
||||
Field::new()
|
||||
.label("代理地址")
|
||||
.required(true)
|
||||
.col_span(3)
|
||||
.child(
|
||||
Input::new(&self.proxy_host)
|
||||
.id("session-proxy-host")
|
||||
.small(),
|
||||
),
|
||||
.child(Input::new(&self.proxy_host).id("host-proxy-host").small()),
|
||||
)
|
||||
.child(
|
||||
Field::new().label("端口").required(true).child(
|
||||
Input::new(&self.proxy_port)
|
||||
.id("session-proxy-port")
|
||||
.small(),
|
||||
),
|
||||
Field::new()
|
||||
.label("端口")
|
||||
.required(true)
|
||||
.child(Input::new(&self.proxy_port).id("host-proxy-port").small()),
|
||||
)
|
||||
.child(
|
||||
Field::new().label("用户名").col_span(2).child(
|
||||
Input::new(&self.proxy_user)
|
||||
.id("session-proxy-user")
|
||||
.small(),
|
||||
),
|
||||
Field::new()
|
||||
.label("用户名")
|
||||
.col_span(2)
|
||||
.child(Input::new(&self.proxy_user).id("host-proxy-user").small()),
|
||||
)
|
||||
.child(
|
||||
Field::new()
|
||||
.label("密码")
|
||||
.col_span(2)
|
||||
.child(password.password_input("session-proxy-password")),
|
||||
.child(password.password_input("host-proxy-password")),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("session-route-note")
|
||||
.id("host-route-note")
|
||||
.test_support()
|
||||
.aria_label(PROXY_NOTE)
|
||||
.text_sm()
|
||||
@@ -1084,7 +1076,7 @@ impl SessionForm {
|
||||
const DELETED_HOST: &str = "已删除的主机";
|
||||
|
||||
/// The hosts that can still be added as a jump host: those not in `hops`.
|
||||
fn available_jump_hosts(hosts: &[JumpHost], hops: &[Option<SessionId>]) -> Vec<JumpHost> {
|
||||
fn available_jump_hosts(hosts: &[JumpHost], hops: &[Option<HostId>]) -> Vec<JumpHost> {
|
||||
hosts
|
||||
.iter()
|
||||
.filter(|host| !hops.contains(&Some(host.id)))
|
||||
@@ -1092,7 +1084,7 @@ fn available_jump_hosts(hosts: &[JumpHost], hops: &[Option<SessionId>]) -> Vec<J
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Render for SessionForm {
|
||||
impl Render for HostForm {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let source = self.source;
|
||||
let keychain = self.fields.read(cx).keychain_available();
|
||||
@@ -1110,26 +1102,26 @@ impl Render for SessionForm {
|
||||
.label("名称")
|
||||
.required(true)
|
||||
.col_span(4)
|
||||
.child(Input::new(&self.name).id("session-name").small()),
|
||||
.child(Input::new(&self.name).id("host-name").small()),
|
||||
)
|
||||
.child(
|
||||
Field::new()
|
||||
.label("地址")
|
||||
.required(true)
|
||||
.col_span(3)
|
||||
.child(Input::new(&self.host).id("session-host").small()),
|
||||
.child(Input::new(&self.address).id("host-address").small()),
|
||||
)
|
||||
.child(
|
||||
Field::new()
|
||||
.label("端口")
|
||||
.child(Input::new(&self.port).id("session-port").small()),
|
||||
.child(Input::new(&self.port).id("host-port").small()),
|
||||
)
|
||||
.child(
|
||||
Field::new()
|
||||
.label("认证方式")
|
||||
.col_span(4)
|
||||
.child(
|
||||
SegmentedControl::new("session-auth-source")
|
||||
SegmentedControl::new("host-auth-source")
|
||||
.selected_index(AuthSource::ALL.iter().position(|each| *each == source))
|
||||
.on_change(cx.listener(|this, ix: &usize, _, cx| {
|
||||
if let Some(source) = AuthSource::ALL.get(*ix) {
|
||||
@@ -1141,7 +1133,7 @@ impl Render for SessionForm {
|
||||
.when(source == AuthSource::NoPassword, |field| {
|
||||
field.description_fn(|_, _| {
|
||||
div()
|
||||
.id("session-no-password-note")
|
||||
.id("host-no-password-note")
|
||||
.test_support()
|
||||
.aria_label(NO_PASSWORD_NOTE)
|
||||
.child(NO_PASSWORD_NOTE)
|
||||
@@ -1166,7 +1158,7 @@ impl Render for SessionForm {
|
||||
.child(
|
||||
Field::new().label("备注").col_span(4).child(
|
||||
div()
|
||||
.id("session-notes")
|
||||
.id("host-notes")
|
||||
.test_support()
|
||||
.w_full()
|
||||
.child(Textarea::new(&self.notes).text_sm()),
|
||||
@@ -1229,7 +1221,7 @@ fn credential_summary(credential: &Credential, key_dir: Option<&Path>) -> Shared
|
||||
/// A trust question from the test's worker, with where to send the answer.
|
||||
type TrustQuestion = (UnknownHostPrompt, std::sync::mpsc::Sender<bool>);
|
||||
|
||||
/// Put a first-seen host key to the user, above the session dialog. Closing
|
||||
/// Put a first-seen host key to the user, above the host dialog. Closing
|
||||
/// the dialog any other way than trusting counts as declining.
|
||||
fn ask_to_trust(
|
||||
prompt: UnknownHostPrompt,
|
||||
@@ -1278,19 +1270,19 @@ fn connection_test_notification(result: Result<(), String>) -> Notification {
|
||||
|
||||
/// Whether this authentication kind can end up asking for a password.
|
||||
/// `Auto` walks agent, then keys, then password, so it can.
|
||||
/// Open the new-session (`editing == None`) or edit-session dialog.
|
||||
/// `preselect_group` fills in the group field of a new session, so creating
|
||||
/// Open the new-host (`editing == None`) or edit-host dialog.
|
||||
/// `preselect_group` fills in the group field of a new host, so creating
|
||||
/// one from a group's context menu lands it in that group. `tester` backs the
|
||||
/// dialog's 「测试连接」 button.
|
||||
pub fn open_session_dialog(
|
||||
editing: Option<SessionId>,
|
||||
pub fn open_host_dialog(
|
||||
editing: Option<HostId>,
|
||||
preselect_group: Option<GroupId>,
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
tester: SharedConnectionTester,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let form = cx.new(|cx| SessionForm::new(editing, preselect_group, store, tester, window, cx));
|
||||
let form = cx.new(|cx| HostForm::new(editing, preselect_group, store, tester, window, cx));
|
||||
let title: SharedString = if editing.is_some() {
|
||||
"编辑主机"
|
||||
} else {
|
||||
@@ -1377,11 +1369,11 @@ pub struct Dependents {
|
||||
pub jump_users: usize,
|
||||
}
|
||||
|
||||
/// Ask before deleting a session. `on_delete` runs when the user confirms.
|
||||
/// `affected` is whether tabs of the session are open and how many of them
|
||||
/// Ask before deleting a host. `on_delete` runs when the user confirms.
|
||||
/// `affected` is whether tabs of the host are open and how many of them
|
||||
/// are transferring.
|
||||
pub fn confirm_delete_session(
|
||||
session: &Session,
|
||||
pub fn confirm_delete_host(
|
||||
host: &Host,
|
||||
affected: (bool, usize),
|
||||
dependents: Dependents,
|
||||
on_delete: DeleteHandler,
|
||||
@@ -1390,17 +1382,17 @@ pub fn confirm_delete_session(
|
||||
) {
|
||||
let (closes_tabs, uploads) = affected;
|
||||
confirm_delete(
|
||||
&session.name,
|
||||
describe_session_delete(closes_tabs, uploads, dependents),
|
||||
&host.name,
|
||||
describe_host_delete(closes_tabs, uploads, dependents),
|
||||
on_delete,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// What the delete dialog says goes with the session. `None` for a session
|
||||
/// What the delete dialog says goes with the host. `None` for a host
|
||||
/// with nothing open and nothing depending on it.
|
||||
fn describe_session_delete(
|
||||
fn describe_host_delete(
|
||||
closes_tabs: bool,
|
||||
uploads: usize,
|
||||
dependents: Dependents,
|
||||
@@ -1429,7 +1421,7 @@ fn describe_session_delete(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Dependents, describe_session_delete};
|
||||
use super::{Dependents, describe_host_delete};
|
||||
|
||||
fn forwards(forwards: usize) -> Dependents {
|
||||
Dependents {
|
||||
@@ -1439,22 +1431,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_session_says_what_goes_with_it() {
|
||||
assert_eq!(describe_session_delete(false, 0, forwards(0)), None);
|
||||
fn deleting_a_host_says_what_goes_with_it() {
|
||||
assert_eq!(describe_host_delete(false, 0, forwards(0)), None);
|
||||
assert_eq!(
|
||||
describe_session_delete(true, 0, forwards(0)).as_deref(),
|
||||
describe_host_delete(true, 0, forwards(0)).as_deref(),
|
||||
Some("会一并关闭该主机已打开的终端和 SFTP 标签。")
|
||||
);
|
||||
assert_eq!(
|
||||
describe_session_delete(true, 2, forwards(0)).as_deref(),
|
||||
describe_host_delete(true, 2, forwards(0)).as_deref(),
|
||||
Some("会一并关闭该主机已打开的终端和 SFTP 标签。将停止 2 个传输批次并保留续传进度。")
|
||||
);
|
||||
assert_eq!(
|
||||
describe_session_delete(false, 0, forwards(3)).as_deref(),
|
||||
describe_host_delete(false, 0, forwards(3)).as_deref(),
|
||||
Some("将同时删除经由该主机的 3 条端口转发。")
|
||||
);
|
||||
assert_eq!(
|
||||
describe_session_delete(true, 0, forwards(1)).as_deref(),
|
||||
describe_host_delete(true, 0, forwards(1)).as_deref(),
|
||||
Some("会一并关闭该主机已打开的终端和 SFTP 标签。将同时删除经由该主机的 1 条端口转发。")
|
||||
);
|
||||
}
|
||||
@@ -1466,7 +1458,7 @@ mod tests {
|
||||
jump_users: 2,
|
||||
};
|
||||
assert_eq!(
|
||||
describe_session_delete(false, 0, dependents).as_deref(),
|
||||
describe_host_delete(false, 0, dependents).as_deref(),
|
||||
Some(
|
||||
"将同时删除经由该主机的 1 条端口转发。有 2 台主机把它用作跳板主机,删除后要重新选择跳板主机才能连接。"
|
||||
)
|
||||
@@ -26,53 +26,51 @@ use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CatalogIcon, CollapseAllGroups, ConnectGroup, ConnectSelected, ConnectSession, CopySessionId,
|
||||
DeleteGroup, DeleteSession, DuplicateSession, EditSession, ExpandAllGroups, MoveSessionNode,
|
||||
NewChildGroup, NewGroup, NewSession, NewSessionInGroup, OpenExplorer, RenameGroup,
|
||||
SESSION_PANEL_CONTEXT,
|
||||
CatalogIcon, CollapseAllGroups, ConnectGroup, ConnectHost, ConnectSelected, CopyHostId,
|
||||
DeleteGroup, DeleteHost, DuplicateHost, EditHost, ExpandAllGroups, HOST_PANEL_CONTEXT,
|
||||
MoveHostNode, NewChildGroup, NewGroup, NewHost, NewHostInGroup, OpenExplorer, RenameGroup,
|
||||
};
|
||||
|
||||
use crate::shared::HostMark;
|
||||
|
||||
use super::{
|
||||
GroupId, HostOs, NodeDrop, SessionId, SessionNode, SessionStore, matches_query,
|
||||
session_tree_items,
|
||||
GroupId, HostId, HostNode, HostOs, HostStore, NodeDrop, host_tree_items, matches_query,
|
||||
};
|
||||
|
||||
/// The session list of the left dock: a searchable, grouped tree of sessions.
|
||||
/// The host list of the left dock: a searchable, grouped tree of hosts.
|
||||
/// The workspace's sidebar shows it, and borrows its title and toolbar for
|
||||
/// the dock's title bar while it does.
|
||||
///
|
||||
/// Owns the tree and search state; the session data lives in the shared
|
||||
/// `SessionStore`, which this panel observes.
|
||||
pub struct SessionPanel {
|
||||
store: Entity<SessionStore>,
|
||||
/// Owns the tree and search state; the host data lives in the shared
|
||||
/// `HostStore`, which this panel observes.
|
||||
pub struct HostPanel {
|
||||
store: Entity<HostStore>,
|
||||
tree_state: Entity<TreeState>,
|
||||
search: Entity<InputState>,
|
||||
query: String,
|
||||
expanded: HashSet<GroupId>,
|
||||
/// What the store held the last time the tree was rebuilt. Comparing
|
||||
/// against these is how a group or session created in a dialog gets
|
||||
/// against these is how a group or host created in a dialog gets
|
||||
/// revealed without the dialog having to report back to the panel.
|
||||
known_groups: HashSet<GroupId>,
|
||||
known_sessions: HashSet<SessionId>,
|
||||
seen_hosts: HashSet<HostId>,
|
||||
/// Which node the last right-click landed on, `None` for the blank space
|
||||
/// below the rows. Shared with the row renderer and the context menu
|
||||
/// builder, both of which run outside this entity.
|
||||
right_clicked: Rc<Cell<Option<SessionNode>>>,
|
||||
drop_target: Rc<Cell<Option<(SessionNode, NodeDrop)>>>,
|
||||
/// Session rows' tooltips, which open beside the row rather than under
|
||||
right_clicked: Rc<Cell<Option<HostNode>>>,
|
||||
drop_target: Rc<Cell<Option<(HostNode, NodeDrop)>>>,
|
||||
/// Host rows' tooltips, which open beside the row rather than under
|
||||
/// the pointer so they never cover the rows below.
|
||||
row_tooltip: Entity<TooltipOverlay>,
|
||||
/// The row the tooltip was last opened for. See `render_row`.
|
||||
row_tooltip_owner: Rc<Cell<Option<SessionId>>>,
|
||||
row_tooltip_owner: Rc<Cell<Option<HostId>>>,
|
||||
focus_handle: FocusHandle,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SessionPanel {
|
||||
pub fn new(store: Entity<SessionStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let (expanded, known_groups, known_sessions, items) = {
|
||||
impl HostPanel {
|
||||
pub fn new(store: Entity<HostStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let (expanded, known_groups, seen_hosts, items) = {
|
||||
let read = store.read(cx);
|
||||
let expanded: HashSet<GroupId> = read
|
||||
.groups()
|
||||
@@ -80,11 +78,11 @@ impl SessionPanel {
|
||||
.filter(|group| group.expanded)
|
||||
.map(|group| group.id)
|
||||
.collect();
|
||||
let items = session_tree_items(read.groups(), read.sessions(), "", &expanded);
|
||||
let items = host_tree_items(read.groups(), read.hosts(), "", &expanded);
|
||||
(
|
||||
expanded,
|
||||
read.groups().iter().map(|group| group.id).collect(),
|
||||
read.sessions().iter().map(|s| s.id).collect(),
|
||||
read.hosts().iter().map(|s| s.id).collect(),
|
||||
items,
|
||||
)
|
||||
};
|
||||
@@ -117,7 +115,7 @@ impl SessionPanel {
|
||||
TreeEvent::Expanded(id) => (id, true),
|
||||
TreeEvent::Collapsed(id) => (id, false),
|
||||
};
|
||||
if let Some(SessionNode::Group(group)) = SessionNode::parse(id) {
|
||||
if let Some(HostNode::Group(group)) = HostNode::parse(id) {
|
||||
if expanded {
|
||||
this.expanded.insert(group);
|
||||
} else {
|
||||
@@ -137,7 +135,7 @@ impl SessionPanel {
|
||||
query: String::new(),
|
||||
expanded,
|
||||
known_groups,
|
||||
known_sessions,
|
||||
seen_hosts,
|
||||
right_clicked: Rc::new(Cell::new(None)),
|
||||
drop_target: Rc::new(Cell::new(None)),
|
||||
row_tooltip: cx.new(|_| TooltipOverlay::new().render_with(animate_row_tooltip)),
|
||||
@@ -148,7 +146,7 @@ impl SessionPanel {
|
||||
}
|
||||
|
||||
/// Rebuild the tree, then put the cursor on whatever the store just
|
||||
/// gained so a freshly created group or session is visible and selected.
|
||||
/// gained so a freshly created group or host is visible and selected.
|
||||
fn on_store_changed(&mut self, cx: &mut Context<Self>) {
|
||||
let created = self.take_created_node(cx);
|
||||
self.rebuild_tree(cx);
|
||||
@@ -159,22 +157,22 @@ impl SessionPanel {
|
||||
|
||||
/// The node the store gained since the last rebuild, if any, with the
|
||||
/// folders above it opened so it can be scrolled to.
|
||||
fn take_created_node(&mut self, cx: &mut Context<Self>) -> Option<SessionNode> {
|
||||
let (created, ancestors, groups, sessions) = {
|
||||
fn take_created_node(&mut self, cx: &mut Context<Self>) -> Option<HostNode> {
|
||||
let (created, ancestors, groups, hosts) = {
|
||||
let store = self.store.read(cx);
|
||||
let created = store
|
||||
.groups()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|group| !self.known_groups.contains(&group.id))
|
||||
.map(|group| SessionNode::Group(group.id))
|
||||
.map(|group| HostNode::Group(group.id))
|
||||
.or_else(|| {
|
||||
store
|
||||
.sessions()
|
||||
.hosts()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|session| !self.known_sessions.contains(&session.id))
|
||||
.map(|session| SessionNode::Session(session.id))
|
||||
.find(|host| !self.seen_hosts.contains(&host.id))
|
||||
.map(|host| HostNode::Host(host.id))
|
||||
});
|
||||
let ancestors = created
|
||||
.map(|node| groups_above(store, node))
|
||||
@@ -183,13 +181,13 @@ impl SessionPanel {
|
||||
created,
|
||||
ancestors,
|
||||
store.groups().iter().map(|g| g.id).collect(),
|
||||
store.sessions().iter().map(|s| s.id).collect(),
|
||||
store.hosts().iter().map(|s| s.id).collect(),
|
||||
)
|
||||
};
|
||||
self.known_groups = groups;
|
||||
self.known_sessions = sessions;
|
||||
self.seen_hosts = hosts;
|
||||
self.expanded.extend(ancestors);
|
||||
if let Some(SessionNode::Group(id)) = created
|
||||
if let Some(HostNode::Group(id)) = created
|
||||
&& self
|
||||
.store
|
||||
.read(cx)
|
||||
@@ -205,12 +203,7 @@ impl SessionPanel {
|
||||
fn rebuild_tree(&mut self, cx: &mut Context<Self>) {
|
||||
let items = {
|
||||
let store = self.store.read(cx);
|
||||
session_tree_items(
|
||||
store.groups(),
|
||||
store.sessions(),
|
||||
&self.query,
|
||||
&self.expanded,
|
||||
)
|
||||
host_tree_items(store.groups(), store.hosts(), &self.query, &self.expanded)
|
||||
};
|
||||
let selected_id = self
|
||||
.tree_state
|
||||
@@ -227,7 +220,7 @@ impl SessionPanel {
|
||||
|
||||
/// Select (and reveal) a row, for example a fresh duplicate or a group
|
||||
/// just created.
|
||||
pub fn select_node(&mut self, node: SessionNode, cx: &mut Context<Self>) {
|
||||
pub fn select_node(&mut self, node: HostNode, cx: &mut Context<Self>) {
|
||||
self.rebuild_tree(cx);
|
||||
let row_id = node.id();
|
||||
self.tree_state.update(cx, |state, cx| {
|
||||
@@ -238,7 +231,7 @@ impl SessionPanel {
|
||||
}
|
||||
|
||||
/// Keep the moved row visible even when it was dropped into a closed group.
|
||||
pub fn reveal_node(&mut self, node: SessionNode, cx: &mut Context<Self>) {
|
||||
pub fn reveal_node(&mut self, node: HostNode, cx: &mut Context<Self>) {
|
||||
let ancestors = groups_above(self.store.read(cx), node);
|
||||
self.expanded.extend(ancestors);
|
||||
self.select_node(node, cx);
|
||||
@@ -265,23 +258,23 @@ impl SessionPanel {
|
||||
}
|
||||
|
||||
/// The node currently selected in the tree.
|
||||
pub fn selected_node(&self, cx: &App) -> Option<SessionNode> {
|
||||
pub fn selected_node(&self, cx: &App) -> Option<HostNode> {
|
||||
self.tree_state
|
||||
.read(cx)
|
||||
.selected_item()
|
||||
.and_then(|item| SessionNode::parse(&item.id))
|
||||
.and_then(|item| HostNode::parse(&item.id))
|
||||
}
|
||||
|
||||
fn connect_first_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let first = self
|
||||
.store
|
||||
.read(cx)
|
||||
.sessions()
|
||||
.hosts()
|
||||
.iter()
|
||||
.find(|session| matches_query(session, &self.query))
|
||||
.map(|session| session.id);
|
||||
.find(|host| matches_query(host, &self.query))
|
||||
.map(|host| host.id);
|
||||
if let Some(id) = first {
|
||||
window.dispatch_action(Box::new(ConnectSession(id)), cx);
|
||||
window.dispatch_action(Box::new(ConnectHost(id)), cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,10 +285,10 @@ impl SessionPanel {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match self.selected_node(cx) {
|
||||
Some(SessionNode::Session(id)) => {
|
||||
window.dispatch_action(Box::new(ConnectSession(id)), cx);
|
||||
Some(HostNode::Host(id)) => {
|
||||
window.dispatch_action(Box::new(ConnectHost(id)), cx);
|
||||
}
|
||||
Some(SessionNode::Group(group)) => {
|
||||
Some(HostNode::Group(group)) => {
|
||||
if !self.query.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -317,10 +310,10 @@ impl SessionPanel {
|
||||
|
||||
/// Every group a node sits in, at any depth: the folders that have to be
|
||||
/// open for its row to show.
|
||||
fn groups_above(store: &SessionStore, node: SessionNode) -> Vec<GroupId> {
|
||||
fn groups_above(store: &HostStore, node: HostNode) -> Vec<GroupId> {
|
||||
let parent = match node {
|
||||
SessionNode::Group(id) => store.group(id).and_then(|group| group.parent),
|
||||
SessionNode::Session(id) => store.session(id).and_then(|session| session.group),
|
||||
HostNode::Group(id) => store.group(id).and_then(|group| group.parent),
|
||||
HostNode::Host(id) => store.host(id).and_then(|host| host.group),
|
||||
};
|
||||
parent
|
||||
.map(|id| {
|
||||
@@ -331,17 +324,17 @@ fn groups_above(store: &SessionStore, node: SessionNode) -> Vec<GroupId> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for SessionPanel {}
|
||||
impl EventEmitter<PanelEvent> for HostPanel {}
|
||||
|
||||
impl Focusable for SessionPanel {
|
||||
impl Focusable for HostPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for SessionPanel {
|
||||
impl BasePanel for HostPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"SessionPanel"
|
||||
"HostPanel"
|
||||
}
|
||||
|
||||
fn closable(&self, _: &App) -> bool {
|
||||
@@ -353,7 +346,7 @@ impl BasePanel for SessionPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for SessionPanel {
|
||||
impl Panel for HostPanel {
|
||||
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
@@ -364,10 +357,10 @@ impl Panel for SessionPanel {
|
||||
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
|
||||
// A new host first: it is what the list is mostly added to.
|
||||
Some(vec![
|
||||
Button::new("new-session-panel")
|
||||
Button::new("new-host-panel")
|
||||
.icon(IconName::Plus)
|
||||
.tooltip("新建主机…")
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(NewSession), cx)),
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(NewHost), cx)),
|
||||
Button::new("new-group")
|
||||
.icon(Icon::new(CatalogIcon::FolderPlus))
|
||||
.tooltip("新建分组…")
|
||||
@@ -384,24 +377,24 @@ impl Panel for SessionPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SessionPanel {
|
||||
impl Render for HostPanel {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// Plain snapshots for the row renderer: render callbacks must not
|
||||
// read entities.
|
||||
let host_os: Rc<HashMap<SessionId, HostOs>> = Rc::new(
|
||||
let host_os: Rc<HashMap<HostId, HostOs>> = Rc::new(
|
||||
self.store
|
||||
.read(cx)
|
||||
.sessions()
|
||||
.hosts()
|
||||
.iter()
|
||||
.filter_map(|session| session.os.map(|os| (session.id, os)))
|
||||
.filter_map(|host| host.os.map(|os| (host.id, os)))
|
||||
.collect(),
|
||||
);
|
||||
let addresses: Rc<HashMap<SessionId, SharedString>> = Rc::new(
|
||||
let addresses: Rc<HashMap<HostId, SharedString>> = Rc::new(
|
||||
self.store
|
||||
.read(cx)
|
||||
.sessions()
|
||||
.hosts()
|
||||
.iter()
|
||||
.map(|session| (session.id, session.address().into()))
|
||||
.map(|host| (host.id, host.endpoint().into()))
|
||||
.collect(),
|
||||
);
|
||||
let group_parents: Rc<HashMap<GroupId, Option<GroupId>>> = Rc::new(
|
||||
@@ -413,7 +406,7 @@ impl Render for SessionPanel {
|
||||
.collect(),
|
||||
);
|
||||
let group_counts = Rc::new(group_host_counts(
|
||||
self.store.read(cx).sessions(),
|
||||
self.store.read(cx).hosts(),
|
||||
&group_parents,
|
||||
));
|
||||
let clicked_row = self.right_clicked.clone();
|
||||
@@ -435,8 +428,8 @@ impl Render for SessionPanel {
|
||||
let tree_scroll = self.tree_state.read(cx).scroll_handle().clone();
|
||||
|
||||
v_flex()
|
||||
.id("session-panel")
|
||||
.key_context(SESSION_PANEL_CONTEXT)
|
||||
.id("host-panel")
|
||||
.key_context(HOST_PANEL_CONTEXT)
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_connect_selected))
|
||||
.size_full()
|
||||
@@ -445,7 +438,7 @@ impl Render for SessionPanel {
|
||||
.child(
|
||||
div().p_2().child(
|
||||
Input::new(&self.search)
|
||||
.id("session-search")
|
||||
.id("host-search")
|
||||
.small()
|
||||
.cleanable(true)
|
||||
.prefix(Icon::new(IconName::Search).small()),
|
||||
@@ -453,7 +446,7 @@ impl Render for SessionPanel {
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("session-tree")
|
||||
.id("host-tree")
|
||||
.test_support()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
@@ -464,7 +457,7 @@ impl Render for SessionPanel {
|
||||
.child(
|
||||
BaseTree::new(&self.tree_state)
|
||||
.item(move |_, entry, state, _, cx| {
|
||||
let node = SessionNode::parse(&entry.item().id);
|
||||
let node = HostNode::parse(&entry.item().id);
|
||||
let selected = match row_interactions.right_clicked.get() {
|
||||
Some(right_clicked) => node == Some(right_clicked),
|
||||
None => state.is_selected(),
|
||||
@@ -502,14 +495,14 @@ impl Render for SessionPanel {
|
||||
}
|
||||
})
|
||||
.when(can_reorder, |view| {
|
||||
view.on_drag_move(move |_: &DragMoveEvent<DraggedSessionNode>, _, _| {
|
||||
view.on_drag_move(move |_: &DragMoveEvent<DraggedHostNode>, _, _| {
|
||||
drop_target_for_move.set(None);
|
||||
})
|
||||
.on_drop(
|
||||
move |drag: &DraggedSessionNode, window, cx| {
|
||||
move |drag: &DraggedHostNode, window, cx| {
|
||||
if drop_target_for_root.get().is_none() {
|
||||
window.dispatch_action(
|
||||
Box::new(MoveSessionNode {
|
||||
Box::new(MoveHostNode {
|
||||
source: drag.node,
|
||||
destination: NodeDrop::Root,
|
||||
}),
|
||||
@@ -526,8 +519,8 @@ impl Render for SessionPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that is not a session still has to reserve the badge's width, or the
|
||||
/// labels of groups and sessions would not line up.
|
||||
/// A row that is not a host still has to reserve the badge's width, or the
|
||||
/// labels of groups and hosts would not line up.
|
||||
fn plain_mark(icon: Icon, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
@@ -540,27 +533,27 @@ fn plain_mark(icon: Icon, cx: &App) -> AnyElement {
|
||||
|
||||
struct RowInteractions {
|
||||
group_parents: Rc<HashMap<GroupId, Option<GroupId>>>,
|
||||
right_clicked: Rc<Cell<Option<SessionNode>>>,
|
||||
drop_target: Rc<Cell<Option<(SessionNode, NodeDrop)>>>,
|
||||
right_clicked: Rc<Cell<Option<HostNode>>>,
|
||||
drop_target: Rc<Cell<Option<(HostNode, NodeDrop)>>>,
|
||||
can_reorder: bool,
|
||||
/// `user@host:port` of every session, for the row tooltips.
|
||||
addresses: Rc<HashMap<SessionId, SharedString>>,
|
||||
/// `user@host:port` of every host, for the row tooltips.
|
||||
addresses: Rc<HashMap<HostId, SharedString>>,
|
||||
tooltip: Entity<TooltipOverlay>,
|
||||
tooltip_owner: Rc<Cell<Option<SessionId>>>,
|
||||
tooltip_owner: Rc<Cell<Option<HostId>>>,
|
||||
}
|
||||
|
||||
/// A session row's tooltip: where the session logs in, as `user@host:port`.
|
||||
/// A host row's tooltip: where the host logs in, as `user@host:port`.
|
||||
///
|
||||
/// Drawn in the theme's inverse, dark on the light theme and light on the
|
||||
/// dark one, so it stands out against the sidebar and the terminal in both.
|
||||
/// Only this tooltip: gpui-kit's others share the popover colour with menus.
|
||||
fn session_tooltip(address: SharedString) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static {
|
||||
fn host_tooltip(address: SharedString) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static {
|
||||
move |window, cx| {
|
||||
let address = address.clone();
|
||||
let (background, foreground) = (cx.theme().foreground, cx.theme().background);
|
||||
Tooltip::element(move |_, _| {
|
||||
div()
|
||||
.id("session-tooltip")
|
||||
.id("host-tooltip")
|
||||
.test_support()
|
||||
.aria_label(address.clone())
|
||||
.child(address.clone())
|
||||
@@ -588,7 +581,7 @@ fn animate_row_tooltip(
|
||||
.fade(0., 1.)
|
||||
.apply(
|
||||
tooltip,
|
||||
ElementId::NamedInteger("session-tooltip-enter".into(), epoch as u64),
|
||||
ElementId::NamedInteger("host-tooltip-enter".into(), epoch as u64),
|
||||
)
|
||||
.into_any_element(),
|
||||
TooltipTransition::Switch {
|
||||
@@ -600,7 +593,7 @@ fn animate_row_tooltip(
|
||||
.slide_y(previous.center().y - current.center().y, px(0.))
|
||||
.apply(
|
||||
tooltip,
|
||||
ElementId::NamedInteger("session-tooltip-move".into(), epoch as u64),
|
||||
ElementId::NamedInteger("host-tooltip-move".into(), epoch as u64),
|
||||
)
|
||||
.into_any_element(),
|
||||
}
|
||||
@@ -608,12 +601,12 @@ fn animate_row_tooltip(
|
||||
|
||||
/// Count hosts in each group, including hosts in nested child groups.
|
||||
fn group_host_counts(
|
||||
sessions: &[super::Session],
|
||||
hosts: &[super::Host],
|
||||
parents: &HashMap<GroupId, Option<GroupId>>,
|
||||
) -> HashMap<GroupId, usize> {
|
||||
let mut counts = HashMap::new();
|
||||
for session in sessions {
|
||||
let mut group = session.group;
|
||||
for host in hosts {
|
||||
let mut group = host.group;
|
||||
for _ in 0..parents.len() {
|
||||
let Some(id) = group else { break };
|
||||
*counts.entry(id).or_insert(0) += 1;
|
||||
@@ -627,7 +620,7 @@ fn render_row(
|
||||
entry: &TreeEntry,
|
||||
selected: bool,
|
||||
group_counts: &HashMap<GroupId, usize>,
|
||||
host_os: &HashMap<SessionId, HostOs>,
|
||||
host_os: &HashMap<HostId, HostOs>,
|
||||
interactions: &RowInteractions,
|
||||
cx: &mut App,
|
||||
) -> ListItem {
|
||||
@@ -641,9 +634,9 @@ fn render_row(
|
||||
tooltip_owner,
|
||||
} = interactions;
|
||||
let item = entry.item();
|
||||
let node = SessionNode::parse(&item.id);
|
||||
let node = HostNode::parse(&item.id);
|
||||
let (mark, row_id): (AnyElement, ElementId) = match node {
|
||||
Some(SessionNode::Group(id)) => (
|
||||
Some(HostNode::Group(id)) => (
|
||||
plain_mark(
|
||||
Icon::new(if entry.is_expanded() {
|
||||
IconName::FolderOpen
|
||||
@@ -654,23 +647,23 @@ fn render_row(
|
||||
),
|
||||
("group-row", id.0).into(),
|
||||
),
|
||||
Some(SessionNode::Session(id)) => (
|
||||
Some(HostNode::Host(id)) => (
|
||||
HostMark::new(
|
||||
("session-os", id.0),
|
||||
("host-os", id.0),
|
||||
item.label.clone(),
|
||||
host_os.get(&id).copied(),
|
||||
)
|
||||
.small()
|
||||
.without_tooltip()
|
||||
.into_any_element(),
|
||||
("session-row", id.0).into(),
|
||||
("host-row", id.0).into(),
|
||||
),
|
||||
None => (
|
||||
plain_mark(Icon::new(IconName::File), cx),
|
||||
item.id.clone().into(),
|
||||
),
|
||||
};
|
||||
let session_id = node.and_then(SessionNode::session_id);
|
||||
let host_id = node.and_then(HostNode::host_id);
|
||||
|
||||
ListItem::new(row_id)
|
||||
.w_full()
|
||||
@@ -679,11 +672,11 @@ fn render_row(
|
||||
// Make selection stronger than hover against the sidebar background.
|
||||
.confirmed(selected)
|
||||
.when(selected, |row| {
|
||||
row.bg(crate::app::session_tree_selection_color(cx.theme()))
|
||||
row.bg(crate::app::host_tree_selection_color(cx.theme()))
|
||||
})
|
||||
.pl(rems(0.75 + entry.depth() as f32))
|
||||
.child(h_flex().gap_2().child(mark).child(item.label.clone()))
|
||||
.when_some(node.and_then(SessionNode::group_id), |row, id| {
|
||||
.when_some(node.and_then(HostNode::group_id), |row, id| {
|
||||
let count = group_counts.get(&id).copied().unwrap_or(0);
|
||||
row.suffix(move |_, cx| {
|
||||
div()
|
||||
@@ -696,15 +689,15 @@ fn render_row(
|
||||
.child(count.to_string())
|
||||
})
|
||||
})
|
||||
.when_some(session_id, |row, id| {
|
||||
.when_some(host_id, |row, id| {
|
||||
row.on_click(move |event: &ClickEvent, window, cx| {
|
||||
if event.click_count() == 2 {
|
||||
window.dispatch_action(Box::new(ConnectSession(id)), cx);
|
||||
window.dispatch_action(Box::new(ConnectHost(id)), cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.when_some(session_id, |row, id| {
|
||||
let content = Rc::new(session_tooltip(
|
||||
.when_some(host_id, |row, id| {
|
||||
let content = Rc::new(host_tooltip(
|
||||
addresses.get(&id).cloned().unwrap_or_default(),
|
||||
));
|
||||
let bounds = Rc::new(Cell::new(Bounds::default()));
|
||||
@@ -761,17 +754,17 @@ fn render_row(
|
||||
let drop_target_for_drop = drop_target.clone();
|
||||
let parents_for_move = group_parents.clone();
|
||||
let label = item.label.clone();
|
||||
row.on_drag(DraggedSessionNode { node, label }, |drag, _, _, cx| {
|
||||
row.on_drag(DraggedHostNode { node, label }, |drag, _, _, cx| {
|
||||
cx.new(|_| drag.clone())
|
||||
})
|
||||
.on_drag_move(move |event: &DragMoveEvent<DraggedSessionNode>, _, cx| {
|
||||
.on_drag_move(move |event: &DragMoveEvent<DraggedHostNode>, _, cx| {
|
||||
if event.bounds.contains(&event.event.position) {
|
||||
let source = event.drag(cx).node;
|
||||
let relative = (event.event.position.y - event.bounds.top())
|
||||
/ event.bounds.size.height;
|
||||
let destination = match (source, node) {
|
||||
(SessionNode::Session(_), SessionNode::Group(id)) => NodeDrop::Into(id),
|
||||
(_, SessionNode::Group(id)) if relative > 0.25 && relative < 0.75 => {
|
||||
(HostNode::Host(_), HostNode::Group(id)) => NodeDrop::Into(id),
|
||||
(_, HostNode::Group(id)) if relative > 0.25 && relative < 0.75 => {
|
||||
NodeDrop::Into(id)
|
||||
}
|
||||
_ if relative < 0.5 => NodeDrop::Before(node),
|
||||
@@ -782,7 +775,7 @@ fn render_row(
|
||||
}
|
||||
}
|
||||
})
|
||||
.drag_over::<DraggedSessionNode>(move |style, drag, _, cx| {
|
||||
.drag_over::<DraggedHostNode>(move |style, drag, _, cx| {
|
||||
if drag.node == node {
|
||||
return style;
|
||||
}
|
||||
@@ -800,13 +793,13 @@ fn render_row(
|
||||
_ => style,
|
||||
}
|
||||
})
|
||||
.on_drop(move |drag: &DraggedSessionNode, window, cx| {
|
||||
.on_drop(move |drag: &DraggedHostNode, window, cx| {
|
||||
if let Some((target, destination)) = drop_target_for_drop.get()
|
||||
&& target == node
|
||||
&& drag.node != node
|
||||
{
|
||||
window.dispatch_action(
|
||||
Box::new(MoveSessionNode {
|
||||
Box::new(MoveHostNode {
|
||||
source: drag.node,
|
||||
destination,
|
||||
}),
|
||||
@@ -821,12 +814,12 @@ fn render_row(
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DraggedSessionNode {
|
||||
node: SessionNode,
|
||||
struct DraggedHostNode {
|
||||
node: HostNode,
|
||||
label: SharedString,
|
||||
}
|
||||
|
||||
impl Render for DraggedSessionNode {
|
||||
impl Render for DraggedHostNode {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.px_2()
|
||||
@@ -842,7 +835,7 @@ impl Render for DraggedSessionNode {
|
||||
|
||||
/// Use the render snapshot to keep impossible group drops from looking active.
|
||||
fn valid_drop(
|
||||
source: SessionNode,
|
||||
source: HostNode,
|
||||
destination: NodeDrop,
|
||||
parents: &HashMap<GroupId, Option<GroupId>>,
|
||||
) -> bool {
|
||||
@@ -854,17 +847,17 @@ fn valid_drop(
|
||||
return false;
|
||||
}
|
||||
match source {
|
||||
SessionNode::Session(_) => match destination {
|
||||
NodeDrop::Before(SessionNode::Session(_))
|
||||
| NodeDrop::After(SessionNode::Session(_))
|
||||
HostNode::Host(_) => match destination {
|
||||
NodeDrop::Before(HostNode::Host(_))
|
||||
| NodeDrop::After(HostNode::Host(_))
|
||||
| NodeDrop::Root => true,
|
||||
NodeDrop::Into(group) => parents.contains_key(&group),
|
||||
_ => false,
|
||||
},
|
||||
SessionNode::Group(id) => {
|
||||
HostNode::Group(id) => {
|
||||
let mut parent = match destination {
|
||||
NodeDrop::Before(SessionNode::Group(target))
|
||||
| NodeDrop::After(SessionNode::Group(target)) => match parents.get(&target) {
|
||||
NodeDrop::Before(HostNode::Group(target))
|
||||
| NodeDrop::After(HostNode::Group(target)) => match parents.get(&target) {
|
||||
Some(parent) => *parent,
|
||||
None => return false,
|
||||
},
|
||||
@@ -889,13 +882,13 @@ fn valid_drop(
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu of one session, wherever it is listed: the session tree and the
|
||||
/// start page's recent sessions both build it here, so they cannot drift.
|
||||
pub fn session_menu(menu: PopupMenu, id: SessionId) -> PopupMenu {
|
||||
/// The menu of one host, wherever it is listed: the host tree and the
|
||||
/// start page's recent hosts both build it here, so they cannot drift.
|
||||
pub fn host_menu(menu: PopupMenu, id: HostId) -> PopupMenu {
|
||||
menu.menu_with_icon(
|
||||
"连接",
|
||||
Icon::new(CatalogIcon::Plug),
|
||||
Box::new(ConnectSession(id)),
|
||||
Box::new(ConnectHost(id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"打开 SFTP",
|
||||
@@ -906,31 +899,31 @@ pub fn session_menu(menu: PopupMenu, id: SessionId) -> PopupMenu {
|
||||
.menu_with_icon(
|
||||
"编辑主机…",
|
||||
Icon::new(CatalogIcon::Pencil),
|
||||
Box::new(EditSession(id)),
|
||||
Box::new(EditHost(id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"复制",
|
||||
Icon::new(IconName::Copy),
|
||||
Box::new(DuplicateSession(id)),
|
||||
Box::new(DuplicateHost(id)),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"复制 ID",
|
||||
Icon::new(CatalogIcon::ClipboardCopy),
|
||||
Box::new(CopySessionId(id)),
|
||||
Box::new(CopyHostId(id)),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"删除",
|
||||
Icon::new(CatalogIcon::Trash),
|
||||
Box::new(DeleteSession(id)),
|
||||
Box::new(DeleteHost(id)),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_context_menu(node: Option<SessionNode>, menu: PopupMenu) -> PopupMenu {
|
||||
fn build_context_menu(node: Option<HostNode>, menu: PopupMenu) -> PopupMenu {
|
||||
match node {
|
||||
Some(SessionNode::Session(id)) => session_menu(menu, id),
|
||||
Some(SessionNode::Group(id)) => menu
|
||||
Some(HostNode::Host(id)) => host_menu(menu, id),
|
||||
Some(HostNode::Group(id)) => menu
|
||||
.menu_with_icon(
|
||||
"连接组内主机",
|
||||
Icon::new(CatalogIcon::Plug),
|
||||
@@ -940,7 +933,7 @@ fn build_context_menu(node: Option<SessionNode>, menu: PopupMenu) -> PopupMenu {
|
||||
.menu_with_icon(
|
||||
"新建主机…",
|
||||
Icon::new(IconName::Plus),
|
||||
Box::new(NewSessionInGroup(id)),
|
||||
Box::new(NewHostInGroup(id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"新建子分组…",
|
||||
@@ -971,7 +964,7 @@ fn build_context_menu(node: Option<SessionNode>, menu: PopupMenu) -> PopupMenu {
|
||||
Box::new(DeleteGroup(id)),
|
||||
),
|
||||
None => menu
|
||||
.menu_with_icon("新建主机…", Icon::new(IconName::Plus), Box::new(NewSession))
|
||||
.menu_with_icon("新建主机…", Icon::new(IconName::Plus), Box::new(NewHost))
|
||||
.menu_with_icon(
|
||||
"新建分组…",
|
||||
Icon::new(CatalogIcon::FolderPlus),
|
||||
@@ -7,7 +7,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::secrets::SecretRef;
|
||||
|
||||
use super::{AuthKind, Credential, CredentialKind, ProxyKind, ProxySettings, Session};
|
||||
use super::{AuthKind, Credential, CredentialKind, Host, ProxyKind, ProxySettings};
|
||||
|
||||
/// Which authentication methods a login tries. Each one starts by asking
|
||||
/// the server to let the user in with nothing at all, which a server without
|
||||
@@ -30,7 +30,7 @@ pub enum LoginMethod {
|
||||
/// nothing that only changes what the user sees, such as a name.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct SessionLogin {
|
||||
pub struct HostLogin {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
@@ -74,7 +74,7 @@ pub enum JumpLogin {
|
||||
/// What the user calls it, for the questions and errors of its hop.
|
||||
name: String,
|
||||
/// How it logs in, directly from the hop before it.
|
||||
login: Box<SessionLogin>,
|
||||
login: Box<HostLogin>,
|
||||
},
|
||||
/// A jump host that has been deleted: the route cannot be taken.
|
||||
Deleted,
|
||||
@@ -127,7 +127,7 @@ impl From<&ProxySettings> for ProxyLogin {
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionLogin {
|
||||
impl HostLogin {
|
||||
/// A login of the host's own rather than taken from a credential.
|
||||
pub fn manual(
|
||||
host: impl Into<String>,
|
||||
@@ -186,20 +186,18 @@ impl SessionLogin {
|
||||
self
|
||||
}
|
||||
|
||||
/// How `session` logs in, given the credential it uses, as if it were
|
||||
/// How `host` logs in, given the credential it uses, as if it were
|
||||
/// reached directly: its route takes the store to look up. `credential`
|
||||
/// is ignored unless it is the one the session names, so a session whose
|
||||
/// is ignored unless it is the one the host names, so a host whose
|
||||
/// credential has gone logs in with what the form holds.
|
||||
pub fn of(session: &Session, credential: Option<&Credential>) -> Self {
|
||||
match credential.filter(|credential| session.credential == Some(credential.id)) {
|
||||
Some(credential) => {
|
||||
Self::with_credential(session.host.as_ref(), session.port, credential)
|
||||
}
|
||||
pub fn of(host: &Host, credential: Option<&Credential>) -> Self {
|
||||
match credential.filter(|credential| host.credential == Some(credential.id)) {
|
||||
Some(credential) => Self::with_credential(host.address.as_ref(), host.port, credential),
|
||||
None => Self::manual(
|
||||
session.host.as_ref(),
|
||||
session.port,
|
||||
session.user.as_ref(),
|
||||
session.auth,
|
||||
host.address.as_ref(),
|
||||
host.port,
|
||||
host.user.as_ref(),
|
||||
host.auth,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -212,13 +210,13 @@ impl SessionLogin {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{CredentialDraft, CredentialId, SessionDraft, SessionId};
|
||||
use super::super::{CredentialDraft, CredentialId, HostDraft, HostId};
|
||||
use super::*;
|
||||
|
||||
fn session(auth: AuthKind) -> Session {
|
||||
Session::new(
|
||||
SessionId(1),
|
||||
SessionDraft::new("web", "10.0.0.1", 22, "root", auth, None),
|
||||
fn host(auth: AuthKind) -> Host {
|
||||
Host::new(
|
||||
HostId(1),
|
||||
HostDraft::new("web", "10.0.0.1", 22, "root", auth, None),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -231,16 +229,16 @@ mod tests {
|
||||
Credential::new(CredentialId(7), draft)
|
||||
}
|
||||
|
||||
fn using(kind: CredentialKind) -> (Session, Credential) {
|
||||
fn using(kind: CredentialKind) -> (Host, Credential) {
|
||||
let credential = credential(kind);
|
||||
let mut session = session(AuthKind::Password);
|
||||
session.credential = Some(credential.id);
|
||||
(session, credential)
|
||||
let mut host = host(AuthKind::Password);
|
||||
host.credential = Some(credential.id);
|
||||
(host, credential)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manual_host_logs_in_with_its_endpoint_password() {
|
||||
let login = SessionLogin::of(&session(AuthKind::Password), None);
|
||||
let login = HostLogin::of(&host(AuthKind::Password), None);
|
||||
assert_eq!(login.method, LoginMethod::Password);
|
||||
assert_eq!(login.user, "root");
|
||||
assert_eq!(login.password, SecretRef::password("root", "10.0.0.1", 22));
|
||||
@@ -249,7 +247,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_host_without_a_password_keeps_its_endpoint_for_a_partial_success() {
|
||||
let login = SessionLogin::of(&session(AuthKind::NoPassword), None);
|
||||
let login = HostLogin::of(&host(AuthKind::NoPassword), None);
|
||||
assert_eq!(login.method, LoginMethod::NoPassword);
|
||||
assert_eq!(login.key_path, None);
|
||||
assert_eq!(login.password, SecretRef::password("root", "10.0.0.1", 22));
|
||||
@@ -257,8 +255,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_password_credential_logs_in_with_its_own_keychain_entry() {
|
||||
let (session, credential) = using(CredentialKind::Password);
|
||||
let login = SessionLogin::of(&session, Some(&credential));
|
||||
let (host, credential) = using(CredentialKind::Password);
|
||||
let login = HostLogin::of(&host, Some(&credential));
|
||||
assert_eq!(login.method, LoginMethod::Password);
|
||||
assert_eq!(login.user, "deploy");
|
||||
assert_eq!(login.password, credential.password_secret());
|
||||
@@ -266,14 +264,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn key_and_agent_credentials_keep_the_endpoint_for_a_partial_success() {
|
||||
let (session, credential) = using(CredentialKind::Key);
|
||||
let key = SessionLogin::of(&session, Some(&credential));
|
||||
let (host, credential) = using(CredentialKind::Key);
|
||||
let key = HostLogin::of(&host, Some(&credential));
|
||||
assert_eq!(key.method, LoginMethod::Key);
|
||||
assert_eq!(key.key_path, Some(PathBuf::from("/tmp/id_deploy")));
|
||||
assert_eq!(key.password, SecretRef::password("deploy", "10.0.0.1", 22));
|
||||
|
||||
let (session, credential) = using(CredentialKind::Agent);
|
||||
let agent = SessionLogin::of(&session, Some(&credential));
|
||||
let (host, credential) = using(CredentialKind::Agent);
|
||||
let agent = HostLogin::of(&host, Some(&credential));
|
||||
assert_eq!(agent.method, LoginMethod::Agent);
|
||||
assert_eq!(agent.key_path, None);
|
||||
assert_eq!(
|
||||
@@ -285,7 +283,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_credential_the_host_does_not_name_is_not_used() {
|
||||
let credential = credential(CredentialKind::Password);
|
||||
let login = SessionLogin::of(&session(AuthKind::NoPassword), Some(&credential));
|
||||
let login = HostLogin::of(&host(AuthKind::NoPassword), Some(&credential));
|
||||
assert_eq!(login.method, LoginMethod::NoPassword);
|
||||
assert_eq!(login.user, "root");
|
||||
}
|
||||
@@ -294,7 +292,7 @@ mod tests {
|
||||
fn a_jump_hosts_name_is_not_part_of_the_login() {
|
||||
let hop = |name: &str, host: &str| JumpLogin::Host {
|
||||
name: name.into(),
|
||||
login: Box::new(SessionLogin::manual(host, 22, "root", AuthKind::Password)),
|
||||
login: Box::new(HostLogin::manual(host, 22, "root", AuthKind::Password)),
|
||||
};
|
||||
assert_eq!(hop("阿里云99", "10.0.0.1"), hop("跳板", "10.0.0.1"));
|
||||
assert_ne!(hop("阿里云99", "10.0.0.1"), hop("阿里云99", "10.0.0.2"));
|
||||
@@ -324,11 +322,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn renaming_a_credential_does_not_change_the_login() {
|
||||
let (session, mut credential) = using(CredentialKind::Password);
|
||||
let before = SessionLogin::of(&session, Some(&credential));
|
||||
let (host, mut credential) = using(CredentialKind::Password);
|
||||
let before = HostLogin::of(&host, Some(&credential));
|
||||
credential.name = "别的名字".into();
|
||||
assert_eq!(SessionLogin::of(&session, Some(&credential)), before);
|
||||
assert_eq!(HostLogin::of(&host, Some(&credential)), before);
|
||||
credential.user = "admin".into();
|
||||
assert_ne!(SessionLogin::of(&session, Some(&credential)), before);
|
||||
assert_ne!(HostLogin::of(&host, Some(&credential)), before);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -5,16 +5,16 @@ use crate::secrets::SecretRef;
|
||||
|
||||
use super::CredentialId;
|
||||
|
||||
/// Stable identity of a session. Never reused within a process.
|
||||
/// Stable identity of a host. Never reused within a process.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct SessionId(pub u64);
|
||||
pub struct HostId(pub u64);
|
||||
|
||||
/// The identity a session shows outside the app, such as
|
||||
/// The identity a host shows outside the app, such as
|
||||
/// `Jwg5rHvXCxw89paM`: what 复制 ID copies, so a script or another tool can
|
||||
/// name the machine. Random letters and digits, not derived from
|
||||
/// [`SessionId`]: that one is an allocator detail that can come back after a
|
||||
/// restart, while this one is never reused and stays with its session
|
||||
/// through renames and address changes. It names a session; it grants
|
||||
/// [`HostId`]: that one is an allocator detail that can come back after a
|
||||
/// restart, while this one is never reused and stays with its host
|
||||
/// through renames and address changes. It names a host; it grants
|
||||
/// nothing.
|
||||
///
|
||||
/// A credential uses one the same way to name its password in the keychain.
|
||||
@@ -61,11 +61,11 @@ impl std::fmt::Display for PublicId {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable identity of a session group (a folder in the session tree).
|
||||
/// Stable identity of a host group (a folder in the host tree).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct GroupId(pub u64);
|
||||
|
||||
/// How a session logs in on its own, without a credential. A key, or a
|
||||
/// How a host logs in on its own, without a credential. A key, or a
|
||||
/// password shared by several hosts, is a credential.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum AuthKind {
|
||||
@@ -89,9 +89,8 @@ impl AuthKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored spelling. Anything else, including what versions
|
||||
/// before 10 wrote (`auto`, `key`), is a password: version 10's
|
||||
/// migration turned those rows into passwords and key credentials.
|
||||
/// Parse a stored spelling. Anything else is the default: `credential`,
|
||||
/// which a host using a credential stores, keeps the default here.
|
||||
pub fn from_stored(value: &str) -> Self {
|
||||
match value {
|
||||
"no-password" => AuthKind::NoPassword,
|
||||
@@ -113,14 +112,14 @@ pub enum Route {
|
||||
/// `None` is a jump host that has been deleted. It stays in its place
|
||||
/// so that the host fails to connect, saying why, rather than quietly
|
||||
/// skipping a hop or going direct.
|
||||
Jump(Vec<Option<SessionId>>),
|
||||
Jump(Vec<Option<HostId>>),
|
||||
/// Through an HTTP or SOCKS5 proxy.
|
||||
Proxy(ProxySettings),
|
||||
}
|
||||
|
||||
impl Route {
|
||||
/// The jump hosts this route goes through, deleted ones left out.
|
||||
pub fn jump_hosts(&self) -> impl Iterator<Item = SessionId> + '_ {
|
||||
pub fn jump_hosts(&self) -> impl Iterator<Item = HostId> + '_ {
|
||||
let hops = match self {
|
||||
Route::Jump(hops) => hops.as_slice(),
|
||||
_ => &[],
|
||||
@@ -205,7 +204,7 @@ impl ProxyKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which pane of a session's SFTP tab a bookmark belongs to.
|
||||
/// Which pane of a host's SFTP tab a bookmark belongs to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum BookmarkSide {
|
||||
Local,
|
||||
@@ -234,7 +233,7 @@ impl BookmarkSide {
|
||||
}
|
||||
}
|
||||
|
||||
/// The operating system running on a host, as reported by the session's own
|
||||
/// The operating system running on a host, as reported by the host's own
|
||||
/// shell after it connects.
|
||||
///
|
||||
/// Each variant owns four things that must stay in step: the spelling kept in
|
||||
@@ -354,8 +353,8 @@ fn is_light(color: Rgba) -> bool {
|
||||
luminance > 0.5
|
||||
}
|
||||
|
||||
/// Connection state of a session. Runtime only: it is never persisted, so a
|
||||
/// freshly loaded session always starts disconnected.
|
||||
/// Connection state of a host. Runtime only: it is never persisted, so a
|
||||
/// freshly loaded host always starts disconnected.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ConnectionState {
|
||||
#[default]
|
||||
@@ -378,21 +377,22 @@ impl ConnectionState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved SSH session. `group` is `None` for a session that sits at the root
|
||||
/// A saved SSH host. `group` is `None` for a host that sits at the root
|
||||
/// of the tree rather than inside a folder.
|
||||
#[derive(Clone, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Session {
|
||||
pub id: SessionId,
|
||||
/// Set once when the session is created; editing never changes it.
|
||||
pub struct Host {
|
||||
pub id: HostId,
|
||||
/// Set once when the host is created; editing never changes it.
|
||||
pub public_id: PublicId,
|
||||
pub name: SharedString,
|
||||
pub host: SharedString,
|
||||
/// The host name or IP address it is reached at: 「地址」 in the form.
|
||||
pub address: SharedString,
|
||||
pub port: u16,
|
||||
/// The user it logs in as. For a session using a credential this is the
|
||||
/// The user it logs in as. For a host using a credential this is the
|
||||
/// credential's user, which the store keeps in step.
|
||||
pub user: SharedString,
|
||||
/// How a session logs in on its own. A session using a credential keeps
|
||||
/// How a host logs in on its own. A host using a credential keeps
|
||||
/// the default here.
|
||||
pub auth: AuthKind,
|
||||
/// The credential it logs in with instead of `auth`, if any.
|
||||
@@ -402,24 +402,24 @@ pub struct Session {
|
||||
pub group: Option<GroupId>,
|
||||
/// Whatever the user wants to remember about it; empty for none.
|
||||
pub notes: SharedString,
|
||||
/// Order among sessions in the same group.
|
||||
/// Order among hosts in the same group.
|
||||
pub sort_order: i64,
|
||||
pub state: ConnectionState,
|
||||
/// Detected on every successful connection and persisted, so the tree can
|
||||
/// show the right mark before anyone connects. `None` until a probe
|
||||
/// succeeds, and the session tree falls back to the name's first letter.
|
||||
/// succeeds, and the host tree falls back to the name's first letter.
|
||||
pub os: Option<HostOs>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A session with a fresh [`PublicId`]. The store makes sure it is not
|
||||
/// one another session already has.
|
||||
pub fn new(id: SessionId, draft: SessionDraft) -> Self {
|
||||
impl Host {
|
||||
/// A host with a fresh [`PublicId`]. The store makes sure it is not
|
||||
/// one another host already has.
|
||||
pub fn new(id: HostId, draft: HostDraft) -> Self {
|
||||
Self {
|
||||
id,
|
||||
public_id: PublicId::generate(),
|
||||
name: draft.name,
|
||||
host: draft.host,
|
||||
address: draft.address,
|
||||
port: draft.port,
|
||||
user: draft.user,
|
||||
auth: draft.auth,
|
||||
@@ -433,29 +433,29 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// `user@host:port`, as shown in the status bar.
|
||||
pub fn address(&self) -> String {
|
||||
format!("{}@{}:{}", self.user, self.host, self.port)
|
||||
/// `user@address:port`, as shown in the status bar.
|
||||
pub fn endpoint(&self) -> String {
|
||||
format!("{}@{}:{}", self.user, self.address, self.port)
|
||||
}
|
||||
|
||||
/// Whether the host field is a literal IP address rather than a name, so
|
||||
/// a command can say which of the two it copies.
|
||||
pub fn host_is_ip(&self) -> bool {
|
||||
is_ip_address(&self.host)
|
||||
/// Whether the address is a literal IP address rather than a name, so a
|
||||
/// command can say which of the two it copies.
|
||||
pub fn address_is_ip(&self) -> bool {
|
||||
is_ip_address(&self.address)
|
||||
}
|
||||
|
||||
/// Where this session's login password lives in the system keychain.
|
||||
/// Keyed by the endpoint, so renaming or copying a session keeps the
|
||||
/// password and two sessions on the same account share one entry.
|
||||
/// Where this host's login password lives in the system keychain.
|
||||
/// Keyed by the endpoint, so renaming or copying a host keeps the
|
||||
/// password and two hosts on the same account share one entry.
|
||||
pub fn password_secret(&self) -> SecretRef {
|
||||
SecretRef::password(self.user.as_ref(), self.host.as_ref(), self.port)
|
||||
SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port)
|
||||
}
|
||||
|
||||
/// The editable fields, for pre-filling the session form.
|
||||
pub fn draft(&self) -> SessionDraft {
|
||||
SessionDraft {
|
||||
/// The editable fields, for pre-filling the host form.
|
||||
pub fn draft(&self) -> HostDraft {
|
||||
HostDraft {
|
||||
name: self.name.clone(),
|
||||
host: self.host.clone(),
|
||||
address: self.address.clone(),
|
||||
port: self.port,
|
||||
user: self.user.clone(),
|
||||
auth: self.auth,
|
||||
@@ -478,21 +478,21 @@ fn is_ip_address(host: &str) -> bool {
|
||||
bare.parse::<std::net::IpAddr>().is_ok()
|
||||
}
|
||||
|
||||
/// A folder in the session tree. Groups nest: `parent` is `None` for a
|
||||
/// A folder in the host tree. Groups nest: `parent` is `None` for a
|
||||
/// top-level folder.
|
||||
#[derive(Clone, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct SessionGroup {
|
||||
pub struct HostGroup {
|
||||
pub id: GroupId,
|
||||
pub name: SharedString,
|
||||
pub parent: Option<GroupId>,
|
||||
/// Order among groups with the same parent.
|
||||
pub sort_order: i64,
|
||||
/// Whether the group is expanded in the session tree across launches.
|
||||
/// Whether the group is expanded in the host tree across launches.
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
impl SessionGroup {
|
||||
impl HostGroup {
|
||||
pub fn new(id: GroupId, draft: GroupDraft) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -512,12 +512,12 @@ impl SessionGroup {
|
||||
}
|
||||
}
|
||||
|
||||
/// The values the session form commits.
|
||||
/// The values the host form commits.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct SessionDraft {
|
||||
pub struct HostDraft {
|
||||
pub name: SharedString,
|
||||
pub host: SharedString,
|
||||
pub address: SharedString,
|
||||
pub port: u16,
|
||||
pub user: SharedString,
|
||||
pub auth: AuthKind,
|
||||
@@ -527,10 +527,10 @@ pub struct SessionDraft {
|
||||
pub notes: SharedString,
|
||||
}
|
||||
|
||||
impl SessionDraft {
|
||||
impl HostDraft {
|
||||
pub fn new(
|
||||
name: impl Into<SharedString>,
|
||||
host: impl Into<SharedString>,
|
||||
address: impl Into<SharedString>,
|
||||
port: u16,
|
||||
user: impl Into<SharedString>,
|
||||
auth: AuthKind,
|
||||
@@ -538,7 +538,7 @@ impl SessionDraft {
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
host: host.into(),
|
||||
address: address.into(),
|
||||
port,
|
||||
user: user.into(),
|
||||
auth,
|
||||
@@ -568,9 +568,9 @@ impl SessionDraft {
|
||||
}
|
||||
|
||||
/// The keychain entry this draft would log in with. Matches
|
||||
/// [`Session::password_secret`] once the draft is applied.
|
||||
/// [`Host::password_secret`] once the draft is applied.
|
||||
pub fn password_secret(&self) -> SecretRef {
|
||||
SecretRef::password(self.user.as_ref(), self.host.as_ref(), self.port)
|
||||
SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,23 +641,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_session_has_not_been_probed_yet() {
|
||||
let session = Session::new(
|
||||
SessionId(1),
|
||||
SessionDraft::new("s", "h", 22, "root", AuthKind::Password, None),
|
||||
fn a_new_host_has_not_been_probed_yet() {
|
||||
let host = Host::new(
|
||||
HostId(1),
|
||||
HostDraft::new("s", "h", 22, "root", AuthKind::Password, None),
|
||||
);
|
||||
assert_eq!(session.os, None);
|
||||
assert_eq!(host.os, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_is_the_default_and_old_spellings_read_as_one() {
|
||||
fn a_password_is_the_default_and_spellings_round_trip() {
|
||||
assert_eq!(AuthKind::default(), AuthKind::Password);
|
||||
for auth in [AuthKind::Password, AuthKind::NoPassword] {
|
||||
assert_eq!(AuthKind::from_stored(auth.as_str()), auth);
|
||||
}
|
||||
// What versions before 10 wrote.
|
||||
assert_eq!(AuthKind::from_stored("auto"), AuthKind::Password);
|
||||
assert_eq!(AuthKind::from_stored("key"), AuthKind::Password);
|
||||
assert_eq!(AuthKind::from_stored("credential"), AuthKind::Password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
//! Pure functions that turn the session store into tree items.
|
||||
//! Pure functions that turn the host store into tree items.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use gpui_kit::SharedString;
|
||||
use gpui_kit::component::tree::TreeItem;
|
||||
|
||||
use super::{GroupId, Session, SessionGroup, SessionId};
|
||||
use super::{GroupId, Host, HostGroup, HostId};
|
||||
|
||||
/// What a tree row stands for. Encoded into the row's `TreeItem` id so the
|
||||
/// renderer and the context menu can recover the domain object.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SessionNode {
|
||||
pub enum HostNode {
|
||||
Group(GroupId),
|
||||
Session(SessionId),
|
||||
Host(HostId),
|
||||
}
|
||||
|
||||
/// A drop location in the session tree. Groups and sessions each have their
|
||||
/// A drop location in the host tree. Groups and hosts each have their
|
||||
/// own order within a parent; a drop into a group changes the parent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NodeDrop {
|
||||
Before(SessionNode),
|
||||
After(SessionNode),
|
||||
Before(HostNode),
|
||||
After(HostNode),
|
||||
Into(GroupId),
|
||||
Root,
|
||||
}
|
||||
|
||||
impl SessionNode {
|
||||
impl HostNode {
|
||||
pub fn id(self) -> SharedString {
|
||||
match self {
|
||||
SessionNode::Group(GroupId(id)) => format!("g:{id}").into(),
|
||||
SessionNode::Session(SessionId(id)) => format!("s:{id}").into(),
|
||||
HostNode::Group(GroupId(id)) => format!("g:{id}").into(),
|
||||
HostNode::Host(HostId(id)) => format!("s:{id}").into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,56 +37,56 @@ impl SessionNode {
|
||||
let (kind, number) = id.split_once(':')?;
|
||||
let number = number.parse().ok()?;
|
||||
match kind {
|
||||
"g" => Some(SessionNode::Group(GroupId(number))),
|
||||
"s" => Some(SessionNode::Session(SessionId(number))),
|
||||
"g" => Some(HostNode::Group(GroupId(number))),
|
||||
"s" => Some(HostNode::Host(HostId(number))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_id(self) -> Option<SessionId> {
|
||||
pub fn host_id(self) -> Option<HostId> {
|
||||
match self {
|
||||
SessionNode::Session(id) => Some(id),
|
||||
SessionNode::Group(_) => None,
|
||||
HostNode::Host(id) => Some(id),
|
||||
HostNode::Group(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_id(self) -> Option<GroupId> {
|
||||
match self {
|
||||
SessionNode::Group(id) => Some(id),
|
||||
SessionNode::Session(_) => None,
|
||||
HostNode::Group(id) => Some(id),
|
||||
HostNode::Host(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive match on name, host or user.
|
||||
pub fn matches_query(session: &Session, query: &str) -> bool {
|
||||
pub fn matches_query(host: &Host, query: &str) -> bool {
|
||||
let query = query.trim().to_lowercase();
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
[&session.name, &session.host, &session.user]
|
||||
[&host.name, &host.address, &host.user]
|
||||
.iter()
|
||||
.any(|field| field.to_lowercase().contains(&query))
|
||||
}
|
||||
|
||||
/// Build the nested tree. Each level lists its subgroups first, then the
|
||||
/// sessions that belong to it; sessions with no group sit at the root beside
|
||||
/// the top-level groups. A non-empty query keeps only matching sessions, drops
|
||||
/// hosts that belong to it; hosts with no group sit at the root beside
|
||||
/// the top-level groups. A non-empty query keeps only matching hosts, drops
|
||||
/// the groups left without any, and expands what remains; otherwise
|
||||
/// `expanded` decides which groups are open.
|
||||
pub fn session_tree_items(
|
||||
groups: &[SessionGroup],
|
||||
sessions: &[Session],
|
||||
pub fn host_tree_items(
|
||||
groups: &[HostGroup],
|
||||
hosts: &[Host],
|
||||
query: &str,
|
||||
expanded: &HashSet<GroupId>,
|
||||
) -> Vec<TreeItem> {
|
||||
let filtering = !query.trim().is_empty();
|
||||
items_under(None, 0, groups, sessions, query, filtering, expanded)
|
||||
items_under(None, 0, groups, hosts, query, filtering, expanded)
|
||||
}
|
||||
|
||||
/// The groups directly under `parent`, in display order. `None` asks for the
|
||||
/// top-level groups.
|
||||
fn child_groups(groups: &[SessionGroup], parent: Option<GroupId>) -> Vec<&SessionGroup> {
|
||||
fn child_groups(groups: &[HostGroup], parent: Option<GroupId>) -> Vec<&HostGroup> {
|
||||
let mut children: Vec<_> = groups
|
||||
.iter()
|
||||
.filter(|group| group.parent == parent)
|
||||
@@ -98,8 +98,8 @@ fn child_groups(groups: &[SessionGroup], parent: Option<GroupId>) -> Vec<&Sessio
|
||||
fn items_under(
|
||||
parent: Option<GroupId>,
|
||||
depth: usize,
|
||||
groups: &[SessionGroup],
|
||||
sessions: &[Session],
|
||||
groups: &[HostGroup],
|
||||
hosts: &[Host],
|
||||
query: &str,
|
||||
filtering: bool,
|
||||
expanded: &HashSet<GroupId>,
|
||||
@@ -116,7 +116,7 @@ fn items_under(
|
||||
Some(group.id),
|
||||
depth + 1,
|
||||
groups,
|
||||
sessions,
|
||||
hosts,
|
||||
query,
|
||||
filtering,
|
||||
expanded,
|
||||
@@ -125,20 +125,20 @@ fn items_under(
|
||||
continue;
|
||||
}
|
||||
items.push(
|
||||
TreeItem::new(SessionNode::Group(group.id).id(), group.name.clone())
|
||||
TreeItem::new(HostNode::Group(group.id).id(), group.name.clone())
|
||||
.expanded(filtering || expanded.contains(&group.id))
|
||||
.children(children),
|
||||
);
|
||||
}
|
||||
let mut child_sessions: Vec<_> = sessions
|
||||
let mut child_hosts: Vec<_> = hosts
|
||||
.iter()
|
||||
.filter(|s| s.group == parent && matches_query(s, query))
|
||||
.collect();
|
||||
child_sessions.sort_by_key(|session| (session.sort_order, session.id));
|
||||
child_hosts.sort_by_key(|host| (host.sort_order, host.id));
|
||||
items.extend(
|
||||
child_sessions.into_iter().map(|session| {
|
||||
TreeItem::new(SessionNode::Session(session.id).id(), session.name.clone())
|
||||
}),
|
||||
child_hosts
|
||||
.into_iter()
|
||||
.map(|host| TreeItem::new(HostNode::Host(host.id).id(), host.name.clone())),
|
||||
);
|
||||
items
|
||||
}
|
||||
@@ -147,17 +147,14 @@ fn items_under(
|
||||
/// `生产 / 数据库`), for the group pickers in the forms. An excluded group is
|
||||
/// skipped along with everything below it, which is how the rename dialog
|
||||
/// keeps a group from being moved into its own subtree.
|
||||
pub fn group_options(
|
||||
groups: &[SessionGroup],
|
||||
excluded: &[GroupId],
|
||||
) -> Vec<(GroupId, SharedString)> {
|
||||
pub fn group_options(groups: &[HostGroup], excluded: &[GroupId]) -> Vec<(GroupId, SharedString)> {
|
||||
let mut options = Vec::new();
|
||||
collect_group_options(groups, None, "", 0, excluded, &mut options);
|
||||
options
|
||||
}
|
||||
|
||||
fn collect_group_options(
|
||||
groups: &[SessionGroup],
|
||||
groups: &[HostGroup],
|
||||
parent: Option<GroupId>,
|
||||
prefix: &str,
|
||||
depth: usize,
|
||||
@@ -184,9 +181,9 @@ fn collect_group_options(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::{AuthKind, GroupDraft, SessionDraft, SessionStore};
|
||||
use crate::host::{AuthKind, GroupDraft, HostDraft, HostStore};
|
||||
|
||||
fn all_groups(store: &SessionStore) -> HashSet<GroupId> {
|
||||
fn all_groups(store: &HostStore) -> HashSet<GroupId> {
|
||||
store.groups().iter().map(|g| g.id).collect()
|
||||
}
|
||||
|
||||
@@ -196,20 +193,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn node_id_round_trips() {
|
||||
for node in [
|
||||
SessionNode::Group(GroupId(7)),
|
||||
SessionNode::Session(SessionId(42)),
|
||||
] {
|
||||
assert_eq!(SessionNode::parse(&node.id()), Some(node));
|
||||
for node in [HostNode::Group(GroupId(7)), HostNode::Host(HostId(42))] {
|
||||
assert_eq!(HostNode::parse(&node.id()), Some(node));
|
||||
}
|
||||
assert_eq!(SessionNode::parse("x:1"), None);
|
||||
assert_eq!(SessionNode::parse("s:abc"), None);
|
||||
assert_eq!(HostNode::parse("x:1"), None);
|
||||
assert_eq!(HostNode::parse("s:abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_query_keeps_every_group_and_session() {
|
||||
let store = SessionStore::seed();
|
||||
let items = session_tree_items(store.groups(), store.sessions(), "", &all_groups(&store));
|
||||
fn empty_query_keeps_every_group_and_host() {
|
||||
let store = HostStore::seed();
|
||||
let items = host_tree_items(store.groups(), store.hosts(), "", &all_groups(&store));
|
||||
assert_eq!(items.len(), 3);
|
||||
let total: usize = items.iter().map(|g| g.children.len()).sum();
|
||||
assert_eq!(total, 6);
|
||||
@@ -218,44 +212,38 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn collapsed_groups_follow_the_expanded_set() {
|
||||
let store = SessionStore::seed();
|
||||
let items = session_tree_items(store.groups(), store.sessions(), "", &HashSet::new());
|
||||
let store = HostStore::seed();
|
||||
let items = host_tree_items(store.groups(), store.hosts(), "", &HashSet::new());
|
||||
assert!(items.iter().all(|g| !g.is_expanded()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_matches_host_and_user_case_insensitively() {
|
||||
let store = SessionStore::seed();
|
||||
let by_host =
|
||||
session_tree_items(store.groups(), store.sessions(), "10.0.9", &HashSet::new());
|
||||
let store = HostStore::seed();
|
||||
let by_host = host_tree_items(store.groups(), store.hosts(), "10.0.9", &HashSet::new());
|
||||
let names: Vec<_> = by_host
|
||||
.iter()
|
||||
.flat_map(|g| g.children.iter().map(|c| c.label.to_string()))
|
||||
.collect();
|
||||
assert_eq!(names, ["staging-api", "qa-runner"]);
|
||||
|
||||
let by_user = session_tree_items(
|
||||
store.groups(),
|
||||
store.sessions(),
|
||||
"POSTGRES",
|
||||
&HashSet::new(),
|
||||
);
|
||||
let by_user = host_tree_items(store.groups(), store.hosts(), "POSTGRES", &HashSet::new());
|
||||
assert_eq!(by_user.len(), 1);
|
||||
assert_eq!(by_user[0].children[0].label.as_ref(), "db-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_drops_groups_without_matches_and_expands_the_rest() {
|
||||
let store = SessionStore::seed();
|
||||
let items = session_tree_items(store.groups(), store.sessions(), "dev", &HashSet::new());
|
||||
let store = HostStore::seed();
|
||||
let items = host_tree_items(store.groups(), store.hosts(), "dev", &HashSet::new());
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].label.as_ref(), "开发");
|
||||
assert!(items[0].is_expanded());
|
||||
}
|
||||
|
||||
/// 生产 / 数据库 with one session each, plus a session at the root.
|
||||
fn nested_store() -> SessionStore {
|
||||
let mut store = SessionStore::empty();
|
||||
/// 生产 / 数据库 with one host each, plus a host at the root.
|
||||
fn nested_store() -> HostStore {
|
||||
let mut store = HostStore::empty();
|
||||
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
|
||||
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
|
||||
for (name, group) in [
|
||||
@@ -263,7 +251,7 @@ mod tests {
|
||||
("db-01", Some(databases)),
|
||||
("jump", None),
|
||||
] {
|
||||
store.insert_unnotified(SessionDraft::new(
|
||||
store.insert_unnotified(HostDraft::new(
|
||||
name,
|
||||
"10.0.0.1",
|
||||
22,
|
||||
@@ -276,12 +264,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subgroups_nest_and_ungrouped_sessions_sit_at_the_root() {
|
||||
fn subgroups_nest_and_ungrouped_hosts_sit_at_the_root() {
|
||||
let store = nested_store();
|
||||
let items = session_tree_items(store.groups(), store.sessions(), "", &all_groups(&store));
|
||||
// A top-level group, then the session that belongs to no group.
|
||||
let items = host_tree_items(store.groups(), store.hosts(), "", &all_groups(&store));
|
||||
// A top-level group, then the host that belongs to no group.
|
||||
assert_eq!(labels(&items), ["生产", "jump"]);
|
||||
// Inside 生产: the subgroup first, then its own session.
|
||||
// Inside 生产: the subgroup first, then its own host.
|
||||
assert_eq!(labels(&items[0].children), ["数据库", "web-01"]);
|
||||
assert_eq!(labels(&items[0].children[0].children), ["db-01"]);
|
||||
}
|
||||
@@ -289,7 +277,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_match_deep_in_the_tree_keeps_its_whole_ancestor_chain() {
|
||||
let store = nested_store();
|
||||
let items = session_tree_items(store.groups(), store.sessions(), "db-01", &HashSet::new());
|
||||
let items = host_tree_items(store.groups(), store.hosts(), "db-01", &HashSet::new());
|
||||
assert_eq!(labels(&items), ["生产"]);
|
||||
assert!(items[0].is_expanded());
|
||||
assert_eq!(labels(&items[0].children), ["数据库"]);
|
||||
@@ -299,7 +287,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn group_options_are_depth_first_and_skip_an_excluded_subtree() {
|
||||
let mut store = SessionStore::empty();
|
||||
let mut store = HostStore::empty();
|
||||
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
|
||||
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
|
||||
store.insert_group_unnotified(GroupDraft::new("只读副本", Some(databases)));
|
||||
@@ -325,9 +313,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_query_matching_only_a_root_session_drops_every_group() {
|
||||
fn a_query_matching_only_a_root_host_drops_every_group() {
|
||||
let store = nested_store();
|
||||
let items = session_tree_items(store.groups(), store.sessions(), "jump", &HashSet::new());
|
||||
let items = host_tree_items(store.groups(), store.hosts(), "jump", &HashSet::new());
|
||||
assert_eq!(labels(&items), ["jump"]);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -1,7 +1,7 @@
|
||||
//! ShellRS: an SSH session manager with real SSH and local terminals.
|
||||
//! ShellRS: an SSH host manager with real SSH and local terminals.
|
||||
//!
|
||||
//! Modules are organized by capability so they can become crates later:
|
||||
//! `session` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
|
||||
//! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
|
||||
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
|
||||
//! (凭据), `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update`
|
||||
//! (在线升级), `connection` 与 `shared` (跨模块共用), `workspace` (窗口壳), `app`
|
||||
@@ -13,8 +13,8 @@ pub mod connection;
|
||||
pub mod credential;
|
||||
pub mod explorer;
|
||||
pub mod forward;
|
||||
pub mod host;
|
||||
pub mod secrets;
|
||||
pub mod session;
|
||||
pub mod settings;
|
||||
pub mod sftp;
|
||||
pub mod shared;
|
||||
|
||||
+8
-8
@@ -5,7 +5,7 @@
|
||||
use gpui_kit::component::{Root, notification::Notification};
|
||||
use gpui_kit::*;
|
||||
|
||||
use shellrs::session::{SessionDatabase, SessionStore};
|
||||
use shellrs::host::{HostDatabase, HostStore};
|
||||
use shellrs::settings::SettingsStore;
|
||||
|
||||
fn main() {
|
||||
@@ -16,7 +16,7 @@ fn main() {
|
||||
if let Some(args) = shellrs::cli::command_line_arguments() {
|
||||
std::process::exit(shellrs::cli::main(args));
|
||||
}
|
||||
// One ShellRS per data directory. Each keeps the sessions in memory and
|
||||
// One ShellRS per data directory. Each keeps the hosts in memory and
|
||||
// writes its changes through, so a second one would write over the
|
||||
// first one's; opened again, ShellRS brings the running one forward.
|
||||
if shellrs::cli::activate_running_app(&shellrs::app::cli_socket_path()) {
|
||||
@@ -59,15 +59,15 @@ fn main() {
|
||||
shellrs::update::start_handed_over();
|
||||
}
|
||||
|
||||
/// Load the sessions saved on disk. If the database cannot be opened the
|
||||
/// Load the hosts saved on disk. If the database cannot be opened the
|
||||
/// window still comes up, on a store that forgets everything when it closes;
|
||||
/// the returned message says so.
|
||||
///
|
||||
/// Either way the store gets the system keychain, which is where passwords
|
||||
/// live. A machine without one still runs; the session form says so and
|
||||
/// live. A machine without one still runs; the host form says so and
|
||||
/// disables its password field. Pasted and generated private keys go
|
||||
/// beside the database.
|
||||
fn open_store() -> (SessionStore, Option<SharedString>) {
|
||||
fn open_store() -> (HostStore, Option<SharedString>) {
|
||||
let secrets = shellrs::secrets::system_secret_store();
|
||||
match load_store() {
|
||||
// Keys are kept only with a database to remember them: a store
|
||||
@@ -81,7 +81,7 @@ fn open_store() -> (SessionStore, Option<SharedString>) {
|
||||
Err(error) => {
|
||||
eprintln!("shellrs: 无法打开本地数据库:{error}");
|
||||
(
|
||||
SessionStore::empty().with_secrets(secrets),
|
||||
HostStore::empty().with_secrets(secrets),
|
||||
Some(format!("无法打开本地数据库,本次运行的改动不会被保存:{error}").into()),
|
||||
)
|
||||
}
|
||||
@@ -100,7 +100,7 @@ fn open_settings() -> (SettingsStore, Option<SharedString>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_store() -> anyhow::Result<SessionStore> {
|
||||
fn load_store() -> anyhow::Result<HostStore> {
|
||||
let path = shellrs::app::database_path()?;
|
||||
Ok(SessionStore::load(SessionDatabase::open(&path)?)?)
|
||||
Ok(HostStore::load(HostDatabase::open(&path)?)?)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ impl SecretStore for InMemorySecretStore {
|
||||
}
|
||||
|
||||
/// 用在没有系统钥匙串的机器上:读永远是空,写明确报错,界面据此把密码字段
|
||||
/// 禁用掉。也是 `SessionStore` 的默认值,所以单元测试不会碰到任何秘密存储。
|
||||
/// 禁用掉。也是 `HostStore` 的默认值,所以单元测试不会碰到任何秘密存储。
|
||||
pub struct NoSecretStore;
|
||||
|
||||
impl SecretStore for NoSecretStore {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
//! 系统钥匙串(macOS Keychain Services / Windows 凭据管理器 / Secret Service)。
|
||||
//!
|
||||
//! ShellRS 的秘密只存在这里。数据库里永远不出现密码或口令,`session/database.rs`
|
||||
//! ShellRS 的秘密只存在这里。数据库里永远不出现密码或口令,`host/database.rs`
|
||||
//! 的 `schema_never_contains_secret_columns` 守着这条不变量。
|
||||
|
||||
mod keychain;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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};
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
|
||||
]
|
||||
}
|
||||
|
||||
/// 外部 CLI: whether the `shellrs` command may use the saved sessions, the
|
||||
/// 外部 CLI: whether the `shellrs` command may use the saved hosts, the
|
||||
/// command itself, and the skill that teaches agents to use it.
|
||||
fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
|
||||
let (reader, writer) = (panel.store.clone(), panel.store.clone());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::{
|
||||
};
|
||||
use crate::{
|
||||
connection::{ConnectionPrompt, ConnectionPromptReply},
|
||||
session::SessionLogin,
|
||||
host::HostLogin,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use async_channel::{Receiver, Sender};
|
||||
@@ -96,6 +96,6 @@ pub trait SftpTransport: Send + 'static {
|
||||
) -> Result<()>;
|
||||
}
|
||||
pub trait SftpTransportProvider: Send + Sync + 'static {
|
||||
fn create(&self, login: &SessionLogin) -> Box<dyn SftpTransport>;
|
||||
fn create(&self, login: &HostLogin) -> Box<dyn SftpTransport>;
|
||||
}
|
||||
pub type SharedSftpTransportProvider = Arc<dyn SftpTransportProvider>;
|
||||
+3
-3
@@ -12,7 +12,7 @@ use super::{
|
||||
upload::UploadBatch,
|
||||
};
|
||||
use crate::{
|
||||
session::SessionLogin,
|
||||
host::HostLogin,
|
||||
ssh::{SshConnectionConfig, SshConnector, SshPrompts},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
@@ -43,7 +43,7 @@ impl SshSftpTransportProvider {
|
||||
}
|
||||
}
|
||||
impl SftpTransportProvider for SshSftpTransportProvider {
|
||||
fn create(&self, login: &SessionLogin) -> Box<dyn SftpTransport> {
|
||||
fn create(&self, login: &HostLogin) -> Box<dyn SftpTransport> {
|
||||
Box::new(SshSftpTransport {
|
||||
connector: self.connector.clone(),
|
||||
config: SshConnectionConfig::from(login),
|
||||
@@ -59,7 +59,7 @@ struct SshSftpTransport {
|
||||
download_journal: DownloadJournal,
|
||||
}
|
||||
|
||||
/// The session's one transfer batch, whichever way it goes.
|
||||
/// The host's one transfer batch, whichever way it goes.
|
||||
enum TransferBatch {
|
||||
Upload(UploadBatch),
|
||||
Download(DownloadBatch),
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui_kit::component::{
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::ToggleSessionPanel;
|
||||
use crate::app::ToggleHostPanel;
|
||||
|
||||
type MenuBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
|
||||
|
||||
@@ -20,9 +20,9 @@ type MenuBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) ->
|
||||
/// Closing goes through an action rather than `TabGroup::close_panel` on
|
||||
/// purpose: the group refuses to close the last panel of a region, while the
|
||||
/// workspace wants every tab closable (an empty center shows the recent
|
||||
/// sessions instead) and removes the panel from the dock itself.
|
||||
/// hosts instead) and removes the panel from the dock itself.
|
||||
///
|
||||
/// Double-clicking the title shows or hides the session sidebar, the way a
|
||||
/// Double-clicking the title shows or hides the host sidebar, the way a
|
||||
/// double click on an editor tab gives it the room elsewhere.
|
||||
///
|
||||
/// The context menu and the double click hang on the title rather than on
|
||||
@@ -97,7 +97,7 @@ impl RenderOnce for ClosableTabTitle {
|
||||
// The first click selects the tab as usual.
|
||||
.on_click(|event, window, cx| {
|
||||
if event.click_count() == 2 {
|
||||
window.dispatch_action(Box::new(ToggleSessionPanel), cx);
|
||||
window.dispatch_action(Box::new(ToggleHostPanel), cx);
|
||||
}
|
||||
})
|
||||
.test_support()
|
||||
|
||||
@@ -4,13 +4,13 @@ use gpui_kit::component::{
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::session::HostOs;
|
||||
use crate::host::HostOs;
|
||||
|
||||
/// The mark that stands for a session: the operating system found on its host,
|
||||
/// The mark that stands for a host: the operating system found on its host,
|
||||
/// drawn on a badge in that project's own colour, or the first character of
|
||||
/// the session name until a probe succeeds.
|
||||
/// the host name until a probe succeeds.
|
||||
///
|
||||
/// Shared so the session tree and the start page show one identity for the
|
||||
/// Shared so the host tree and the start page show one identity for the
|
||||
/// same host. The badge borrows `Avatar`'s treatment, a circle at the theme's
|
||||
/// radius, so identity marks look alike wherever the product shows one.
|
||||
#[derive(IntoElement)]
|
||||
@@ -38,7 +38,7 @@ impl HostMark {
|
||||
}
|
||||
|
||||
/// No tooltip of its own, for a row that shows one for the whole row, as
|
||||
/// the session tree does, so two tooltips never stack.
|
||||
/// the host tree does, so two tooltips never stack.
|
||||
pub fn without_tooltip(mut self) -> Self {
|
||||
self.tooltip = false;
|
||||
self
|
||||
|
||||
+13
-13
@@ -3,8 +3,8 @@ use crate::{
|
||||
connection::{
|
||||
ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply,
|
||||
},
|
||||
host::{HostLogin, JumpLogin, LoginMethod, LoginRoute, ProxyLogin},
|
||||
secrets::{SecretRef, SharedSecretStore},
|
||||
session::{JumpLogin, LoginMethod, LoginRoute, ProxyLogin, SessionLogin},
|
||||
};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use russh::keys::{
|
||||
@@ -46,11 +46,11 @@ static NEXT_PROMPT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
/// it on the UI thread, credential and all.
|
||||
#[derive(Clone)]
|
||||
pub struct SshConnectionConfig {
|
||||
login: SessionLogin,
|
||||
login: HostLogin,
|
||||
}
|
||||
|
||||
impl From<&SessionLogin> for SshConnectionConfig {
|
||||
fn from(login: &SessionLogin) -> Self {
|
||||
impl From<&HostLogin> for SshConnectionConfig {
|
||||
fn from(login: &HostLogin) -> Self {
|
||||
Self {
|
||||
login: login.clone(),
|
||||
}
|
||||
@@ -159,7 +159,7 @@ impl SshConnector {
|
||||
async fn log_in(
|
||||
&self,
|
||||
tunnel: Tunnel,
|
||||
login: &SessionLogin,
|
||||
login: &HostLogin,
|
||||
jump_host: Option<&str>,
|
||||
broker: &Arc<SshPrompts>,
|
||||
secrets: &SharedSecretStore,
|
||||
@@ -200,7 +200,7 @@ impl SshConnector {
|
||||
async fn through_jumps(
|
||||
&self,
|
||||
hops: &[JumpLogin],
|
||||
login: &SessionLogin,
|
||||
login: &HostLogin,
|
||||
broker: &Arc<SshPrompts>,
|
||||
secrets: &SharedSecretStore,
|
||||
) -> Result<Tunnel> {
|
||||
@@ -255,7 +255,7 @@ impl SshConnector {
|
||||
&self,
|
||||
tunnel: Option<Tunnel>,
|
||||
name: &str,
|
||||
hop: &SessionLogin,
|
||||
hop: &HostLogin,
|
||||
next: (&str, u16),
|
||||
broker: &Arc<SshPrompts>,
|
||||
secrets: &SharedSecretStore,
|
||||
@@ -349,7 +349,7 @@ async fn tcp(host: &str, port: u16) -> Result<TcpStream> {
|
||||
/// A stream to `login`'s host through `proxy`.
|
||||
async fn through_proxy(
|
||||
proxy: &ProxyLogin,
|
||||
login: &SessionLogin,
|
||||
login: &HostLogin,
|
||||
secrets: &SharedSecretStore,
|
||||
) -> Result<TcpStream> {
|
||||
let mut socket = TcpStream::connect((proxy.host.as_str(), proxy.port))
|
||||
@@ -778,7 +778,7 @@ fn read_known_keys(host: &str, port: u16, path: &Path) -> Result<Vec<(usize, Pub
|
||||
|
||||
async fn authenticate(
|
||||
handle: &mut SshHandle,
|
||||
login: &SessionLogin,
|
||||
login: &HostLogin,
|
||||
secrets: &SharedSecretStore,
|
||||
agent: &AgentLocation,
|
||||
broker: Asker<'_>,
|
||||
@@ -863,7 +863,7 @@ async fn authenticate(
|
||||
|
||||
if method == LoginMethod::Password || partial {
|
||||
if methods.contains(&MethodKind::Password) {
|
||||
// Try what the session has saved before bothering anyone.
|
||||
// Try what the host has saved before bothering anyone.
|
||||
let mut saved_rejected = false;
|
||||
if let Some(saved) = saved_secret(secrets, &login.password) {
|
||||
let result = handle
|
||||
@@ -873,7 +873,7 @@ async fn authenticate(
|
||||
if result.success() {
|
||||
return Ok(());
|
||||
}
|
||||
// The entry stays: the user typed it into the session dialog,
|
||||
// The entry stays: the user typed it into the host dialog,
|
||||
// and deleting it behind their back would be baffling. This
|
||||
// connection just falls back to asking, and says why.
|
||||
saved_rejected = true;
|
||||
@@ -1042,7 +1042,7 @@ async fn connect_agent(location: &AgentLocation) -> Result<Agent, AgentProblem>
|
||||
Err(AgentProblem::Unreachable("这个平台不支持".to_string()))
|
||||
}
|
||||
|
||||
fn key_paths(login: &SessionLogin) -> Result<Vec<PathBuf>> {
|
||||
fn key_paths(login: &HostLogin) -> Result<Vec<PathBuf>> {
|
||||
if login.method == LoginMethod::Key {
|
||||
return login
|
||||
.key_path
|
||||
@@ -1073,7 +1073,7 @@ async fn load_private_key(
|
||||
Err(_) => bail!("无法读取私钥文件:{}", path.display()),
|
||||
}
|
||||
// Passphrases are saved per key file, so one saved answer unlocks the same
|
||||
// key for every session that uses it.
|
||||
// key for every host that uses it.
|
||||
let mut saved_rejected = false;
|
||||
if let Some(saved) = saved_secret(secrets, &SecretRef::passphrase(path)) {
|
||||
if let Ok(key) = load_secret_key(path, Some(saved.as_str())) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
//! Run one command on a session's host and pass on what it prints: the
|
||||
//! Run one command on a saved host and pass on what it prints: the
|
||||
//! external CLI's `exec`. Nobody is there to answer a question, so the
|
||||
//! connection never asks one: a host not trusted yet or a password not saved
|
||||
//! fails at once and says so.
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
//! Runs once per successful connection on its own exec channel, so the user's
|
||||
//! shell never sees it and its output cannot be confused with theirs.
|
||||
|
||||
use crate::session::HostOs;
|
||||
use crate::host::HostOs;
|
||||
|
||||
/// One question for POSIX hosts: the kernel name, then whatever
|
||||
/// `/etc/os-release` says. stderr is dropped because macOS legitimately has no
|
||||
@@ -165,7 +165,7 @@ impl HostOsProbe {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{HostOsProbe, MAX_PROBE_OUTPUT, ProbeOutcome, parse_probe, parse_windows_probe};
|
||||
use crate::session::HostOs;
|
||||
use crate::host::HostOs;
|
||||
|
||||
#[test]
|
||||
fn reads_ubuntu() {
|
||||
|
||||
+1
-1
@@ -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> {
|
||||
|
||||
@@ -23,10 +23,8 @@ use crate::{
|
||||
ConnectionPrompt, ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret,
|
||||
ConnectionTester as _, LoginTest,
|
||||
},
|
||||
host::{AuthKind, HostLogin, JumpLogin, LoginRoute, ProxyKind, ProxyLogin, ProxySettings},
|
||||
secrets::{InMemorySecretStore, SecretRef, SecretStore as _},
|
||||
session::{
|
||||
AuthKind, JumpLogin, LoginRoute, ProxyKind, ProxyLogin, ProxySettings, SessionLogin,
|
||||
},
|
||||
};
|
||||
|
||||
/// An SSH server that lets one user in with one password and forwards
|
||||
@@ -282,8 +280,8 @@ const TARGET_PASSWORD: &str = "target-password";
|
||||
const JUMP_USER: &str = "jumper";
|
||||
const JUMP_PASSWORD: &str = "jump-password";
|
||||
|
||||
fn manual(host: &str, port: u16, user: &str) -> SessionLogin {
|
||||
SessionLogin::manual(host, port, user, AuthKind::Password)
|
||||
fn manual(host: &str, port: u16, user: &str) -> HostLogin {
|
||||
HostLogin::manual(host, port, user, AuthKind::Password)
|
||||
}
|
||||
|
||||
fn hop(name: &str, port: u16) -> JumpLogin {
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
//! Log in once with the session form's values, for its 「测试连接」 button.
|
||||
//! Log in once with the host form's values, for its 「测试连接」 button.
|
||||
use std::{
|
||||
sync::{Arc, Mutex, OnceLock, Weak},
|
||||
time::Duration,
|
||||
@@ -17,8 +17,8 @@ use crate::{
|
||||
ConnectionPrompt, ConnectionPromptKind, ConnectionPromptReply, ConnectionTester, LoginTest,
|
||||
TrustCallback,
|
||||
},
|
||||
host::{JumpLogin, LoginRoute},
|
||||
secrets::{SecretRef, SecretStore, SharedSecretStore},
|
||||
session::{JumpLogin, LoginRoute},
|
||||
};
|
||||
|
||||
/// How long a test may take, not counting the time a person spends deciding
|
||||
|
||||
+65
-71
@@ -3,7 +3,7 @@ use super::{
|
||||
probe::{HostOsProbe, ProbeOutcome},
|
||||
};
|
||||
use crate::{
|
||||
session::SessionLogin,
|
||||
host::HostLogin,
|
||||
terminal::{
|
||||
Latency, RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize,
|
||||
TerminalTransport, TerminalTransportCommand, TerminalTransportEvent,
|
||||
@@ -42,7 +42,7 @@ impl SshTerminalTransportProvider {
|
||||
}
|
||||
}
|
||||
impl RemoteTerminalTransportProvider for SshTerminalTransportProvider {
|
||||
fn factory_for(&self, login: &SessionLogin) -> SharedTerminalTransportFactory {
|
||||
fn factory_for(&self, login: &HostLogin) -> SharedTerminalTransportFactory {
|
||||
Arc::new(SshTerminalTransport {
|
||||
config: SshConnectionConfig::from(login),
|
||||
connector: self.connector.clone(),
|
||||
@@ -50,7 +50,7 @@ impl RemoteTerminalTransportProvider for SshTerminalTransportProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// One session's connection settings. It is its own factory: every launch
|
||||
/// One host's connection settings. It is its own factory: every launch
|
||||
/// and restart runs a fresh copy.
|
||||
#[derive(Clone)]
|
||||
struct SshTerminalTransport {
|
||||
@@ -177,7 +177,7 @@ impl SshTerminalTransport {
|
||||
// loop below collect the answer alongside the shell's output. Opening
|
||||
// it after `Started` keeps the terminal from waiting on a round trip,
|
||||
// and a host that refuses or ignores the probe simply keeps whatever
|
||||
// mark the session already had.
|
||||
// mark the host already had.
|
||||
let mut probe = HostOsProbe::new();
|
||||
let mut probe_channel = open_probe(&handle, probe.command()).await;
|
||||
|
||||
@@ -299,7 +299,7 @@ fn pixel_dimension(cells: usize, cell_size: u16) -> u32 {
|
||||
}
|
||||
|
||||
/// Open a channel and run one probe command on it. Best effort throughout: a
|
||||
/// server that refuses the channel or the command just leaves the session's
|
||||
/// server that refuses the channel or the command just leaves the host's
|
||||
/// recorded operating system as it was.
|
||||
async fn open_probe(
|
||||
handle: &SshHandle,
|
||||
@@ -316,14 +316,14 @@ async fn open_probe(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host::HostOs;
|
||||
use crate::secrets::{
|
||||
InMemorySecretStore, NoSecretStore, SecretRef, SecretStore as _, SharedSecretStore,
|
||||
};
|
||||
use crate::session::HostOs;
|
||||
use crate::ssh::probe::{PROBE_COMMAND, WINDOWS_PROBE_COMMAND};
|
||||
use crate::{
|
||||
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret},
|
||||
session::{AuthKind, Session},
|
||||
host::{AuthKind, Host},
|
||||
};
|
||||
use russh::keys::{PublicKey, known_hosts::learn_known_hosts_path};
|
||||
use russh::server::{self, Server as _};
|
||||
@@ -651,12 +651,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn connect_then_shutdown(
|
||||
session: Session,
|
||||
host: Host,
|
||||
known_hosts: &Path,
|
||||
answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply,
|
||||
) {
|
||||
connect(
|
||||
SessionLogin::of(&session, None),
|
||||
HostLogin::of(&host, None),
|
||||
known_hosts,
|
||||
Arc::new(NoSecretStore),
|
||||
false,
|
||||
@@ -665,13 +665,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn connect_with_secrets(
|
||||
session: Session,
|
||||
host: Host,
|
||||
known_hosts: &Path,
|
||||
secrets: SharedSecretStore,
|
||||
answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply,
|
||||
) -> ConnectionReport {
|
||||
connect(
|
||||
SessionLogin::of(&session, None),
|
||||
HostLogin::of(&host, None),
|
||||
known_hosts,
|
||||
secrets,
|
||||
false,
|
||||
@@ -682,12 +682,12 @@ mod tests {
|
||||
/// Same, but waits for the host-operating-system probe and the first
|
||||
/// round-trip measurement to report before shutting the connection down.
|
||||
fn connect_and_probe(
|
||||
session: Session,
|
||||
host: Host,
|
||||
known_hosts: &Path,
|
||||
answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply,
|
||||
) -> ConnectionReport {
|
||||
connect(
|
||||
SessionLogin::of(&session, None),
|
||||
HostLogin::of(&host, None),
|
||||
known_hosts,
|
||||
Arc::new(NoSecretStore),
|
||||
true,
|
||||
@@ -706,7 +706,7 @@ mod tests {
|
||||
/// Connect, answer whatever is asked, then shut down. The report says what
|
||||
/// was raised along the way, so a test can assert that nothing was.
|
||||
fn connect(
|
||||
login: SessionLogin,
|
||||
login: HostLogin,
|
||||
known_hosts: &Path,
|
||||
secrets: SharedSecretStore,
|
||||
wait_for_host_os: bool,
|
||||
@@ -788,12 +788,12 @@ mod tests {
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
let session = password_session(server.port);
|
||||
let host = password_host(server.port);
|
||||
let provider = SshTerminalTransportProvider::with_connector(SshConnector::new(
|
||||
&known_hosts,
|
||||
Arc::new(NoSecretStore),
|
||||
));
|
||||
let factory = provider.factory_for(&SessionLogin::of(&session, None));
|
||||
let factory = provider.factory_for(&HostLogin::of(&host, None));
|
||||
let (command_tx, command_rx) = mpsc::channel();
|
||||
let (event_tx, event_rx) = async_channel::unbounded();
|
||||
let worker = thread::spawn(move || {
|
||||
@@ -866,7 +866,7 @@ mod tests {
|
||||
|
||||
// A second connection must trust the saved key without prompting and
|
||||
// an active close must return the worker within the shutdown bound.
|
||||
let factory = provider.factory_for(&SessionLogin::of(&session, None));
|
||||
let factory = provider.factory_for(&HostLogin::of(&host, None));
|
||||
let (command_tx, command_rx) = mpsc::channel();
|
||||
let (event_tx, event_rx) = async_channel::unbounded();
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
@@ -925,10 +925,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn password_session(port: u16) -> Session {
|
||||
Session::new(
|
||||
crate::session::SessionId(1),
|
||||
crate::session::SessionDraft::new(
|
||||
fn password_host(port: u16) -> Host {
|
||||
Host::new(
|
||||
crate::host::HostId(1),
|
||||
crate::host::HostDraft::new(
|
||||
"test",
|
||||
"127.0.0.1",
|
||||
port,
|
||||
@@ -939,7 +939,7 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Run a connection test the way the session form does, recording
|
||||
/// Run a connection test the way the host form does, recording
|
||||
/// whether (and about what) it asked to trust the host.
|
||||
fn test_login(
|
||||
request: crate::connection::LoginTest,
|
||||
@@ -962,7 +962,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn login_request(port: u16) -> crate::connection::LoginTest {
|
||||
crate::connection::LoginTest::typed(SessionLogin::manual(
|
||||
crate::connection::LoginTest::typed(HostLogin::manual(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
"tester",
|
||||
@@ -973,7 +973,7 @@ mod tests {
|
||||
/// The CLI's `exec` against the test server, on a thread of its own so
|
||||
/// a login that waits instead of failing shows up as a timeout.
|
||||
fn run_cli_command(
|
||||
session: &Session,
|
||||
host: &Host,
|
||||
known_hosts: &Path,
|
||||
keychain: Arc<InMemorySecretStore>,
|
||||
) -> (
|
||||
@@ -982,7 +982,7 @@ mod tests {
|
||||
Vec<u8>,
|
||||
) {
|
||||
let connector = SshConnector::new(known_hosts, keychain);
|
||||
let config = SshConnectionConfig::from(&SessionLogin::of(session, None));
|
||||
let config = SshConnectionConfig::from(&HostLogin::of(host, None));
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
@@ -1022,13 +1022,13 @@ mod tests {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
trust_server(server.port, &known_hosts);
|
||||
let session = password_session(server.port);
|
||||
let host = password_host(server.port);
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
keychain
|
||||
.set(&session.password_secret(), TEST_PASSWORD)
|
||||
.set(&host.password_secret(), TEST_PASSWORD)
|
||||
.unwrap();
|
||||
|
||||
let (result, stdout, stderr) = run_cli_command(&session, &known_hosts, keychain);
|
||||
let (result, stdout, stderr) = run_cli_command(&host, &known_hosts, keychain);
|
||||
assert_eq!(result, Ok(crate::ssh::ExecExit::Code(3)));
|
||||
assert_eq!(stdout, b"out");
|
||||
assert_eq!(stderr, b"err");
|
||||
@@ -1050,13 +1050,13 @@ mod tests {
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
let session = password_session(server.port);
|
||||
let host = password_host(server.port);
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
keychain
|
||||
.set(&session.password_secret(), TEST_PASSWORD)
|
||||
.set(&host.password_secret(), TEST_PASSWORD)
|
||||
.unwrap();
|
||||
|
||||
let (result, stdout, _) = run_cli_command(&session, &known_hosts, keychain);
|
||||
let (result, stdout, _) = run_cli_command(&host, &known_hosts, keychain);
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind, crate::ssh::ExecErrorKind::HostKeyUnknown);
|
||||
assert!(error.message.contains("请先在 ShellRS 中连接一次"));
|
||||
@@ -1074,10 +1074,10 @@ mod tests {
|
||||
crate::ssh::SshConnectionTester::new(connector).test(request, Box::new(|_| true))
|
||||
}
|
||||
|
||||
fn credential(kind: crate::session::CredentialKind) -> crate::session::Credential {
|
||||
crate::session::Credential::new(
|
||||
crate::session::CredentialId(1),
|
||||
crate::session::CredentialDraft::new("运维", kind, "tester"),
|
||||
fn credential(kind: crate::host::CredentialKind) -> crate::host::Credential {
|
||||
crate::host::Credential::new(
|
||||
crate::host::CredentialId(1),
|
||||
crate::host::CredentialDraft::new("运维", kind, "tester"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1088,7 +1088,7 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let credential = credential(crate::session::CredentialKind::Password);
|
||||
let credential = credential(crate::host::CredentialKind::Password);
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
keychain
|
||||
.set(&credential.password_secret(), TEST_PASSWORD)
|
||||
@@ -1100,7 +1100,7 @@ mod tests {
|
||||
"wrong",
|
||||
)
|
||||
.unwrap();
|
||||
let login = SessionLogin::with_credential("127.0.0.1", server.port, &credential);
|
||||
let login = HostLogin::with_credential("127.0.0.1", server.port, &credential);
|
||||
|
||||
let connector = SshConnector::new(directory.path().join("known_hosts"), keychain);
|
||||
assert_eq!(
|
||||
@@ -1163,10 +1163,10 @@ mod tests {
|
||||
/// `agent`.
|
||||
#[cfg(unix)]
|
||||
fn agent_login(port: u16, agent: std::path::PathBuf, known_hosts: &Path) -> Result<(), String> {
|
||||
let login = SessionLogin::with_credential(
|
||||
let login = HostLogin::with_credential(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
&credential(crate::session::CredentialKind::Agent),
|
||||
&credential(crate::host::CredentialKind::Agent),
|
||||
);
|
||||
let connector = SshConnector::new(known_hosts, Arc::new(NoSecretStore))
|
||||
.with_agent(crate::ssh::AgentLocation::At(agent));
|
||||
@@ -1204,7 +1204,7 @@ mod tests {
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let agent = start_agent(&[key]);
|
||||
let login = SessionLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword);
|
||||
let login = HostLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword);
|
||||
let connector = SshConnector::new(
|
||||
directory.path().join("known_hosts"),
|
||||
Arc::new(NoSecretStore),
|
||||
@@ -1267,7 +1267,7 @@ mod tests {
|
||||
directory.path().join("known_hosts"),
|
||||
Arc::new(NoSecretStore),
|
||||
);
|
||||
let login = SessionLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword);
|
||||
let login = HostLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword);
|
||||
// Refused outright: the connection test cannot ask, and would say a
|
||||
// password was missing if this login tried to.
|
||||
assert_eq!(
|
||||
@@ -1285,10 +1285,10 @@ mod tests {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
trust_server(server.port, &known_hosts);
|
||||
let session = password_session(server.port);
|
||||
let host = password_host(server.port);
|
||||
|
||||
let (result, _, _) = run_cli_command(
|
||||
&session,
|
||||
&host,
|
||||
&known_hosts,
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
);
|
||||
@@ -1462,13 +1462,11 @@ mod tests {
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
let session = password_session(server.port);
|
||||
let host = password_host(server.port);
|
||||
let secrets = Arc::new(InMemorySecretStore::default());
|
||||
secrets
|
||||
.set(&session.password_secret(), TEST_PASSWORD)
|
||||
.unwrap();
|
||||
secrets.set(&host.password_secret(), TEST_PASSWORD).unwrap();
|
||||
|
||||
let report = connect_with_secrets(session, &known_hosts, secrets, |kind| match kind {
|
||||
let report = connect_with_secrets(host, &known_hosts, secrets, |kind| match kind {
|
||||
ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave,
|
||||
other => panic!("unexpected prompt: {other:?}"),
|
||||
});
|
||||
@@ -1492,17 +1490,13 @@ mod tests {
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
let session = password_session(server.port);
|
||||
let endpoint = session.password_secret();
|
||||
let host = password_host(server.port);
|
||||
let endpoint = host.password_secret();
|
||||
let secrets = Arc::new(InMemorySecretStore::default());
|
||||
secrets.set(&endpoint, "stale-password").unwrap();
|
||||
|
||||
let report = connect_with_secrets(
|
||||
session,
|
||||
&known_hosts,
|
||||
secrets.clone(),
|
||||
trust_and_type_password,
|
||||
);
|
||||
let report =
|
||||
connect_with_secrets(host, &known_hosts, secrets.clone(), trust_and_type_password);
|
||||
|
||||
let instructions = report
|
||||
.prompts
|
||||
@@ -1539,7 +1533,7 @@ mod tests {
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
|
||||
let report = connect_and_probe(
|
||||
password_session(server.port),
|
||||
password_host(server.port),
|
||||
&known_hosts,
|
||||
trust_and_type_password,
|
||||
);
|
||||
@@ -1576,7 +1570,7 @@ mod tests {
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
|
||||
let report = connect_and_probe(
|
||||
password_session(server.port),
|
||||
password_host(server.port),
|
||||
&known_hosts,
|
||||
trust_and_type_password,
|
||||
);
|
||||
@@ -1595,7 +1589,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_that_answers_nothing_leaves_the_session_unmarked() {
|
||||
fn a_host_that_answers_nothing_leaves_the_host_unmarked() {
|
||||
let Some(server) = start_server(TestAuth::Password) else {
|
||||
eprintln!(
|
||||
"loopback sockets are unavailable in this sandbox; skipping integration body"
|
||||
@@ -1606,7 +1600,7 @@ mod tests {
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
|
||||
let report = connect_and_probe(
|
||||
password_session(server.port),
|
||||
password_host(server.port),
|
||||
&known_hosts,
|
||||
trust_and_type_password,
|
||||
);
|
||||
@@ -1639,17 +1633,17 @@ mod tests {
|
||||
client_key
|
||||
.write_openssh_file(&key_path, russh::keys::ssh_key::LineEnding::LF)
|
||||
.unwrap();
|
||||
let key_credential = crate::session::Credential::new(
|
||||
crate::session::CredentialId(1),
|
||||
crate::session::CredentialDraft::new(
|
||||
let key_credential = crate::host::Credential::new(
|
||||
crate::host::CredentialId(1),
|
||||
crate::host::CredentialDraft::new(
|
||||
"key-test",
|
||||
crate::session::CredentialKind::Key,
|
||||
crate::host::CredentialKind::Key,
|
||||
"tester",
|
||||
)
|
||||
.with_key_path(key_path.to_string_lossy().into_owned()),
|
||||
);
|
||||
connect(
|
||||
SessionLogin::with_credential("127.0.0.1", key_server.port, &key_credential),
|
||||
HostLogin::with_credential("127.0.0.1", key_server.port, &key_credential),
|
||||
&directory.path().join("key-known-hosts"),
|
||||
Arc::new(NoSecretStore),
|
||||
false,
|
||||
@@ -1663,9 +1657,9 @@ mod tests {
|
||||
let Some(interactive_server) = start_server(TestAuth::KeyboardInteractive) else {
|
||||
panic!("loopback became unavailable during keyboard-interactive test")
|
||||
};
|
||||
let interactive_session = Session::new(
|
||||
crate::session::SessionId(2),
|
||||
crate::session::SessionDraft::new(
|
||||
let interactive_host = Host::new(
|
||||
crate::host::HostId(2),
|
||||
crate::host::HostDraft::new(
|
||||
"interactive-test",
|
||||
"127.0.0.1",
|
||||
interactive_server.port,
|
||||
@@ -1676,7 +1670,7 @@ mod tests {
|
||||
);
|
||||
let mut saw_challenge = false;
|
||||
connect_then_shutdown(
|
||||
interactive_session,
|
||||
interactive_host,
|
||||
&directory.path().join("interactive-known-hosts"),
|
||||
|prompt| match prompt {
|
||||
ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave,
|
||||
@@ -1717,12 +1711,12 @@ mod tests {
|
||||
&known_hosts,
|
||||
)
|
||||
.unwrap();
|
||||
let session = password_session(server.port);
|
||||
let host = password_host(server.port);
|
||||
let provider = SshTerminalTransportProvider::with_connector(SshConnector::new(
|
||||
&known_hosts,
|
||||
Arc::new(NoSecretStore),
|
||||
));
|
||||
let factory = provider.factory_for(&SessionLogin::of(&session, None));
|
||||
let factory = provider.factory_for(&HostLogin::of(&host, None));
|
||||
let (_command_tx, command_rx) = mpsc::channel();
|
||||
let (event_tx, event_rx) = async_channel::unbounded();
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -9,11 +9,11 @@ use gpui_kit::component::{
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CatalogIcon, CenterTab, CloseTerminal, ConnectSession, CopySessionHost, DisconnectTerminal,
|
||||
EditSession, OpenExplorer, ReconnectTerminal, RenameTerminal,
|
||||
CatalogIcon, CenterTab, CloseTerminal, ConnectHost, CopyHostAddress, DisconnectTerminal,
|
||||
EditHost, OpenExplorer, ReconnectTerminal, RenameTerminal,
|
||||
};
|
||||
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
|
||||
use crate::session::{HostOs, SessionId, SessionStore};
|
||||
use crate::host::{HostId, HostOs, HostStore};
|
||||
use crate::shared::{ClosableTabTitle, HostMark, RenamableTab, close_tab_items};
|
||||
|
||||
use super::{
|
||||
@@ -23,19 +23,19 @@ use super::{
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TerminalPanelEvent {
|
||||
Activated(RemoteTerminalId, SessionId),
|
||||
Closed(RemoteTerminalId, SessionId),
|
||||
StatusChanged(RemoteTerminalId, SessionId),
|
||||
PromptRequested(RemoteTerminalId, SessionId, ConnectionPrompt),
|
||||
HostOsDetected(SessionId, HostOs),
|
||||
Activated(RemoteTerminalId, HostId),
|
||||
Closed(RemoteTerminalId, HostId),
|
||||
StatusChanged(RemoteTerminalId, HostId),
|
||||
PromptRequested(RemoteTerminalId, HostId, ConnectionPrompt),
|
||||
HostOsDetected(HostId, HostOs),
|
||||
}
|
||||
|
||||
/// A remote-session Dock panel backed by the shared terminal engine. The
|
||||
/// transport is created from the latest saved session on every connection.
|
||||
/// A remote-host Dock panel backed by the shared terminal engine. The
|
||||
/// transport is created from the latest saved host on every connection.
|
||||
pub struct TerminalPanel {
|
||||
id: RemoteTerminalId,
|
||||
session_id: SessionId,
|
||||
store: Entity<SessionStore>,
|
||||
host_id: HostId,
|
||||
store: Entity<HostStore>,
|
||||
terminal: Entity<TerminalView>,
|
||||
remote_provider: SharedRemoteTerminalTransportProvider,
|
||||
tab_group: Option<WeakEntity<TabGroup>>,
|
||||
@@ -48,36 +48,36 @@ pub struct TerminalPanel {
|
||||
impl TerminalPanel {
|
||||
pub fn new(
|
||||
id: RemoteTerminalId,
|
||||
session_id: SessionId,
|
||||
store: Entity<SessionStore>,
|
||||
host_id: HostId,
|
||||
store: Entity<HostStore>,
|
||||
remote_provider: SharedRemoteTerminalTransportProvider,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let (session, login) = {
|
||||
let (host, login) = {
|
||||
let store = store.read(cx);
|
||||
let session = store
|
||||
.session(session_id)
|
||||
let host = store
|
||||
.host(host_id)
|
||||
.cloned()
|
||||
.expect("terminal sessions must exist in the store");
|
||||
let login = store.login_of(&session);
|
||||
(session, login)
|
||||
.expect("terminal hosts must exist in the store");
|
||||
let login = store.login_of(&host);
|
||||
(host, login)
|
||||
};
|
||||
let terminal = cx.new(|cx| {
|
||||
let mut terminal = TerminalView::new(
|
||||
("terminal", id.0),
|
||||
format!("{} 的终端", session.name),
|
||||
format!("{} 的终端", host.name),
|
||||
remote_provider.factory_for(&login),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
terminal.set_menu_items(connection_menu_items(id, session_id));
|
||||
terminal.set_menu_items(connection_menu_items(id, host_id));
|
||||
terminal
|
||||
});
|
||||
let subscriptions = vec![
|
||||
cx.observe(&store, |_, _, cx| cx.notify()),
|
||||
cx.observe(&terminal, |this, _, cx| {
|
||||
cx.emit(TerminalPanelEvent::StatusChanged(this.id, this.session_id));
|
||||
cx.emit(TerminalPanelEvent::StatusChanged(this.id, this.host_id));
|
||||
cx.notify();
|
||||
}),
|
||||
cx.subscribe(
|
||||
@@ -86,12 +86,12 @@ impl TerminalPanel {
|
||||
TerminalEvent::PromptRequested(prompt) => {
|
||||
cx.emit(TerminalPanelEvent::PromptRequested(
|
||||
this.id,
|
||||
this.session_id,
|
||||
this.host_id,
|
||||
prompt.clone(),
|
||||
));
|
||||
}
|
||||
TerminalEvent::HostOsDetected(os) => {
|
||||
cx.emit(TerminalPanelEvent::HostOsDetected(this.session_id, *os));
|
||||
cx.emit(TerminalPanelEvent::HostOsDetected(this.host_id, *os));
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -99,7 +99,7 @@ impl TerminalPanel {
|
||||
|
||||
Self {
|
||||
id,
|
||||
session_id,
|
||||
host_id,
|
||||
store,
|
||||
terminal,
|
||||
remote_provider,
|
||||
@@ -113,8 +113,8 @@ impl TerminalPanel {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
pub fn host_id(&self) -> HostId {
|
||||
self.host_id
|
||||
}
|
||||
|
||||
pub fn tab_group(&self) -> Option<WeakEntity<TabGroup>> {
|
||||
@@ -124,12 +124,12 @@ impl TerminalPanel {
|
||||
fn tab_menu(&self, cx: &Context<Self>) -> TabMenu {
|
||||
TabMenu {
|
||||
id: self.id,
|
||||
session_id: self.session_id,
|
||||
host_id: self.host_id,
|
||||
host_is_ip: self
|
||||
.store
|
||||
.read(cx)
|
||||
.session(self.session_id)
|
||||
.is_some_and(|session| session.host_is_ip()),
|
||||
.host(self.host_id)
|
||||
.is_some_and(|host| host.address_is_ip()),
|
||||
group: self.tab_group.clone(),
|
||||
panel: cx.entity_id(),
|
||||
}
|
||||
@@ -149,7 +149,7 @@ impl TerminalPanel {
|
||||
}
|
||||
|
||||
pub fn reconnect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(login) = self.store.read(cx).login(self.session_id) else {
|
||||
let Some(login) = self.store.read(cx).login(self.host_id) else {
|
||||
return;
|
||||
};
|
||||
let factory = self.remote_provider.factory_for(&login);
|
||||
@@ -202,7 +202,7 @@ impl BasePanel for TerminalPanel {
|
||||
if !active {
|
||||
return;
|
||||
}
|
||||
let id = self.session_id;
|
||||
let id = self.host_id;
|
||||
self.store
|
||||
.update(cx, |store, cx| store.set_active(Some(id), cx));
|
||||
let focus = self.terminal.read(cx).focus_handle();
|
||||
@@ -218,7 +218,7 @@ impl BasePanel for TerminalPanel {
|
||||
self.tab_group = None;
|
||||
self.terminal
|
||||
.update(cx, |terminal, cx| terminal.shutdown(cx));
|
||||
let id = self.session_id;
|
||||
let id = self.host_id;
|
||||
cx.emit(TerminalPanelEvent::Closed(self.id, id));
|
||||
}
|
||||
}
|
||||
@@ -229,8 +229,8 @@ impl Panel for TerminalPanel {
|
||||
let os = self
|
||||
.store
|
||||
.read(cx)
|
||||
.session(self.session_id)
|
||||
.and_then(|session| session.os);
|
||||
.host(self.host_id)
|
||||
.and_then(|host| host.os);
|
||||
let mark = HostMark::new(("terminal-tab-os", id.0), self.default_title(cx), os).small();
|
||||
let tab_menu = self.tab_menu(cx);
|
||||
ClosableTabTitle::new(("terminal-tab", id.0), mark, self.tab_title(cx))
|
||||
@@ -268,7 +268,7 @@ impl Panel for TerminalPanel {
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
|
||||
let session_id = self.session_id;
|
||||
let host_id = self.host_id;
|
||||
let terminal_id = self.id;
|
||||
Some(vec![
|
||||
Button::new(("sftp", terminal_id.0))
|
||||
@@ -276,7 +276,7 @@ impl Panel for TerminalPanel {
|
||||
.label("SFTP")
|
||||
.tooltip("打开 SFTP 文件浏览")
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(Box::new(OpenExplorer(session_id)), cx)
|
||||
window.dispatch_action(Box::new(OpenExplorer(host_id)), cx)
|
||||
}),
|
||||
Button::new(("reconnect", terminal_id.0))
|
||||
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||
@@ -302,7 +302,7 @@ impl Panel for TerminalPanel {
|
||||
}
|
||||
|
||||
/// The connection commands at the bottom of a remote terminal's context menu.
|
||||
fn connection_menu_items(id: RemoteTerminalId, session_id: SessionId) -> TerminalMenuItems {
|
||||
fn connection_menu_items(id: RemoteTerminalId, host_id: HostId) -> TerminalMenuItems {
|
||||
Rc::new(move |menu, lifecycle| {
|
||||
let connected = matches!(
|
||||
lifecycle,
|
||||
@@ -311,7 +311,7 @@ fn connection_menu_items(id: RemoteTerminalId, session_id: SessionId) -> Termina
|
||||
menu.menu_with_icon(
|
||||
"打开 SFTP",
|
||||
Icon::new(CatalogIcon::FolderTree),
|
||||
Box::new(OpenExplorer(session_id)),
|
||||
Box::new(OpenExplorer(host_id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"重新连接",
|
||||
@@ -333,22 +333,22 @@ fn connection_menu_items(id: RemoteTerminalId, session_id: SessionId) -> Termina
|
||||
#[derive(Clone)]
|
||||
struct TabMenu {
|
||||
id: RemoteTerminalId,
|
||||
session_id: SessionId,
|
||||
host_id: HostId,
|
||||
host_is_ip: bool,
|
||||
group: Option<WeakEntity<TabGroup>>,
|
||||
panel: EntityId,
|
||||
}
|
||||
|
||||
impl RenamableTab for TerminalPanel {
|
||||
/// The session's name, as the tab shows it without a title of its own.
|
||||
/// The host's name, as the tab shows it without a title of its own.
|
||||
fn default_title(&self, cx: &App) -> SharedString {
|
||||
self.store
|
||||
.read(cx)
|
||||
.session(self.session_id)
|
||||
.map(|session| session.name.clone())
|
||||
.host(self.host_id)
|
||||
.map(|host| host.name.clone())
|
||||
.unwrap_or_else(|| "终端".into())
|
||||
}
|
||||
/// The tab's label: its own title when it has one, else the session name.
|
||||
/// The tab's label: its own title when it has one, else the host name.
|
||||
fn tab_title(&self, cx: &App) -> SharedString {
|
||||
self.custom_title
|
||||
.clone()
|
||||
@@ -362,7 +362,7 @@ impl RenamableTab for TerminalPanel {
|
||||
|
||||
impl TabMenu {
|
||||
fn build(&self, menu: PopupMenu, cx: &App) -> PopupMenu {
|
||||
let (id, session_id) = (self.id, self.session_id);
|
||||
let (id, host_id) = (self.id, self.host_id);
|
||||
let copy_host = if self.host_is_ip {
|
||||
"复制 IP 地址"
|
||||
} else {
|
||||
@@ -377,17 +377,17 @@ impl TabMenu {
|
||||
.menu_with_icon(
|
||||
"在新标签页中连接",
|
||||
Icon::new(CatalogIcon::Plug),
|
||||
Box::new(ConnectSession(session_id)),
|
||||
Box::new(ConnectHost(host_id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"打开 SFTP",
|
||||
Icon::new(CatalogIcon::FolderTree),
|
||||
Box::new(OpenExplorer(session_id)),
|
||||
Box::new(OpenExplorer(host_id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
copy_host,
|
||||
Icon::new(IconName::Copy),
|
||||
Box::new(CopySessionHost(session_id)),
|
||||
Box::new(CopyHostAddress(host_id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"重新连接",
|
||||
@@ -397,7 +397,7 @@ impl TabMenu {
|
||||
.menu_with_icon(
|
||||
"编辑主机…",
|
||||
Icon::new(CatalogIcon::Pencil),
|
||||
Box::new(EditSession(session_id)),
|
||||
Box::new(EditHost(host_id)),
|
||||
)
|
||||
.separator();
|
||||
close_tab_items(
|
||||
|
||||
@@ -5,7 +5,7 @@ use async_channel::Sender;
|
||||
|
||||
use super::{Latency, TerminalSize};
|
||||
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
|
||||
use crate::session::{HostOs, SessionLogin};
|
||||
use crate::host::{HostLogin, HostOs};
|
||||
|
||||
/// Commands accepted by every terminal byte-stream transport.
|
||||
pub enum TerminalTransportCommand {
|
||||
@@ -68,16 +68,16 @@ pub trait TerminalTransportFactory: Send + Sync + 'static {
|
||||
fn create(&self) -> Box<dyn TerminalTransport>;
|
||||
}
|
||||
|
||||
/// Creates a fresh remote factory from the latest saved login of a session.
|
||||
/// Creates a fresh remote factory from the latest saved login of a host.
|
||||
/// Keeping this boundary at the workspace makes reconnects pick up edits
|
||||
/// immediately and lets UI tests inject a deterministic fake.
|
||||
pub trait RemoteTerminalTransportProvider: Send + Sync + 'static {
|
||||
fn factory_for(&self, login: &SessionLogin) -> SharedTerminalTransportFactory;
|
||||
fn factory_for(&self, login: &HostLogin) -> SharedTerminalTransportFactory;
|
||||
}
|
||||
|
||||
pub type SharedRemoteTerminalTransportProvider = Arc<dyn RemoteTerminalTransportProvider>;
|
||||
|
||||
/// Provider useful for tests and embedders that want every session to use the
|
||||
/// Provider useful for tests and embedders that want every host to use the
|
||||
/// same factory.
|
||||
pub struct FixedRemoteTerminalTransportProvider {
|
||||
factory: SharedTerminalTransportFactory,
|
||||
@@ -90,7 +90,7 @@ impl FixedRemoteTerminalTransportProvider {
|
||||
}
|
||||
|
||||
impl RemoteTerminalTransportProvider for FixedRemoteTerminalTransportProvider {
|
||||
fn factory_for(&self, _: &SessionLogin) -> SharedTerminalTransportFactory {
|
||||
fn factory_for(&self, _: &HostLogin) -> SharedTerminalTransportFactory {
|
||||
self.factory.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::app::{
|
||||
NewCredential, ShowCredentials,
|
||||
};
|
||||
use crate::credential::{CredentialDialog, open_credential_dialog};
|
||||
use crate::session::{AuthKind, CredentialKind, read_public_key};
|
||||
use crate::host::{AuthKind, CredentialKind, read_public_key};
|
||||
use crate::shared::confirm_delete;
|
||||
|
||||
use super::{sidebar::SidebarMode, workspace_view::Workspace};
|
||||
@@ -117,7 +117,7 @@ impl Workspace {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let hosts = store.sessions_using(id).count();
|
||||
let hosts = store.hosts_using(id).count();
|
||||
let store = self.store.clone();
|
||||
confirm_delete(
|
||||
&name,
|
||||
@@ -147,7 +147,7 @@ fn describe_credential_delete(hosts: usize, kind: CredentialKind) -> Option<Shar
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::describe_credential_delete;
|
||||
use crate::session::CredentialKind;
|
||||
use crate::host::CredentialKind;
|
||||
|
||||
#[test]
|
||||
fn deleting_a_credential_says_how_many_hosts_and_what_they_become() {
|
||||
|
||||
+10
-10
@@ -7,10 +7,10 @@ use gpui_kit::component::{WindowExt as _, dock::DockPlacement, notification::Not
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
DeleteForward, EditForward, NewForward, ShowForwards, ShowSessions, StartForward, StopForward,
|
||||
DeleteForward, EditForward, NewForward, ShowForwards, ShowHosts, StartForward, StopForward,
|
||||
};
|
||||
use crate::forward::{ForwardManagerEvent, open_forward_dialog};
|
||||
use crate::session::ForwardId;
|
||||
use crate::host::ForwardId;
|
||||
use crate::shared::confirm_delete;
|
||||
|
||||
use super::{
|
||||
@@ -45,13 +45,13 @@ impl Workspace {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn on_show_sessions(
|
||||
pub(super) fn on_show_hosts(
|
||||
&mut self,
|
||||
_: &ShowSessions,
|
||||
_: &ShowHosts,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.show_sidebar(SidebarMode::Sessions, window, cx);
|
||||
self.show_sidebar(SidebarMode::Hosts, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn on_show_forwards(
|
||||
@@ -70,7 +70,7 @@ impl Workspace {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let store = self.store.read(cx);
|
||||
if store.sessions().is_empty() {
|
||||
if store.hosts().is_empty() {
|
||||
window.push_notification(
|
||||
Notification::warning("端口转发经由一台主机的 SSH 连接,请先新建主机。"),
|
||||
cx,
|
||||
@@ -159,10 +159,10 @@ impl Workspace {
|
||||
// The title bar counts the forwards that are running.
|
||||
cx.notify();
|
||||
}
|
||||
ForwardManagerEvent::PromptRequested(id, session, generation, prompt) => self
|
||||
ForwardManagerEvent::PromptRequested(id, host, generation, prompt) => self
|
||||
.enqueue_prompt(
|
||||
PromptOwner::Forward(*id, *generation),
|
||||
*session,
|
||||
*host,
|
||||
prompt.clone(),
|
||||
window,
|
||||
cx,
|
||||
@@ -211,11 +211,11 @@ impl Workspace {
|
||||
pub(super) fn forward_prompt_origin(&self, id: ForwardId, cx: &App) -> Option<String> {
|
||||
let store = self.store.read(cx);
|
||||
let rule = store.forward(id)?;
|
||||
let session = store.session(rule.session)?;
|
||||
let host = store.host(rule.host)?;
|
||||
Some(format!(
|
||||
"端口转发“{}”正在通过 {} 连接。",
|
||||
rule.title(),
|
||||
session.name
|
||||
host.name
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
//! The window shell: title bar, dock area with its sidebar, status bar, and
|
||||
//! the start page (recent sessions) the center shows while no tab is open.
|
||||
//! the start page (recent hosts) the center shows while no tab is open.
|
||||
|
||||
mod credentials;
|
||||
mod dock_skin;
|
||||
mod forwards;
|
||||
mod recent_sessions;
|
||||
mod recent_hosts;
|
||||
mod sidebar;
|
||||
mod status_bar;
|
||||
mod title_bar;
|
||||
|
||||
@@ -7,34 +7,32 @@ use gpui_kit::component::{
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CatalogIcon, ConnectSelected, ConnectSession, NewSession, RECENT_SESSIONS_CONTEXT,
|
||||
};
|
||||
use crate::session::{HostOs, SessionId, SessionStore, session_menu};
|
||||
use crate::app::{CatalogIcon, ConnectHost, ConnectSelected, NewHost, RECENT_HOSTS_CONTEXT};
|
||||
use crate::host::{HostId, HostOs, HostStore, host_menu};
|
||||
use crate::shared::HostMark;
|
||||
|
||||
/// The center's empty state: shown in place of the tabs while none is open.
|
||||
///
|
||||
/// Lists the sessions most recently connected, newest first, so the next
|
||||
/// Lists the hosts most recently connected, newest first, so the next
|
||||
/// connection is a double click away. Not a dock panel: the workspace's dock skin
|
||||
/// lays it over the empty center, and it goes away as soon as a tab opens.
|
||||
pub struct RecentSessions {
|
||||
store: Entity<SessionStore>,
|
||||
pub struct RecentHosts {
|
||||
store: Entity<HostStore>,
|
||||
/// The workspace's focus handle: actions dispatched on it reach the
|
||||
/// workspace handlers whatever is focused.
|
||||
target: FocusHandle,
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handle: ScrollHandle,
|
||||
selected: Option<SessionId>,
|
||||
selected: Option<HostId>,
|
||||
/// The row a right click landed on, for the menu about to open. Blank
|
||||
/// space leaves it empty, and an empty menu does not open.
|
||||
menu_hit: Rc<Cell<Option<SessionId>>>,
|
||||
menu_hit: Rc<Cell<Option<HostId>>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
/// One row of the list: a plain snapshot taken from the store in `render`.
|
||||
struct RecentRow {
|
||||
id: SessionId,
|
||||
id: HostId,
|
||||
name: SharedString,
|
||||
address: String,
|
||||
group: Option<SharedString>,
|
||||
@@ -42,8 +40,8 @@ struct RecentRow {
|
||||
os: Option<HostOs>,
|
||||
}
|
||||
|
||||
impl RecentSessions {
|
||||
pub fn new(store: Entity<SessionStore>, target: FocusHandle, cx: &mut Context<Self>) -> Self {
|
||||
impl RecentHosts {
|
||||
pub fn new(store: Entity<HostStore>, target: FocusHandle, cx: &mut Context<Self>) -> Self {
|
||||
let subscription = cx.observe(&store, |_, _, cx| cx.notify());
|
||||
Self {
|
||||
store,
|
||||
@@ -66,18 +64,18 @@ impl RecentSessions {
|
||||
fn rows(&self, cx: &App) -> Vec<RecentRow> {
|
||||
let store = self.store.read(cx);
|
||||
store
|
||||
.recent_sessions()
|
||||
.map(|session| RecentRow {
|
||||
id: session.id,
|
||||
name: session.name.clone(),
|
||||
address: session.address(),
|
||||
.recent_hosts()
|
||||
.map(|host| RecentRow {
|
||||
id: host.id,
|
||||
name: host.name.clone(),
|
||||
address: host.endpoint(),
|
||||
// The full path, so two 数据库 groups under different
|
||||
// parents do not read the same here.
|
||||
group: session
|
||||
group: host
|
||||
.group
|
||||
.map(|id| SharedString::from(store.group_path(id))),
|
||||
connected: session.state.is_connected(),
|
||||
os: session.os,
|
||||
connected: host.state.is_connected(),
|
||||
os: host.os,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -105,10 +103,10 @@ impl RecentSessions {
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("recent-new-session")
|
||||
Button::new("recent-new-host")
|
||||
.icon(IconName::Plus)
|
||||
.label("新建主机…")
|
||||
.on_click(move |_, window, cx| target.dispatch_action(&NewSession, window, cx)),
|
||||
.on_click(move |_, window, cx| target.dispatch_action(&NewHost, window, cx)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,11 +129,11 @@ impl RecentSessions {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(id) = self.selected {
|
||||
self.target.dispatch_action(&ConnectSession(id), window, cx);
|
||||
self.target.dispatch_action(&ConnectHost(id), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows, with the session menu of the session tree. Like the tree's,
|
||||
/// The rows, with the host menu of the host tree. Like the tree's,
|
||||
/// the menu hangs off the list rather than off each row: capture clears
|
||||
/// the hit before a right-clicked row writes itself back, and the menu
|
||||
/// is built after both.
|
||||
@@ -143,12 +141,12 @@ impl RecentSessions {
|
||||
let clear_hit = self.menu_hit.clone();
|
||||
let menu_hit = self.menu_hit.clone();
|
||||
v_flex()
|
||||
.id("recent-session-list")
|
||||
.id("recent-host-list")
|
||||
.gap_1()
|
||||
.capture_any_mouse_down(move |_, _, _| clear_hit.set(None))
|
||||
.children(rows.into_iter().map(|row| self.render_row(row, cx)))
|
||||
.context_menu(move |menu, _, _| match menu_hit.get() {
|
||||
Some(id) => session_menu(menu, id),
|
||||
Some(id) => host_menu(menu, id),
|
||||
None => menu,
|
||||
})
|
||||
}
|
||||
@@ -157,7 +155,7 @@ impl RecentSessions {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let id = row.id;
|
||||
let selected = self.selected == Some(id);
|
||||
ListItem::new(("recent-session", id.0))
|
||||
ListItem::new(("recent-host", id.0))
|
||||
.w_full()
|
||||
.px_3()
|
||||
.py_2()
|
||||
@@ -168,7 +166,7 @@ impl RecentSessions {
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(HostMark::new(
|
||||
("recent-session-os", id.0),
|
||||
("recent-host-os", id.0),
|
||||
row.name.clone(),
|
||||
row.os,
|
||||
))
|
||||
@@ -222,27 +220,27 @@ impl RecentSessions {
|
||||
this.focus_handle.focus(window, cx);
|
||||
cx.notify();
|
||||
if event.click_count() == 2 {
|
||||
this.target.dispatch_action(&ConnectSession(id), window, cx);
|
||||
this.target.dispatch_action(&ConnectHost(id), window, cx);
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for RecentSessions {
|
||||
impl Focusable for RecentHosts {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RecentSessions {
|
||||
impl Render for RecentHosts {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let rows = self.rows(cx);
|
||||
|
||||
div()
|
||||
.id("recent-sessions")
|
||||
.id("recent-hosts")
|
||||
.test_support()
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context(RECENT_SESSIONS_CONTEXT)
|
||||
.key_context(RECENT_HOSTS_CONTEXT)
|
||||
.on_action(cx.listener(Self::on_connect_selected))
|
||||
.size_full()
|
||||
.overflow_y_scroll()
|
||||
+14
-14
@@ -9,18 +9,18 @@ use gpui_kit::*;
|
||||
use crate::app::{CatalogIcon, OpenSettings};
|
||||
use crate::credential::CredentialPanel;
|
||||
use crate::forward::ForwardPanel;
|
||||
use crate::session::SessionPanel;
|
||||
use crate::host::HostPanel;
|
||||
|
||||
/// Which list the left dock is showing.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SidebarMode {
|
||||
#[default]
|
||||
Sessions,
|
||||
Hosts,
|
||||
Forwards,
|
||||
Credentials,
|
||||
}
|
||||
|
||||
/// The left dock's one panel: the session tree, the port-forwarding list or
|
||||
/// The left dock's one panel: the host tree, the port-forwarding list or
|
||||
/// the credential list, whichever the title bar's switch picked, over the
|
||||
/// 设置 button they share.
|
||||
///
|
||||
@@ -31,20 +31,20 @@ pub enum SidebarMode {
|
||||
/// and its scroll position.
|
||||
pub struct Sidebar {
|
||||
mode: SidebarMode,
|
||||
sessions: Entity<SessionPanel>,
|
||||
hosts: Entity<HostPanel>,
|
||||
forwards: Entity<ForwardPanel>,
|
||||
credentials: Entity<CredentialPanel>,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
pub fn new(
|
||||
sessions: Entity<SessionPanel>,
|
||||
hosts: Entity<HostPanel>,
|
||||
forwards: Entity<ForwardPanel>,
|
||||
credentials: Entity<CredentialPanel>,
|
||||
) -> Self {
|
||||
Self {
|
||||
mode: SidebarMode::default(),
|
||||
sessions,
|
||||
hosts,
|
||||
forwards,
|
||||
credentials,
|
||||
}
|
||||
@@ -67,8 +67,8 @@ impl Sidebar {
|
||||
/// Put the keyboard in the search field of the list being shown.
|
||||
pub fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match self.mode {
|
||||
SidebarMode::Sessions => self
|
||||
.sessions
|
||||
SidebarMode::Hosts => self
|
||||
.hosts
|
||||
.update(cx, |panel, cx| panel.focus_search(window, cx)),
|
||||
SidebarMode::Forwards => self
|
||||
.forwards
|
||||
@@ -85,7 +85,7 @@ impl EventEmitter<PanelEvent> for Sidebar {}
|
||||
impl Focusable for Sidebar {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
match self.mode {
|
||||
SidebarMode::Sessions => self.sessions.read(cx).focus_handle(cx),
|
||||
SidebarMode::Hosts => self.hosts.read(cx).focus_handle(cx),
|
||||
SidebarMode::Forwards => self.forwards.read(cx).focus_handle(cx),
|
||||
SidebarMode::Credentials => self.credentials.read(cx).focus_handle(cx),
|
||||
}
|
||||
@@ -110,8 +110,8 @@ impl BasePanel for Sidebar {
|
||||
impl Panel for Sidebar {
|
||||
fn title(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
match self.mode {
|
||||
SidebarMode::Sessions => self
|
||||
.sessions
|
||||
SidebarMode::Hosts => self
|
||||
.hosts
|
||||
.update(cx, |panel, cx| panel.title(window, cx).into_any_element()),
|
||||
SidebarMode::Forwards => self.forwards.read(cx).title().into_any_element(),
|
||||
SidebarMode::Credentials => self.credentials.read(cx).title().into_any_element(),
|
||||
@@ -124,8 +124,8 @@ impl Panel for Sidebar {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<Vec<Button>> {
|
||||
match self.mode {
|
||||
SidebarMode::Sessions => self
|
||||
.sessions
|
||||
SidebarMode::Hosts => self
|
||||
.hosts
|
||||
.update(cx, |panel, cx| panel.toolbar_buttons(window, cx)),
|
||||
SidebarMode::Forwards => Some(self.forwards.read(cx).toolbar_buttons()),
|
||||
SidebarMode::Credentials => Some(self.credentials.read(cx).toolbar_buttons()),
|
||||
@@ -144,7 +144,7 @@ impl Panel for Sidebar {
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let list = match self.mode {
|
||||
SidebarMode::Sessions => self.sessions.clone().into_any_element(),
|
||||
SidebarMode::Hosts => self.hosts.clone().into_any_element(),
|
||||
SidebarMode::Forwards => self.forwards.clone().into_any_element(),
|
||||
SidebarMode::Credentials => self.credentials.clone().into_any_element(),
|
||||
};
|
||||
|
||||
+14
-19
@@ -7,7 +7,7 @@ use gpui_kit::*;
|
||||
|
||||
use crate::app::CatalogIcon;
|
||||
use crate::explorer::ExplorerStatus;
|
||||
use crate::session::{ConnectionState, Session};
|
||||
use crate::host::{ConnectionState, Host};
|
||||
use crate::terminal::{TerminalLifecycle, TerminalStatus};
|
||||
|
||||
/// The active connection/process state on the left and terminal facts on the
|
||||
@@ -15,12 +15,12 @@ use crate::terminal::{TerminalLifecycle, TerminalStatus};
|
||||
/// no terminal in front there is no size to show.
|
||||
#[derive(IntoElement)]
|
||||
pub enum WorkspaceStatus {
|
||||
/// The active session, with the terminal in front when that is one of
|
||||
/// The active host, with the terminal in front when that is one of
|
||||
/// its terminals: the start page and the settings have none.
|
||||
Session(Option<Session>, Option<TerminalStatus>),
|
||||
Host(Option<Host>, Option<TerminalStatus>),
|
||||
Local(TerminalStatus),
|
||||
/// An SFTP tab: its own connection and what went wrong in it.
|
||||
Explorer(Option<Session>, ExplorerStatus),
|
||||
Explorer(Option<Host>, ExplorerStatus),
|
||||
}
|
||||
|
||||
/// The icon for a connection state; a dropped one, like a problem, in red.
|
||||
@@ -50,13 +50,13 @@ impl RenderOnce for WorkspaceStatus {
|
||||
Option<String>,
|
||||
Option<TerminalStatus>,
|
||||
) = match self {
|
||||
WorkspaceStatus::Session(active, terminal) => match active {
|
||||
Some(session) => {
|
||||
alarming = session.state == ConnectionState::Disconnected;
|
||||
WorkspaceStatus::Host(active, terminal) => match active {
|
||||
Some(host) => {
|
||||
alarming = host.state == ConnectionState::Disconnected;
|
||||
(
|
||||
format!("{} {}", session.state.label(), session.name).into(),
|
||||
state_icon(session.state, cx),
|
||||
Some(session.address()),
|
||||
format!("{} {}", host.state.label(), host.name).into(),
|
||||
state_icon(host.state, cx),
|
||||
Some(host.endpoint()),
|
||||
terminal,
|
||||
)
|
||||
}
|
||||
@@ -67,10 +67,10 @@ impl RenderOnce for WorkspaceStatus {
|
||||
terminal,
|
||||
),
|
||||
},
|
||||
WorkspaceStatus::Explorer(session, status) => {
|
||||
let name = session
|
||||
WorkspaceStatus::Explorer(host, status) => {
|
||||
let name = host
|
||||
.as_ref()
|
||||
.map_or_else(|| "SFTP".into(), |session| session.name.clone());
|
||||
.map_or_else(|| "SFTP".into(), |host| host.name.clone());
|
||||
let state = format!("{} {name}", status.state.label());
|
||||
let (text, icon) = match (status.state, status.problem) {
|
||||
(ConnectionState::Disconnected, Some(problem)) => {
|
||||
@@ -90,12 +90,7 @@ impl RenderOnce for WorkspaceStatus {
|
||||
(state, state_icon(now, cx))
|
||||
}
|
||||
};
|
||||
(
|
||||
text.into(),
|
||||
icon,
|
||||
session.map(|session| session.address()),
|
||||
None,
|
||||
)
|
||||
(text.into(), icon, host.map(|host| host.endpoint()), None)
|
||||
}
|
||||
WorkspaceStatus::Local(status) => {
|
||||
let lifecycle = status.lifecycle();
|
||||
|
||||
+10
-10
@@ -8,8 +8,8 @@ use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CatalogIcon, NewLocalTerminal, NewSession, ShowCredentials, ShowForwards, ShowSessions,
|
||||
ShowUpdate, ToggleSessionPanel, ToggleTheme,
|
||||
CatalogIcon, NewHost, NewLocalTerminal, ShowCredentials, ShowForwards, ShowHosts, ShowUpdate,
|
||||
ToggleHostPanel, ToggleTheme,
|
||||
};
|
||||
use crate::update::UpdateBadge;
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn render_title_bar(
|
||||
cx: &App,
|
||||
) -> TitleBar {
|
||||
let dark = cx.theme().is_dark();
|
||||
let (mode, toggle, new_session, new_local, theme, show_update) = (
|
||||
let (mode, toggle, new_host, new_local, theme, show_update) = (
|
||||
target.clone(),
|
||||
target.clone(),
|
||||
target.clone(),
|
||||
@@ -60,11 +60,11 @@ pub fn render_title_bar(
|
||||
.ghost()
|
||||
.small()
|
||||
.child(
|
||||
Button::new("show-sessions")
|
||||
Button::new("show-hosts")
|
||||
.icon(CatalogIcon::Server)
|
||||
.tooltip("主机")
|
||||
.accessibility_label("主机")
|
||||
.selected(sidebar == Some(SidebarMode::Sessions)),
|
||||
.selected(sidebar == Some(SidebarMode::Hosts)),
|
||||
)
|
||||
.child(
|
||||
Button::new("show-forwards")
|
||||
@@ -91,7 +91,7 @@ pub fn render_title_bar(
|
||||
)
|
||||
.on_click(move |picked: &Vec<usize>, window, cx| {
|
||||
match picked.first() {
|
||||
Some(0) => mode.dispatch_action(&ShowSessions, window, cx),
|
||||
Some(0) => mode.dispatch_action(&ShowHosts, window, cx),
|
||||
Some(1) => mode.dispatch_action(&ShowForwards, window, cx),
|
||||
Some(2) => mode.dispatch_action(&ShowCredentials, window, cx),
|
||||
_ => {}
|
||||
@@ -107,7 +107,7 @@ pub fn render_title_bar(
|
||||
.gap_1()
|
||||
.on_mouse_down(MouseButton::Left, no_drag)
|
||||
.child(
|
||||
Button::new("toggle-sessions")
|
||||
Button::new("toggle-hosts")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(IconName::PanelLeft)
|
||||
@@ -117,17 +117,17 @@ pub fn render_title_bar(
|
||||
"显示侧栏"
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
toggle.dispatch_action(&ToggleSessionPanel, window, cx)
|
||||
toggle.dispatch_action(&ToggleHostPanel, window, cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("new-session")
|
||||
Button::new("new-host")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(IconName::Plus)
|
||||
.label("新建主机…")
|
||||
.on_click(move |_, window, cx| {
|
||||
new_session.dispatch_action(&NewSession, window, cx)
|
||||
new_host.dispatch_action(&NewHost, window, cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
|
||||
@@ -7,7 +7,7 @@ use gpui_kit::component::{WindowExt as _, notification::Notification};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{CheckForUpdates, DownloadUpdate, OpenDownloadPage, RestartToUpdate, ShowUpdate};
|
||||
use crate::session::ConnectionState;
|
||||
use crate::host::ConnectionState;
|
||||
use crate::terminal::TerminalLifecycle;
|
||||
use crate::update::{RestartImpact, UpdaterEvent, open_update_dialog};
|
||||
|
||||
|
||||
+177
-202
@@ -20,15 +20,15 @@ use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CenterTab, ClearTerminal, CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseSettings,
|
||||
CloseTabs, CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectSession, CopyAgentSkill,
|
||||
CopySessionHost, CopySessionId, CopyTerminal, DeleteGroup, DeleteSession, DisconnectSession,
|
||||
DisconnectTerminal, DismissTerminalFind, DuplicateSession, EditSession, ExpandAllGroups,
|
||||
CloseTabs, CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectHost, CopyAgentSkill,
|
||||
CopyHostAddress, CopyHostId, CopyTerminal, DeleteGroup, DeleteHost, DisconnectHost,
|
||||
DisconnectTerminal, DismissTerminalFind, DuplicateHost, EditHost, ExpandAllGroups,
|
||||
ExplorerAction, ExplorerCommand, ExplorerShortcut, FindInTerminal, FindNextInTerminal,
|
||||
FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand, MoveSessionNode,
|
||||
NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer,
|
||||
OpenSettings, PasteTerminal, ReconnectTerminal, RefreshCliIntegration, RemoveAgentSkill,
|
||||
RemoveCliCommand, RenameExplorer, RenameGroup, RenameTerminal, RestartLocalTerminal,
|
||||
SetFileSizeFormat, ToggleSessionPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
|
||||
FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand, MoveHostNode,
|
||||
NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal, OpenExplorer, OpenSettings,
|
||||
PasteTerminal, ReconnectTerminal, RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand,
|
||||
RenameExplorer, RenameGroup, RenameTerminal, RestartLocalTerminal, SetFileSizeFormat,
|
||||
ToggleHostPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
|
||||
};
|
||||
use crate::cli::{CliIntegration, CliServer, CliTarget, IntegrationPaths, SshCliBackend};
|
||||
use crate::connection::{
|
||||
@@ -41,10 +41,9 @@ use crate::forward::{
|
||||
ForwardManager, ForwardManagerEvent, ForwardPanel, SharedForwardTransportProvider,
|
||||
SshForwardTransportProvider,
|
||||
};
|
||||
use crate::session::{
|
||||
ConnectionState, Dependents, ForwardId, GroupId, SessionId, SessionNode, SessionPanel,
|
||||
SessionStore, SessionStoreEvent, confirm_delete_group, confirm_delete_session,
|
||||
open_group_dialog, open_session_dialog,
|
||||
use crate::host::{
|
||||
ConnectionState, Dependents, ForwardId, GroupId, HostId, HostNode, HostPanel, HostStore,
|
||||
HostStoreEvent, confirm_delete_group, confirm_delete_host, open_group_dialog, open_host_dialog,
|
||||
};
|
||||
use crate::settings::{
|
||||
Appearance, SettingsPanel, SettingsPanelEvent, SettingsStore, SettingsStoreEvent,
|
||||
@@ -63,7 +62,7 @@ use crate::terminal::{
|
||||
use crate::update::{UpdateServices, Updater, UpdaterEvent};
|
||||
|
||||
use super::{
|
||||
dock_skin::WorkspaceDockSkin, recent_sessions::RecentSessions, sidebar::Sidebar,
|
||||
dock_skin::WorkspaceDockSkin, recent_hosts::RecentHosts, sidebar::Sidebar,
|
||||
status_bar::WorkspaceStatus, title_bar::render_title_bar,
|
||||
};
|
||||
|
||||
@@ -121,21 +120,21 @@ pub(super) enum PromptOwner {
|
||||
|
||||
/// The main window content: title bar above the dock, status bar below.
|
||||
///
|
||||
/// Owns the session store, the dock and the registry of open per-session
|
||||
/// Owns the host store, the dock and the registry of open per-host
|
||||
/// panels, and handles every application action.
|
||||
pub struct Workspace {
|
||||
pub(super) store: Entity<SessionStore>,
|
||||
pub(super) store: Entity<HostStore>,
|
||||
pub(super) dock_area: Entity<DockArea>,
|
||||
skin: Rc<WorkspaceDockSkin>,
|
||||
/// The left dock's panel: the session tree or the forward list.
|
||||
/// The left dock's panel: the host tree or the forward list.
|
||||
pub(super) sidebar: Entity<Sidebar>,
|
||||
session_panel: Entity<SessionPanel>,
|
||||
host_panel: Entity<HostPanel>,
|
||||
/// The port forwards that are running, each on a connection of its own.
|
||||
pub(super) forwards: Entity<ForwardManager>,
|
||||
/// The start page the dock skin shows while the center has no tab.
|
||||
recent: Entity<RecentSessions>,
|
||||
recent: Entity<RecentHosts>,
|
||||
pub(super) terminals: HashMap<RemoteTerminalId, Entity<TerminalPanel>>,
|
||||
/// SFTP tabs; a session can have several, like terminals.
|
||||
/// SFTP tabs; a host can have several, like terminals.
|
||||
pub(super) explorers: HashMap<ExplorerId, Entity<ExplorerPanel>>,
|
||||
pub(super) local_terminals: HashMap<LocalTerminalId, Entity<LocalTerminalPanel>>,
|
||||
/// The settings tab, while it is open. There is only ever one.
|
||||
@@ -157,15 +156,15 @@ pub struct Workspace {
|
||||
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
|
||||
sftp_provider: SharedSftpTransportProvider,
|
||||
local_directory_provider: SharedLocalDirectoryProvider,
|
||||
/// Backs the session dialog's 「测试连接」.
|
||||
/// Backs the host dialog's 「测试连接」.
|
||||
connection_tester: SharedConnectionTester,
|
||||
next_remote_terminal_id: u64,
|
||||
next_local_terminal_id: u64,
|
||||
next_explorer_id: u64,
|
||||
/// The center tab displayed most recently; `CloseActiveTab` closes it.
|
||||
active_tab: Option<CenterTab>,
|
||||
pub(super) prompt_queue: VecDeque<(PromptOwner, SessionId, ConnectionPrompt)>,
|
||||
pub(super) active_prompt: Option<(PromptOwner, SessionId, u64)>,
|
||||
pub(super) prompt_queue: VecDeque<(PromptOwner, HostId, ConnectionPrompt)>,
|
||||
pub(super) active_prompt: Option<(PromptOwner, HostId, u64)>,
|
||||
/// Dispatch target for the title bar and start page: actions sent to it
|
||||
/// reach the workspace handlers whatever is focused.
|
||||
pub(super) focus_handle: FocusHandle,
|
||||
@@ -174,10 +173,10 @@ pub struct Workspace {
|
||||
|
||||
impl Workspace {
|
||||
/// `store` is built by `main` from the database on disk, and by the UI
|
||||
/// tests from `SessionStore::seed`; `settings` likewise from the settings
|
||||
/// tests from `HostStore::seed`; `settings` likewise from the settings
|
||||
/// file, or kept in memory.
|
||||
pub fn new(
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
settings: Entity<SettingsStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -238,7 +237,7 @@ impl Workspace {
|
||||
/// Bring the window forward whenever ShellRS is opened while it is
|
||||
/// already running. The second copy only passes the word on and exits:
|
||||
/// two of them on one data directory would each keep their own copy of
|
||||
/// the sessions and write over the other's changes.
|
||||
/// the hosts and write over the other's changes.
|
||||
///
|
||||
/// Asked on a timer, like every other worker: the thread that hears the
|
||||
/// request never wakes the window itself.
|
||||
@@ -265,7 +264,7 @@ impl Workspace {
|
||||
}
|
||||
|
||||
/// Tell the CLI server what it may use: whether 启用外部 CLI is on, and
|
||||
/// the sessions as they are now.
|
||||
/// the hosts as they are now.
|
||||
fn sync_cli_server(&self, cx: &App) {
|
||||
if let Some(server) = &self.cli_server {
|
||||
server.set_enabled(self.settings.read(cx).settings().external_cli.enabled);
|
||||
@@ -278,7 +277,7 @@ impl Workspace {
|
||||
// constructor; bundling them would only move the list elsewhere.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_with_services(
|
||||
store: Entity<SessionStore>,
|
||||
store: Entity<HostStore>,
|
||||
settings: Entity<SettingsStore>,
|
||||
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
|
||||
local_terminal_factory: SharedTerminalTransportFactory,
|
||||
@@ -290,7 +289,7 @@ impl Workspace {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let recent = cx.new(|cx| RecentSessions::new(store.clone(), focus_handle.clone(), cx));
|
||||
let recent = cx.new(|cx| RecentHosts::new(store.clone(), focus_handle.clone(), cx));
|
||||
let (dock_area, skin) = WorkspaceDockSkin::dock_area(
|
||||
DOCK_ID,
|
||||
Some(DOCK_VERSION),
|
||||
@@ -298,7 +297,7 @@ impl Workspace {
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let session_panel = cx.new(|cx| SessionPanel::new(store.clone(), window, cx));
|
||||
let host_panel = cx.new(|cx| HostPanel::new(store.clone(), window, cx));
|
||||
let forwards = cx.new(|cx| ForwardManager::new(store.clone(), forward_provider, cx));
|
||||
let automatic = settings.read(cx).settings().update.automatic;
|
||||
let updater = cx.new(|cx| {
|
||||
@@ -317,13 +316,12 @@ impl Workspace {
|
||||
});
|
||||
let credential_panel =
|
||||
cx.new(|cx| CredentialPanel::new(store.clone(), focus_handle.clone(), window, cx));
|
||||
let sidebar =
|
||||
cx.new(|_| Sidebar::new(session_panel.clone(), forward_panel, credential_panel));
|
||||
// Start with focus in the session panel so window-level actions have a
|
||||
let sidebar = cx.new(|_| Sidebar::new(host_panel.clone(), forward_panel, credential_panel));
|
||||
// Start with focus in the host panel so window-level actions have a
|
||||
// dispatch path. The workspace's own handle is never focused: the
|
||||
// dialog layer is its child, and a focused ancestor would keep the
|
||||
// dialog's focus trap from taking focus.
|
||||
let panel_focus = session_panel.read(cx).focus_handle(cx);
|
||||
let panel_focus = host_panel.read(cx).focus_handle(cx);
|
||||
window.focus(&panel_focus, cx);
|
||||
// Before the first frame, so a dark theme never starts out light.
|
||||
crate::settings::apply(settings.read(cx).settings(), window, cx);
|
||||
@@ -336,11 +334,11 @@ impl Workspace {
|
||||
cx.subscribe_in(
|
||||
&store,
|
||||
window,
|
||||
|this, _, event: &SessionStoreEvent, window, cx| match event {
|
||||
SessionStoreEvent::PersistFailed(message) => {
|
||||
|this, _, event: &HostStoreEvent, window, cx| match event {
|
||||
HostStoreEvent::PersistFailed(message) => {
|
||||
window.push_notification(Notification::error(message.clone()), cx);
|
||||
}
|
||||
SessionStoreEvent::ConnectionSettingsChanged(id) => {
|
||||
HostStoreEvent::ConnectionSettingsChanged(id) => {
|
||||
let panels: Vec<_> = this.terminals_of(*id, cx).cloned().collect();
|
||||
if !panels.is_empty() {
|
||||
for panel in &panels {
|
||||
@@ -355,7 +353,7 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
// The forward manager restarts the rule itself.
|
||||
SessionStoreEvent::ForwardSettingsChanged(_) => {}
|
||||
HostStoreEvent::ForwardSettingsChanged(_) => {}
|
||||
},
|
||||
),
|
||||
cx.subscribe_in(
|
||||
@@ -401,13 +399,13 @@ impl Workspace {
|
||||
),
|
||||
];
|
||||
|
||||
// Sessions that start out connected get a terminal tab right away.
|
||||
let connected: Vec<SessionId> = store
|
||||
// Hosts that start out connected get a terminal tab right away.
|
||||
let connected: Vec<HostId> = store
|
||||
.read(cx)
|
||||
.sessions()
|
||||
.hosts()
|
||||
.iter()
|
||||
.filter(|session| session.state.is_connected())
|
||||
.map(|session| session.id)
|
||||
.filter(|host| host.state.is_connected())
|
||||
.map(|host| host.id)
|
||||
.collect();
|
||||
let mut terminals = HashMap::new();
|
||||
let mut center = DockLayout::tabs();
|
||||
@@ -456,7 +454,7 @@ impl Workspace {
|
||||
dock_area,
|
||||
skin,
|
||||
sidebar,
|
||||
session_panel,
|
||||
host_panel,
|
||||
forwards,
|
||||
recent,
|
||||
terminals,
|
||||
@@ -483,15 +481,15 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
/// The session store, for tests and for panels created later.
|
||||
pub fn store(&self) -> &Entity<SessionStore> {
|
||||
/// The host store, for tests and for panels created later.
|
||||
pub fn store(&self) -> &Entity<HostStore> {
|
||||
&self.store
|
||||
}
|
||||
|
||||
pub fn terminal(&self, id: SessionId, cx: &App) -> Option<&Entity<TerminalPanel>> {
|
||||
pub fn terminal(&self, id: HostId, cx: &App) -> Option<&Entity<TerminalPanel>> {
|
||||
self.terminals
|
||||
.values()
|
||||
.filter(|panel| panel.read(cx).session_id() == id)
|
||||
.filter(|panel| panel.read(cx).host_id() == id)
|
||||
.max_by_key(|panel| panel.read(cx).id().0)
|
||||
}
|
||||
|
||||
@@ -499,31 +497,31 @@ impl Workspace {
|
||||
self.terminals.get(&id)
|
||||
}
|
||||
|
||||
pub fn terminal_count(&self, id: SessionId, cx: &App) -> usize {
|
||||
pub fn terminal_count(&self, id: HostId, cx: &App) -> usize {
|
||||
self.terminals_of(id, cx).count()
|
||||
}
|
||||
|
||||
/// A session's terminal tabs, in no particular order.
|
||||
/// A host's terminal tabs, in no particular order.
|
||||
fn terminals_of<'a>(
|
||||
&'a self,
|
||||
session: SessionId,
|
||||
host: HostId,
|
||||
cx: &'a App,
|
||||
) -> impl Iterator<Item = &'a Entity<TerminalPanel>> {
|
||||
self.terminals
|
||||
.values()
|
||||
.filter(move |panel| panel.read(cx).session_id() == session)
|
||||
.filter(move |panel| panel.read(cx).host_id() == host)
|
||||
}
|
||||
|
||||
pub fn explorer(&self, id: ExplorerId) -> Option<&Entity<ExplorerPanel>> {
|
||||
self.explorers.get(&id)
|
||||
}
|
||||
|
||||
/// A session's SFTP tabs, oldest first.
|
||||
pub fn explorers_of(&self, session: SessionId, cx: &App) -> Vec<Entity<ExplorerPanel>> {
|
||||
/// A host's SFTP tabs, oldest first.
|
||||
pub fn explorers_of(&self, host: HostId, cx: &App) -> Vec<Entity<ExplorerPanel>> {
|
||||
let mut explorers: Vec<_> = self
|
||||
.explorers
|
||||
.iter()
|
||||
.filter(|(_, panel)| panel.read(cx).session_id() == session)
|
||||
.filter(|(_, panel)| panel.read(cx).host_id() == host)
|
||||
.collect();
|
||||
explorers.sort_by_key(|(id, _)| id.0);
|
||||
explorers
|
||||
@@ -532,15 +530,14 @@ impl Workspace {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a session has any tab open in the center.
|
||||
fn has_tabs(&self, session: SessionId, cx: &App) -> bool {
|
||||
self.terminals_of(session, cx).next().is_some()
|
||||
|| !self.explorers_of(session, cx).is_empty()
|
||||
/// Whether a host has any tab open in the center.
|
||||
fn has_tabs(&self, host: HostId, cx: &App) -> bool {
|
||||
self.terminals_of(host, cx).next().is_some() || !self.explorers_of(host, cx).is_empty()
|
||||
}
|
||||
|
||||
/// How many of a session's SFTP tabs are in the middle of a transfer.
|
||||
fn transfers_of(&self, session: SessionId, cx: &App) -> usize {
|
||||
self.explorers_of(session, cx)
|
||||
/// How many of a host's SFTP tabs are in the middle of a transfer.
|
||||
fn transfers_of(&self, host: HostId, cx: &App) -> usize {
|
||||
self.explorers_of(host, cx)
|
||||
.iter()
|
||||
.filter(|panel| panel.read(cx).is_transferring())
|
||||
.count()
|
||||
@@ -590,13 +587,12 @@ impl Workspace {
|
||||
pub(super) fn enqueue_prompt(
|
||||
&mut self,
|
||||
terminal_id: PromptOwner,
|
||||
session_id: SessionId,
|
||||
host_id: HostId,
|
||||
prompt: ConnectionPrompt,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.prompt_queue
|
||||
.push_back((terminal_id, session_id, prompt));
|
||||
self.prompt_queue.push_back((terminal_id, host_id, prompt));
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor()
|
||||
@@ -623,7 +619,7 @@ impl Workspace {
|
||||
}
|
||||
|
||||
fn open_next_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let (terminal_id, session_id, prompt) = loop {
|
||||
let (terminal_id, host_id, prompt) = loop {
|
||||
let Some(next) = self.prompt_queue.pop_front() else {
|
||||
return;
|
||||
};
|
||||
@@ -632,7 +628,7 @@ impl Workspace {
|
||||
}
|
||||
};
|
||||
let request_id = prompt.request_id();
|
||||
self.active_prompt = Some((terminal_id, session_id, request_id));
|
||||
self.active_prompt = Some((terminal_id, host_id, request_id));
|
||||
let origin = match terminal_id {
|
||||
PromptOwner::Forward(id, _) => self.forward_prompt_origin(id, cx),
|
||||
PromptOwner::Terminal(_) | PromptOwner::Sftp(..) => None,
|
||||
@@ -777,7 +773,7 @@ impl Workspace {
|
||||
reply: ConnectionPromptReply,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some((active_terminal, session_id, active_request)) = self.active_prompt else {
|
||||
let Some((active_terminal, host_id, active_request)) = self.active_prompt else {
|
||||
return;
|
||||
};
|
||||
if (active_terminal, active_request) != (terminal_id, request_id) {
|
||||
@@ -802,7 +798,7 @@ impl Workspace {
|
||||
PromptOwner::Sftp(..) => {}
|
||||
}
|
||||
}
|
||||
self.refresh_session_connection_state(session_id, cx);
|
||||
self.refresh_host_connection_state(host_id, cx);
|
||||
}
|
||||
|
||||
fn prompt_owner_is_live(&self, owner: PromptOwner, cx: &App) -> bool {
|
||||
@@ -871,30 +867,30 @@ impl Workspace {
|
||||
) {
|
||||
self.cancel_prompts_for_owner(PromptOwner::Terminal(terminal_id), window, cx);
|
||||
}
|
||||
fn cancel_prompts_for_session(
|
||||
fn cancel_prompts_for_host(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
host_id: HostId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// A port forward through the session is on its own: what the
|
||||
// session's tabs are told does not reach it. Its questions end when
|
||||
// A port forward through the host is on its own: what the
|
||||
// host's tabs are told does not reach it. Its questions end when
|
||||
// the forward does.
|
||||
let of_a_tab = |owner: &PromptOwner| !matches!(owner, PromptOwner::Forward(..));
|
||||
self.prompt_queue
|
||||
.retain(|(owner, id, _)| *id != session_id || !of_a_tab(owner));
|
||||
.retain(|(owner, id, _)| *id != host_id || !of_a_tab(owner));
|
||||
if let Some((owner, id, _)) = self.active_prompt
|
||||
&& id == session_id
|
||||
&& id == host_id
|
||||
&& of_a_tab(&owner)
|
||||
{
|
||||
self.cancel_prompts_for_owner(owner, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_session_connection_state(&mut self, session_id: SessionId, cx: &mut Context<Self>) {
|
||||
fn refresh_host_connection_state(&mut self, host_id: HostId, cx: &mut Context<Self>) {
|
||||
let mut has_starting = false;
|
||||
let mut has_running = false;
|
||||
for panel in self.terminals_of(session_id, cx) {
|
||||
for panel in self.terminals_of(host_id, cx) {
|
||||
match panel.read(cx).lifecycle(cx) {
|
||||
TerminalLifecycle::Running => has_running = true,
|
||||
TerminalLifecycle::Starting => has_starting = true,
|
||||
@@ -903,7 +899,7 @@ impl Workspace {
|
||||
| TerminalLifecycle::Closing => {}
|
||||
}
|
||||
}
|
||||
for panel in self.explorers_of(session_id, cx) {
|
||||
for panel in self.explorers_of(host_id, cx) {
|
||||
match panel.read(cx).connection_state() {
|
||||
ConnectionState::Connected => has_running = true,
|
||||
ConnectionState::Connecting => has_starting = true,
|
||||
@@ -918,16 +914,16 @@ impl Workspace {
|
||||
ConnectionState::Disconnected
|
||||
};
|
||||
self.store
|
||||
.update(cx, |store, cx| store.set_state(session_id, state, cx));
|
||||
.update(cx, |store, cx| store.set_state(host_id, state, cx));
|
||||
}
|
||||
|
||||
fn on_connect_session(
|
||||
fn on_connect_host(
|
||||
&mut self,
|
||||
action: &ConnectSession,
|
||||
action: &ConnectHost,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.connect_session(action.0, window, cx);
|
||||
self.connect_host(action.0, window, cx);
|
||||
}
|
||||
|
||||
fn on_connect_group(
|
||||
@@ -936,19 +932,14 @@ impl Workspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let session_ids = self.store.read(cx).sessions_under(action.0);
|
||||
for session_id in session_ids {
|
||||
self.connect_session(session_id, window, cx);
|
||||
let host_ids = self.store.read(cx).hosts_under(action.0);
|
||||
for host_id in host_ids {
|
||||
self.connect_host(host_id, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_session(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.store.read(cx).session(session_id).is_none() {
|
||||
fn connect_host(&mut self, host_id: HostId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.store.read(cx).host(host_id).is_none() {
|
||||
return;
|
||||
}
|
||||
let terminal_id = RemoteTerminalId(self.next_remote_terminal_id);
|
||||
@@ -957,13 +948,13 @@ impl Workspace {
|
||||
&self.store,
|
||||
self.remote_terminal_provider.clone(),
|
||||
terminal_id,
|
||||
session_id,
|
||||
host_id,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
self._subscriptions.push(subscription);
|
||||
self.terminals.insert(terminal_id, panel.clone());
|
||||
self.refresh_session_connection_state(session_id, cx);
|
||||
self.refresh_host_connection_state(host_id, cx);
|
||||
self.dock_area.update(cx, |area, cx| {
|
||||
area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
});
|
||||
@@ -975,15 +966,15 @@ impl Workspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let session_id = action.0;
|
||||
if self.store.read(cx).session(session_id).is_none() {
|
||||
let host_id = action.0;
|
||||
if self.store.read(cx).host(host_id).is_none() {
|
||||
return;
|
||||
}
|
||||
// Every request opens a tab of its own, as connecting does for
|
||||
// terminals: two directories of one host side by side.
|
||||
let id = ExplorerId(self.next_explorer_id);
|
||||
self.next_explorer_id += 1;
|
||||
let (panel, subscription) = new_explorer_panel(self, id, session_id, window, cx);
|
||||
let (panel, subscription) = new_explorer_panel(self, id, host_id, window, cx);
|
||||
self._subscriptions.push(subscription);
|
||||
self.explorers.insert(id, panel.clone());
|
||||
self.dock_area.update(cx, |area, cx| {
|
||||
@@ -1193,7 +1184,7 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
/// Close a session's terminal tab. Goes through the dock area rather
|
||||
/// Close a host's terminal tab. Goes through the dock area rather
|
||||
/// than the tab group: the group refuses to close the last tab of the
|
||||
/// center, and here every tab is closable.
|
||||
fn on_close_terminal(
|
||||
@@ -1421,17 +1412,17 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_copy_session_host(
|
||||
fn on_copy_host_address(
|
||||
&mut self,
|
||||
action: &CopySessionHost,
|
||||
action: &CopyHostAddress,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(host) = self
|
||||
.store
|
||||
.read(cx)
|
||||
.session(action.0)
|
||||
.map(|session| session.host.clone())
|
||||
.host(action.0)
|
||||
.map(|host| host.address.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1439,17 +1430,17 @@ impl Workspace {
|
||||
window.push_notification(Notification::success(format!("已复制 {host}")), cx);
|
||||
}
|
||||
|
||||
fn on_copy_session_id(
|
||||
fn on_copy_host_id(
|
||||
&mut self,
|
||||
action: &CopySessionId,
|
||||
action: &CopyHostId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(public_id) = self
|
||||
.store
|
||||
.read(cx)
|
||||
.session(action.0)
|
||||
.map(|session| session.public_id.clone())
|
||||
.host(action.0)
|
||||
.map(|host| host.public_id.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1554,20 +1545,20 @@ impl Workspace {
|
||||
let id = action.0;
|
||||
self.cancel_prompts_for_terminal(id, window, cx);
|
||||
if let Some(terminal) = self.terminals.get(&id).cloned() {
|
||||
let session_id = terminal.read(cx).session_id();
|
||||
let host_id = terminal.read(cx).host_id();
|
||||
terminal.update(cx, |terminal, cx| terminal.disconnect(cx));
|
||||
self.refresh_session_connection_state(session_id, cx);
|
||||
self.refresh_host_connection_state(host_id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_disconnect_session(
|
||||
fn on_disconnect_host(
|
||||
&mut self,
|
||||
action: &DisconnectSession,
|
||||
action: &DisconnectHost,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = action.0;
|
||||
self.cancel_prompts_for_session(id, window, cx);
|
||||
self.cancel_prompts_for_host(id, window, cx);
|
||||
self.store.update(cx, |store, cx| {
|
||||
store.set_state(id, ConnectionState::Disconnected, cx);
|
||||
});
|
||||
@@ -1589,14 +1580,14 @@ impl Workspace {
|
||||
let id = action.0;
|
||||
self.cancel_prompts_for_terminal(id, window, cx);
|
||||
if let Some(terminal) = self.terminals.get(&id).cloned() {
|
||||
let session_id = terminal.read(cx).session_id();
|
||||
let host_id = terminal.read(cx).host_id();
|
||||
terminal.update(cx, |terminal, cx| terminal.reconnect(window, cx));
|
||||
self.refresh_session_connection_state(session_id, cx);
|
||||
self.refresh_host_connection_state(host_id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_new_session(&mut self, _: &NewSession, window: &mut Window, cx: &mut Context<Self>) {
|
||||
open_session_dialog(
|
||||
fn on_new_host(&mut self, _: &NewHost, window: &mut Window, cx: &mut Context<Self>) {
|
||||
open_host_dialog(
|
||||
None,
|
||||
None,
|
||||
self.store.clone(),
|
||||
@@ -1606,14 +1597,9 @@ impl Workspace {
|
||||
);
|
||||
}
|
||||
|
||||
fn on_edit_session(
|
||||
&mut self,
|
||||
action: &EditSession,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.store.read(cx).session(action.0).is_some() {
|
||||
open_session_dialog(
|
||||
fn on_edit_host(&mut self, action: &EditHost, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.store.read(cx).host(action.0).is_some() {
|
||||
open_host_dialog(
|
||||
Some(action.0),
|
||||
None,
|
||||
self.store.clone(),
|
||||
@@ -1624,9 +1610,9 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_duplicate_session(
|
||||
fn on_duplicate_host(
|
||||
&mut self,
|
||||
action: &DuplicateSession,
|
||||
action: &DuplicateHost,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
@@ -1634,35 +1620,24 @@ impl Workspace {
|
||||
.store
|
||||
.update(cx, |store, cx| store.duplicate(action.0, cx));
|
||||
if let Some(copy) = copy {
|
||||
self.session_panel.update(cx, |panel, cx| {
|
||||
panel.select_node(SessionNode::Session(copy), cx)
|
||||
});
|
||||
self.host_panel
|
||||
.update(cx, |panel, cx| panel.select_node(HostNode::Host(copy), cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_move_session_node(
|
||||
&mut self,
|
||||
action: &MoveSessionNode,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
fn on_move_host_node(&mut self, action: &MoveHostNode, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let moved = self.store.update(cx, |store, cx| {
|
||||
store.move_node(action.source, action.destination, cx)
|
||||
});
|
||||
if moved {
|
||||
self.session_panel
|
||||
self.host_panel
|
||||
.update(cx, |panel, cx| panel.reveal_node(action.source, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_delete_session(
|
||||
&mut self,
|
||||
action: &DeleteSession,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
fn on_delete_host(&mut self, action: &DeleteHost, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let id = action.0;
|
||||
let Some(session) = self.store.read(cx).session(id).cloned() else {
|
||||
let Some(host) = self.store.read(cx).host(id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let workspace = cx.entity().downgrade();
|
||||
@@ -1671,13 +1646,13 @@ impl Workspace {
|
||||
forwards: store.forwards_of(id).count(),
|
||||
jump_users: store.jump_users(&[id]),
|
||||
};
|
||||
confirm_delete_session(
|
||||
&session,
|
||||
confirm_delete_host(
|
||||
&host,
|
||||
(self.has_tabs(id, cx), self.transfers_of(id, cx)),
|
||||
dependents,
|
||||
Rc::new(move |window, cx| {
|
||||
workspace
|
||||
.update(cx, |this, cx| this.remove_session(id, window, cx))
|
||||
.update(cx, |this, cx| this.remove_host(id, window, cx))
|
||||
.ok();
|
||||
}),
|
||||
window,
|
||||
@@ -1685,18 +1660,18 @@ impl Workspace {
|
||||
);
|
||||
}
|
||||
|
||||
fn remove_session(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.close_session_tabs(id, window, cx);
|
||||
fn remove_host(&mut self, id: HostId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.close_host_tabs(id, window, cx);
|
||||
self.store.update(cx, |store, cx| {
|
||||
store.remove(id, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Close whatever a session has open in the center, leaving the store
|
||||
/// alone. Deleting a session and deleting the group around it both need
|
||||
/// this, the latter for every session in the subtree.
|
||||
fn close_session_tabs(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.cancel_prompts_for_session(id, window, cx);
|
||||
/// Close whatever a host has open in the center, leaving the store
|
||||
/// alone. Deleting a host and deleting the group around it both need
|
||||
/// this, the latter for every host in the subtree.
|
||||
fn close_host_tabs(&mut self, id: HostId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.cancel_prompts_for_host(id, window, cx);
|
||||
let terminals: Vec<_> = self.terminals_of(id, cx).cloned().collect();
|
||||
for terminal in terminals {
|
||||
self.dock_area
|
||||
@@ -1709,13 +1684,13 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_new_session_in_group(
|
||||
fn on_new_host_in_group(
|
||||
&mut self,
|
||||
action: &NewSessionInGroup,
|
||||
action: &NewHostInGroup,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_session_dialog(
|
||||
open_host_dialog(
|
||||
None,
|
||||
Some(action.0),
|
||||
self.store.clone(),
|
||||
@@ -1750,7 +1725,7 @@ impl Workspace {
|
||||
fn set_all_groups_expanded(&mut self, expanded: bool, cx: &mut Context<Self>) {
|
||||
self.store
|
||||
.update(cx, |store, cx| store.set_all_groups_expanded(expanded, cx));
|
||||
self.session_panel
|
||||
self.host_panel
|
||||
.update(cx, |panel, cx| panel.set_all_groups_expanded(expanded, cx));
|
||||
}
|
||||
|
||||
@@ -1789,7 +1764,7 @@ impl Workspace {
|
||||
};
|
||||
let name = group.name.to_string();
|
||||
let subgroups = store.descendant_groups(id).len();
|
||||
let doomed = store.sessions_under(id);
|
||||
let doomed = store.hosts_under(id);
|
||||
let closes_tabs = doomed.iter().any(|id| self.has_tabs(*id, cx));
|
||||
let transfers = doomed.iter().map(|id| self.transfers_of(*id, cx)).sum();
|
||||
let dependents = Dependents {
|
||||
@@ -1814,19 +1789,19 @@ impl Workspace {
|
||||
}
|
||||
|
||||
/// The store cascades the delete; the workspace only has to close the
|
||||
/// tabs of the sessions that went with the group.
|
||||
/// tabs of the hosts that went with the group.
|
||||
fn remove_group(&mut self, id: GroupId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let removed = self
|
||||
.store
|
||||
.update(cx, |store, cx| store.remove_group(id, cx));
|
||||
for session in removed {
|
||||
self.close_session_tabs(session, window, cx);
|
||||
for host in removed {
|
||||
self.close_host_tabs(host, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_toggle_session_panel(
|
||||
fn on_toggle_host_panel(
|
||||
&mut self,
|
||||
_: &ToggleSessionPanel,
|
||||
_: &ToggleHostPanel,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
@@ -1895,17 +1870,17 @@ impl Workspace {
|
||||
}
|
||||
|
||||
fn new_terminal_panel(
|
||||
store: &Entity<SessionStore>,
|
||||
store: &Entity<HostStore>,
|
||||
remote_provider: SharedRemoteTerminalTransportProvider,
|
||||
terminal_id: RemoteTerminalId,
|
||||
session_id: SessionId,
|
||||
host_id: HostId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> (Entity<TerminalPanel>, Subscription) {
|
||||
let panel = cx.new(|cx| {
|
||||
TerminalPanel::new(
|
||||
terminal_id,
|
||||
session_id,
|
||||
host_id,
|
||||
store.clone(),
|
||||
remote_provider,
|
||||
window,
|
||||
@@ -1919,36 +1894,36 @@ fn new_terminal_panel(
|
||||
TerminalPanelEvent::Activated(terminal_id, _) => {
|
||||
this.active_tab = Some(CenterTab::Terminal(*terminal_id))
|
||||
}
|
||||
TerminalPanelEvent::Closed(terminal_id, session_id) => {
|
||||
TerminalPanelEvent::Closed(terminal_id, host_id) => {
|
||||
this.cancel_prompts_for_terminal(*terminal_id, window, cx);
|
||||
this.terminals.remove(terminal_id);
|
||||
if this.active_tab == Some(CenterTab::Terminal(*terminal_id)) {
|
||||
this.active_tab = None;
|
||||
}
|
||||
this.refresh_session_connection_state(*session_id, cx);
|
||||
if !this.has_tabs(*session_id, cx)
|
||||
&& this.store.read(cx).active().map(|session| session.id) == Some(*session_id)
|
||||
this.refresh_host_connection_state(*host_id, cx);
|
||||
if !this.has_tabs(*host_id, cx)
|
||||
&& this.store.read(cx).active().map(|host| host.id) == Some(*host_id)
|
||||
{
|
||||
this.store
|
||||
.update(cx, |store, cx| store.set_active(None, cx));
|
||||
}
|
||||
}
|
||||
TerminalPanelEvent::StatusChanged(_, session_id) => {
|
||||
this.refresh_session_connection_state(*session_id, cx);
|
||||
TerminalPanelEvent::StatusChanged(_, host_id) => {
|
||||
this.refresh_host_connection_state(*host_id, cx);
|
||||
cx.notify();
|
||||
}
|
||||
TerminalPanelEvent::PromptRequested(terminal_id, session_id, prompt) => this
|
||||
TerminalPanelEvent::PromptRequested(terminal_id, host_id, prompt) => this
|
||||
.enqueue_prompt(
|
||||
PromptOwner::Terminal(*terminal_id),
|
||||
*session_id,
|
||||
*host_id,
|
||||
prompt.clone(),
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
TerminalPanelEvent::HostOsDetected(session_id, os) => {
|
||||
let (session_id, os) = (*session_id, *os);
|
||||
TerminalPanelEvent::HostOsDetected(host_id, os) => {
|
||||
let (host_id, os) = (*host_id, *os);
|
||||
this.store
|
||||
.update(cx, |store, cx| store.set_host_os(session_id, Some(os), cx));
|
||||
.update(cx, |store, cx| store.set_host_os(host_id, Some(os), cx));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -2012,14 +1987,14 @@ impl Render for AuthenticationPromptForm {
|
||||
fn new_explorer_panel(
|
||||
workspace: &Workspace,
|
||||
id: ExplorerId,
|
||||
session_id: SessionId,
|
||||
host_id: HostId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> (Entity<ExplorerPanel>, Subscription) {
|
||||
let panel = cx.new(|cx| {
|
||||
ExplorerPanel::new(
|
||||
id,
|
||||
session_id,
|
||||
host_id,
|
||||
workspace.store.clone(),
|
||||
workspace.sftp_provider.clone(),
|
||||
workspace.local_directory_provider.clone(),
|
||||
@@ -2033,14 +2008,14 @@ fn new_explorer_panel(
|
||||
window,
|
||||
|this, _, event: &ExplorerPanelEvent, window, cx| match event {
|
||||
ExplorerPanelEvent::Activated(id) => this.active_tab = Some(CenterTab::Explorer(*id)),
|
||||
ExplorerPanelEvent::Closed(id, session_id) => {
|
||||
ExplorerPanelEvent::Closed(id, host_id) => {
|
||||
this.explorers.remove(id);
|
||||
if this.active_tab == Some(CenterTab::Explorer(*id)) {
|
||||
this.active_tab = None;
|
||||
}
|
||||
this.refresh_session_connection_state(*session_id, cx);
|
||||
this.refresh_host_connection_state(*host_id, cx);
|
||||
}
|
||||
ExplorerPanelEvent::StateChanged(id, session_id) => {
|
||||
ExplorerPanelEvent::StateChanged(id, host_id) => {
|
||||
if let Some(panel) = this.explorers.get(id) {
|
||||
let generation = panel.read(cx).generation();
|
||||
this.prompt_queue.retain(|(owner,_,_)| !matches!(owner,PromptOwner::Sftp(s,g) if s == id && *g != generation));
|
||||
@@ -2048,16 +2023,16 @@ fn new_explorer_panel(
|
||||
this.cancel_prompts_for_owner(owner,window,cx);
|
||||
}
|
||||
}
|
||||
this.refresh_session_connection_state(*session_id, cx);
|
||||
this.refresh_host_connection_state(*host_id, cx);
|
||||
},
|
||||
ExplorerPanelEvent::StatusChanged(id) => {
|
||||
if this.active_tab == Some(CenterTab::Explorer(*id)) {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
ExplorerPanelEvent::PromptRequested(id, session_id, generation, prompt) => this.enqueue_prompt(
|
||||
ExplorerPanelEvent::PromptRequested(id, host_id, generation, prompt) => this.enqueue_prompt(
|
||||
PromptOwner::Sftp(*id, *generation),
|
||||
*session_id,
|
||||
*host_id,
|
||||
prompt.clone(),
|
||||
window,
|
||||
cx,
|
||||
@@ -2106,24 +2081,24 @@ impl Render for Workspace {
|
||||
.terminals
|
||||
.get(&id)
|
||||
.map(|panel| panel.read(cx).status(cx));
|
||||
WorkspaceStatus::Session(active, terminal)
|
||||
WorkspaceStatus::Host(active, terminal)
|
||||
}
|
||||
Some(CenterTab::LocalTerminal(id)) => self
|
||||
.local_terminals
|
||||
.get(&id)
|
||||
.map(|panel| WorkspaceStatus::Local(panel.read(cx).status(cx)))
|
||||
.unwrap_or_else(|| WorkspaceStatus::Session(active, None)),
|
||||
// An SFTP tab tells its own connection, not the session's, and
|
||||
.unwrap_or_else(|| WorkspaceStatus::Host(active, None)),
|
||||
// An SFTP tab tells its own connection, not the host's, and
|
||||
// what went wrong in it.
|
||||
Some(CenterTab::Explorer(id)) => match self.explorers.get(&id) {
|
||||
Some(panel) => {
|
||||
let panel = panel.read(cx);
|
||||
let session = self.store.read(cx).session(panel.session_id()).cloned();
|
||||
WorkspaceStatus::Explorer(session, panel.status(cx))
|
||||
let host = self.store.read(cx).host(panel.host_id()).cloned();
|
||||
WorkspaceStatus::Explorer(host, panel.status(cx))
|
||||
}
|
||||
None => WorkspaceStatus::Session(active, None),
|
||||
None => WorkspaceStatus::Host(active, None),
|
||||
},
|
||||
_ => WorkspaceStatus::Session(active, None),
|
||||
_ => WorkspaceStatus::Host(active, None),
|
||||
};
|
||||
let sidebar = self.sidebar_showing(cx);
|
||||
let running_forwards = self.forwards.read(cx).active_count();
|
||||
@@ -2132,22 +2107,22 @@ impl Render for Workspace {
|
||||
.id("workspace")
|
||||
.key_context("Workspace")
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_new_session))
|
||||
.on_action(cx.listener(Self::on_new_host))
|
||||
.on_action(cx.listener(Self::on_new_local_terminal))
|
||||
.on_action(cx.listener(Self::on_edit_session))
|
||||
.on_action(cx.listener(Self::on_duplicate_session))
|
||||
.on_action(cx.listener(Self::on_move_session_node))
|
||||
.on_action(cx.listener(Self::on_delete_session))
|
||||
.on_action(cx.listener(Self::on_new_session_in_group))
|
||||
.on_action(cx.listener(Self::on_edit_host))
|
||||
.on_action(cx.listener(Self::on_duplicate_host))
|
||||
.on_action(cx.listener(Self::on_move_host_node))
|
||||
.on_action(cx.listener(Self::on_delete_host))
|
||||
.on_action(cx.listener(Self::on_new_host_in_group))
|
||||
.on_action(cx.listener(Self::on_new_group))
|
||||
.on_action(cx.listener(Self::on_expand_all_groups))
|
||||
.on_action(cx.listener(Self::on_collapse_all_groups))
|
||||
.on_action(cx.listener(Self::on_new_child_group))
|
||||
.on_action(cx.listener(Self::on_rename_group))
|
||||
.on_action(cx.listener(Self::on_delete_group))
|
||||
.on_action(cx.listener(Self::on_connect_session))
|
||||
.on_action(cx.listener(Self::on_connect_host))
|
||||
.on_action(cx.listener(Self::on_connect_group))
|
||||
.on_action(cx.listener(Self::on_disconnect_session))
|
||||
.on_action(cx.listener(Self::on_disconnect_host))
|
||||
.on_action(cx.listener(Self::on_reconnect_terminal))
|
||||
.on_action(cx.listener(Self::on_disconnect_terminal))
|
||||
.on_action(cx.listener(Self::on_open_explorer))
|
||||
@@ -2174,8 +2149,8 @@ impl Render for Workspace {
|
||||
.on_action(cx.listener(Self::on_close_tabs))
|
||||
.on_action(cx.listener(Self::on_rename_terminal))
|
||||
.on_action(cx.listener(Self::on_rename_explorer))
|
||||
.on_action(cx.listener(Self::on_copy_session_host))
|
||||
.on_action(cx.listener(Self::on_copy_session_id))
|
||||
.on_action(cx.listener(Self::on_copy_host_address))
|
||||
.on_action(cx.listener(Self::on_copy_host_id))
|
||||
.on_action(cx.listener(Self::on_restart_local_terminal))
|
||||
.on_action(cx.listener(Self::on_copy_terminal))
|
||||
.on_action(cx.listener(Self::on_paste_terminal))
|
||||
@@ -2184,8 +2159,8 @@ impl Render for Workspace {
|
||||
.on_action(cx.listener(Self::on_find_previous_in_terminal))
|
||||
.on_action(cx.listener(Self::on_dismiss_terminal_find))
|
||||
.on_action(cx.listener(Self::on_clear_terminal))
|
||||
.on_action(cx.listener(Self::on_toggle_session_panel))
|
||||
.on_action(cx.listener(Self::on_show_sessions))
|
||||
.on_action(cx.listener(Self::on_toggle_host_panel))
|
||||
.on_action(cx.listener(Self::on_show_hosts))
|
||||
.on_action(cx.listener(Self::on_show_forwards))
|
||||
.on_action(cx.listener(Self::on_new_forward))
|
||||
.on_action(cx.listener(Self::on_edit_forward))
|
||||
|
||||
+8
-12
@@ -12,9 +12,9 @@ use std::{
|
||||
|
||||
use shellrs::app::cli_endpoint;
|
||||
use shellrs::cli::{
|
||||
CliBackend, CliError, CliServer, CliTarget, SessionInfo, TransferCounters, TransferSummary,
|
||||
CliBackend, CliError, CliServer, CliTarget, HostInfo, TransferCounters, TransferSummary,
|
||||
};
|
||||
use shellrs::session::{AuthKind, Session, SessionDraft, SessionId, SessionLogin};
|
||||
use shellrs::host::{AuthKind, Host, HostDraft, HostId, HostLogin};
|
||||
use shellrs::ssh::ExecStream;
|
||||
|
||||
/// More than a pipe's buffer, so the app finishes writing before the
|
||||
@@ -102,23 +102,19 @@ fn the_command_finds_the_app_through_its_data_directory() {
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let backend = Arc::new(FakeBackend::default());
|
||||
let server = CliServer::start(cli_endpoint(data_dir.path()), backend.clone()).unwrap();
|
||||
let web = Session::new(
|
||||
SessionId(1),
|
||||
SessionDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Password, None),
|
||||
let web = Host::new(
|
||||
HostId(1),
|
||||
HostDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Password, None),
|
||||
);
|
||||
server.set_targets(vec![CliTarget::new(
|
||||
&web,
|
||||
SessionLogin::of(&web, None),
|
||||
None,
|
||||
)]);
|
||||
server.set_targets(vec![CliTarget::new(&web, HostLogin::of(&web, None), None)]);
|
||||
server.set_enabled(true);
|
||||
let id = web.public_id.to_string();
|
||||
|
||||
// A pipe for stdout means JSON.
|
||||
let output = shellrs(data_dir.path(), &["list"], b"");
|
||||
assert_eq!(output.status.code(), Some(0));
|
||||
let sessions: Vec<SessionInfo> = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(sessions[0].id, id);
|
||||
let hosts: Vec<HostInfo> = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(hosts[0].id, id);
|
||||
|
||||
// What PowerShell pipes in: a BOM and CRLF line ends.
|
||||
let output = shellrs(
|
||||
|
||||
+533
-592
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user