Compare commits
11
Commits
v0.1.0-beta.8
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09dc45e7a2 | ||
|
|
2de318a4e6 | ||
|
|
9474c9e789 | ||
|
|
835894625f | ||
|
|
28d4799154 | ||
|
|
f1a7c68907 | ||
|
|
76f832caf6 | ||
|
|
6dcd084840 | ||
|
|
ac6f8eaeba | ||
|
|
2feae2fe3b | ||
|
|
d5370f8bac |
No files matched your search
@@ -2,6 +2,23 @@
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
|
||||
|
||||
## [0.1.2]
|
||||
|
||||
### 新增
|
||||
|
||||
- 临时连接:标题栏右上角的「临时连接…」连一台主机而不保存,密码只留在内存里(新建主机请用主机列表的「+」或 ⌘N)。
|
||||
- 开始页的「快速连接」:搜索保存的主机,回车或单击连接。
|
||||
|
||||
### 改进
|
||||
|
||||
- 「测试连接」不再要求填写密码,没填时按「无密码」的方式尝试。
|
||||
|
||||
## [0.1.1]
|
||||
|
||||
### 改进
|
||||
|
||||
- 精简了给 AI Agent 的 Skill 说明。
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
ShellRS 的第一个公开版本。
|
||||
@@ -12,11 +29,20 @@ ShellRS 的第一个公开版本。
|
||||
- 凭据:密码、密钥(本机文件、粘贴或生成新密钥)和 SSH Agent,多台主机可以共用一条凭据。
|
||||
- 连接方式:直接连接、SSH 跳板或 HTTP / SOCKS5 代理。
|
||||
- 终端:本地终端和多个 SSH 终端,支持查找、清屏和延迟显示。
|
||||
- 右侧栏:跟着当前远程终端的系统监控、网络连接、进程管理、系统服务、Docker、历史命令和命令片段。
|
||||
- SFTP:对标 WinSCP 的双栏文件浏览器,上传和下载都支持断点续传和传输队列。
|
||||
- 端口转发:本地、远程和动态转发,断线后自动重连,可随 ShellRS 一起启动。
|
||||
- 从堡垒机打开:像调用 Xshell、WinSCP 一样用 `ssh://`、`sftp://` 链接打开终端或 SFTP,不保存连接。
|
||||
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
|
||||
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
|
||||
|
||||
## [0.1.0-beta.9]
|
||||
|
||||
### 新增
|
||||
|
||||
- 堡垒机可以像调用 WinSCP 一样,用 `sftp://` 链接打开 ShellRS 的 SFTP。
|
||||
- 从堡垒机打开时,左侧主机栏自动收起,终端或 SFTP 占满窗口。
|
||||
|
||||
## [0.1.0-beta.8]
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -8,7 +8,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP
|
||||
|
||||
**保存的一项叫「主机」,代码里叫 `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 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接拉起 ShellRS,开一个不保存的「临时连接」(见下面「临时连接不保存」)。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
|
||||
主机和分组是**真实持久化**的,存在一个 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 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;标题栏右上角「临时连接…」打开的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」(见下面「临时连接不保存,功能齐全」)。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
|
||||
|
||||
已确认的产品决定: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
|
||||
|
||||
@@ -42,11 +42,11 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
|
||||
|
||||
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`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())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`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 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`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`)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://用户[:密码]@地址[:端口]`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。
|
||||
- `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 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`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`(`HostForm`;`HostForm::temporary` / `open_temporary_connection_dialog` 是临时连接的对话框)、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`quick_connect.rs`(开始页的「快速连接」,用户给了参考图:gpui-kit 的 `List`(搜索框、↑↓、回车和单击确认)列出保存的主机,`quick_hosts` 排序(最近连接的在前,其余按名称),`QuickHost::matches` 几个词都要在名称、地址、用户名、备注或分组路径里,打开时不高亮任何一台(用户嫌预先选中一台别扭),搜索后由 `List` 自己高亮第一个匹配的、回车就连它。`QuickChoice` 是不依赖窗口的选择状态(高亮行、Tab / ⌘A 选中的、⇧↵ 连哪些),都有单元测试。**多选先关掉了**(用户定的,常量 `MULTIPLE` 为假):开着时搜索框自己要的 Tab(`IndentInline`)、⌘A(`SelectAll`)和 ⇧↵(`Enter { shift }`)由外层容器在捕获阶段 `capture_action` 截下再 `stop_propagation`,⌘ 单击也是选中,页脚列出这三个键;关着时这些都不挂,Tab、⌘A 照搜索框的本意。普通回车照常经输入框传给 `List` 的确认。连接是关掉对话框后 `window.defer` 在工作区的 focus handle 上逐台派发 `ConnectHost`,同临时连接)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。
|
||||
- `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`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、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` 一样,解析不了的名字也算网络错误。**算法按 OpenSSH 的默认来**(`ssh_config`:OpenSSH 默认能连上的,ShellRS 也要能连上):russh 自己的密钥交换列表不含 NIST 曲线,素数组交换(GEX)又要至少 3072 位;用户的堡垒机是老 Java 上的 Apache SSHD 2.10,没有 curve25519、GEX 最大只有 2048 位,于是 russh 选了 GEX、服务器找不到合适的素数直接断开(「Disconnected」/「early eof」),OpenSSH 却选 `ecdh-sha2-nistp256` 连上了。所以 `KEX_ORDER` 照 OpenSSH 的顺序加上 `ecdh-sha2-nistp*`,`MIN_DH_GROUP_BITS` 是 OpenSSH 的 2048;没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的老算法。`route_tests` 的 `a_server_with_only_2048_bit_groups_or_nist_curves_is_reached` 用只给 2048 位组、只有 NIST 曲线的进程内服务端守着。连接失败的说明一律经 `describe_login_error`(终端、SFTP、测试连接、外部 CLI、转发):它沿错误链找到真正的原因(网络、缺凭据、`NoCommonAlgo` 时写明服务器支持哪些算法),找不到时给出整条错误链(`{error:#}`),不再只剩「无法建立 SSH 连接,请检查地址、端口和主机密钥」这一层。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 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算Line truncated
|
||||
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `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` 给没有钥匙串的机器(界面据此禁用密码字段)。`TransientSecretStore`(`transient.rs`)是 `HostStore` 包在注入的存储外面的一层:`SecretRef::Transient { host }`(临时连接链接里的密码)只记在它的内存里,其余原样转给钥匙串,`remember` / `forget` 只碰内存、可以在 UI 线程上调。服务名是 `SERVICE` = `shellrs`。
|
||||
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `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` 按类型映射成中文。选「密码」又没填密码时不拦着(用户定的:不强制填密码,连不上就连不上):`LoginTest::password_left_empty` 为真,测试按「无密码」登录(免认证、SSH Agent、默认私钥),服务器要密码时(`PasswordWanted`,「无密码」那句报错的类型)改报「未填写密码」,不说「请改用…」(`a_test_with_the_password_left_empty_goes_without_one`)。陌生主机密钥仍然要问:工作线程通过 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` 给没有钥匙串的机器(界面据此禁用密码字段)。`TemporarySecretStore`(`temporary.rs`)是 `HostStore` 包在注入的存储外面的一层:`SecretRef::Temporary { host }`(不保存的连接的密码)只记在它的内存里,其余原样转给钥匙串,`remember` / `forget` 只碰内存、可以在 UI 线程上调。服务名是 `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/` — 端口转发。规则的模型在 `host/forward.rs`,这里是运行和界面。
|
||||
@@ -64,10 +64,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、下划线 `TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。
|
||||
- `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。
|
||||
- `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。
|
||||
- `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` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的临时连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_link_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
||||
- `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` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉;标题右边是小号的「快速连接」(`QuickConnect`,primary)和「新建主机…」两个按钮)、`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_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`,或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。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 往管道里写的就是这样)。
|
||||
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`、WinSCP 的开关(`/sessionname=` 当标签名,`/newinstance`、`/ini=` 等列在 `WINSCP_SWITCHES` 里的忽略;只认列出的名字,所以 `/tmp/x` 这种路径不算),或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。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」)。
|
||||
- `client.rs`:命令这一端,连 `app::cli_socket_path()`(即 `app::cli_endpoint(data_dir)`:Unix 上是 `<data_dir>/cli.sock`,Windows 上是 `\\.\pipe\shellrs-cli-<小写的数据目录绝对路径的 sha256 前 16 位>`,所以 `SHELLRS_DATA_DIR` 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。`connect` 按平台实现,读帧循环 `talk` 共用。进度行用 `\r` 加补空格覆盖,不用 `\x1b[2K`(老 conhost 不认)。
|
||||
- `pipe_windows.rs`:Windows 命名管道的 Win32 调用(`windows-sys`):带 DACL 的实例、`ConnectNamedPipe`、客户端的打开与属主校验。
|
||||
@@ -134,12 +134,19 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把链接追加进 `Shared::activation`(`Mutex<Option<Vec<OpenLink>>>`);请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,是 `Some` 就 `bring_forward`,再按顺序 `open_link`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
|
||||
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
|
||||
- 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
|
||||
- **临时连接不保存。** 堡垒机按 Xshell 的写法拉起 ShellRS(`ShellRS ssh://user[:password]@host[:port]` 或 `-url … -newtab 名称`),开一个终端标签连过去(用户定的:和 Xshell 的 `-url` 一样是临时的;只做 `ssh://`、不做 `sftp://`;只认命令行参数,不注册系统的 `ssh://` 链接、不改安装包)。
|
||||
- `HostStore` 把它放在单独的 `transient` 里:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `transient_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(临时连接)」;它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。
|
||||
- **终端只开一个通道**(`HostLogin::shell_only`,`login_of` 对临时主机置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 `ssh/transport.rs` 对 `shell_only` 不做系统探测、`Exec` 直接答 `SHELL_ONLY` 不发给服务器(`a_terminal_a_bastion_host_opened_carries_nothing_beside_it` 守着),右侧栏只给不执行命令的工具(`ToolKind::runs_commands` 为假的只有命令片段,`offered_tools`)。延迟测量是全局请求、不开通道,照常。
|
||||
- 登录用「密码」方式,`login_of` 把密码引用换成 `SecretRef::Transient { host }`:链接里的密码只在 `TransientSecretStore` 的内存里(钥匙串、数据库都不进),也不读同一 `user@host:port` 的已保存密码;没带密码就照常 none → 弹框。
|
||||
- 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_link_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_transient`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。
|
||||
- 链接解析失败(不是 `ssh://`、缺用户名 / 地址、端口不对)弹「无法打开 SSH 链接」通知,文字不带链接原文(可能有密码)。第二个进程在 Windows 发布版里没有控制台,所以错误一律由正在运行的那个显示。
|
||||
- **临时连接不保存,功能齐全。** 标题栏右上角原来的「新建主机…」换成了「临时连接…」(用户要的;新建主机还在主机列表的「+」、右键菜单和 ⌘N),派发 `NewTemporaryConnection`。
|
||||
- 对话框是 `HostForm::temporary`(`open_temporary_connection_dialog`):新建主机的地址、端口、认证方式(密码 / 使用凭据 / 无密码)和密码,没有分组、连接方式和备注,「测试连接」照旧(用户要的),名称可以不填(用地址),下面一行说明「仅当前使用的临时会话,不会保存到主机列表。」(用户给的文案)。页脚 `form_footer` 三个主机对话框共用。
|
||||
- `commit_temporary` 调 `HostStore::insert_temporary`:分组和连接方式一律丢掉(直连),填的密码只 `remember` 在 `TemporarySecretStore` 的内存里(用户定的:不进钥匙串)。然后在对话框关掉之后(`window.defer`)经工作区的 focus handle 派发 `ConnectHost`,新标签才留得住焦点。
|
||||
- 它不记进 `external`:不是 shell_only,右侧栏工具全有,也不收起左侧栏。其余和外部连接一样:不进主机树、最近连接和数据库,最后一个标签关掉就 `remove_temporary`,标签菜单没有「编辑主机…」,外部 CLI 照样列出。
|
||||
- `login_of` 只把端点自己的密码(`SecretRef::Password`)换成 `Temporary`,用密码凭据时照用凭据的。改凭据(用户名跟着变、重连)和删凭据(改回自己登录)也照顾临时主机:`logins`、`update_credential_unnotified`、`remove_credential_unnotified` 都把 `temporary` 算进去;凭据的用量和删除确认只数保存的主机。`a_temporary_connection_goes_directly_and_may_use_a_credential` 和 UI 的 `a_temporary_connection_has_every_feature_and_saves_nothing` 守着。
|
||||
- **外部连接不保存,只开一个标签。** 堡垒机按 Xshell 的写法拉起 ShellRS(`ShellRS ssh://user[:password]@host[:port]` 或 `-url … -newtab 名称`),开一个终端标签连过去;按 WinSCP 的写法(`ShellRS sftp://…`,JumpServer 的模板就是单独一个链接,前面可以有 `/sessionname=名称`)则开一个 SFTP 标签(`open_link` 按 `SshLink::kind` 调 `connect_host` 或 `open_explorer`),其余规则两种一样(用户定的:和 Xshell 的 `-url` 一样是临时的;只认命令行参数,不注册系统的 `ssh://` / `sftp://` 链接、不改安装包;`sftp://` 链接里的路径暂不认,`scp://` 不做)。
|
||||
- `HostStore` 把它放在单独的 `temporary`(临时主机)里,并记进 `external`:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `temporary_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(未保存)」(用户定的:临时连接和外部连接在外部 CLI 里不区分,agent 用不着,ID 失效时 CLI 自己报 `host_not_found`);它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。
|
||||
- **终端只开一个通道**(`HostLogin::shell_only`,`login_of` 只对 `is_external` 的置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 `ssh/transport.rs` 对 `shell_only` 不做系统探测、`Exec` 直接答 `SHELL_ONLY` 不发给服务器(`a_terminal_a_bastion_host_opened_carries_nothing_beside_it` 守着),右侧栏只给不执行命令的工具(`ToolKind::runs_commands` 为假的只有命令片段,`offered_tools` 看 `is_external`)。延迟测量是全局请求、不开通道,照常。SFTP 标签本来就只开一个通道(`sftp/client.rs` 的 sftp 子系统,延迟同样是全局请求),所以 `sftp://` 的外部连接不用另外处理;以后给 SFTP 加别的通道(执行命令之类)要对 `is_external` 关掉。
|
||||
- 登录用「密码」方式,`login_of` 把临时主机的密码引用换成 `SecretRef::Temporary { host }`:链接里的密码只在 `TemporarySecretStore` 的内存里(钥匙串、数据库都不进),也不读同一 `user@host:port` 的已保存密码;没带密码就照常 none → 弹框。
|
||||
- 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_temporary_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_temporary`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。
|
||||
- **打开时标签占满窗口**(用户要的「终端默认全屏」「SFTP 全屏显示」,选的是收起左侧栏):`open_link` 成功后左侧 Dock 开着就 `toggle_dock` 收起,和 ⌘B 一样,用户按 ⌘B 或双击标签展开;解析失败什么也不打开时不收。不用 Dock 的放大,也不动窗口本身。
|
||||
- **命令片段照常全有**:片段面板列出库里的全部片段,不看是哪台主机(`store.snippets()`),界面只会建「所有主机」范围的片段。以后做「适用范围」(分组 / 主机)的筛选时,外部连接和临时连接不属于任何分组、也不是保存的主机,只显示「所有主机」的(用户定的)。
|
||||
- 链接解析失败(不是 `ssh://` / `sftp://`、缺用户名 / 地址、端口不对)弹「无法打开链接」通知,文字不带链接原文(可能有密码)。第二个进程在 Windows 发布版里没有控制台,所以错误一律由正在运行的那个显示。
|
||||
- `tests/cli_bin.rs` 的 `shellrs_opened_with_a_link_hands_it_to_the_running_app` 用真的 `shellrs` 二进制交接链接(含中文 `-newtab`,Windows CI 上也跑),子进程 20 秒不退就杀掉,免得交接坏了起出一个窗口。
|
||||
- **macOS 上关窗口是隐藏,不是退出。** ShellRS 只有一个窗口,在跑的东西(连接、传输、端口转发、外部 CLI)都归窗口里的工作区;GPUI 在 macOS 上默认不随最后一个窗口退出(`QuitMode::Default`),真关掉窗口会停掉这一切,却留下一个没有窗口、点 Dock 图标也唤不回的进程。所以 `main` 给窗口挂 `app::hide_when_closed`(`on_window_should_close` 里 `cx.hide()` 并返回 `false`),点 Dock 图标(`Application::on_reopen`)和再次打开 ShellRS(`come_forward_when_opened_again`)都走 `app::bring_forward`:先 `NSApplication::unhide`(只 `activate` 不一定让隐藏的窗口重新出现),再激活窗口。⌘Q 才退出。Windows / Linux 关窗口即退出(GPUI 的默认)。测试平台的 `hide()` 是 `unimplemented!()`,所以这条只挂在生产路径上,没有 UI 测试,改动后要在真实窗口里试。
|
||||
- **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动升级」和「更新渠道」存在 `AppSettings::update`(`automatic`、`channel`,后者直接用 `update::Channel`,在 settings 里实现 `Choice`),工作区在 `observe_in(&settings)` 里 `sync_updater`(`set_automatic`、`set_channel`),`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` 时在数据目录下)。
|
||||
@@ -167,7 +174,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **主机系统每次连上都重探一遍。** 认证过、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` 同时认两种)。唯一不进钥匙串的是临时连接链接里的密码(`SecretRef::Transient`):只在 `TransientSecretStore` 的内存里,随临时主机一起忘掉。
|
||||
- **秘密只进钥匙串,不进数据库。** `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` 同时认两种)。唯一不进钥匙串的是不保存的连接的密码(`SecretRef::Temporary`):只在 `TemporarySecretStore` 的内存里,随临时主机一起忘掉。
|
||||
- **id 由内存分配。** `next_host_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`HostStore::seed()` 生产环境不再使用,是 UI 测试的夹具。
|
||||
- **对外只给 `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`,工作区据此清理注册表。
|
||||
@@ -201,4 +208,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
|
||||
## 测试约定
|
||||
|
||||
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的临时连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<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` / `workbuddy`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltLine truncated
|
||||
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<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` / `workbuddy`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltLine truncated
|
||||
Generated
+1
-1
@@ -7686,7 +7686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.0-beta.8"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.0-beta.8"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
|
||||
+22
-3
@@ -24,12 +24,12 @@
|
||||
|
||||
## About
|
||||
|
||||
ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style dual-pane SFTP browser and SSH port forwarding together in one cross-platform app. It is written in Rust, and its UI is built on GPUI, the GPU-accelerated UI framework of the Zed editor (through [gpui-kit](https://gpui-kit.com)). There is no Electron and no JVM.
|
||||
ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style dual-pane SFTP browser and SSH port forwarding together in one cross-platform app, which bastion hosts such as JumpServer can launch the way they launch Xshell and WinSCP. It is written in Rust, and its UI is built on GPUI, the GPU-accelerated UI framework of the Zed editor (through [gpui-kit](https://gpui-kit.com)). There is no Electron and no JVM.
|
||||
|
||||
Hosts, groups, credentials and forwarding rules live in a local SQLite database. Passwords and key passphrases go only into the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service); the database never holds a secret. ShellRS also ships a `shellrs` command that lets AI agents such as Claude Code or Codex run commands and transfer files on your saved hosts without ever seeing a password.
|
||||
|
||||
> [!NOTE]
|
||||
> ShellRS is in beta, and its interface is currently in Simplified Chinese only. An English translation is in progress.
|
||||
> ShellRS's interface is currently in Simplified Chinese only. An English translation is in progress.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -48,6 +48,14 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
|
||||
- Find (smart case) and local clear; configurable font, size and line height.
|
||||
- Drag tabs into side-by-side or stacked splits to watch a terminal and SFTP together.
|
||||
|
||||
**Right sidebar tools**
|
||||
|
||||
- They follow the current remote terminal and read and run on that terminal's own connection, with no second login. ⌘⌥B (Ctrl+Alt+B elsewhere) shows or hides them.
|
||||
- System monitor: system details, CPU (per core too), memory, network rates and disk use, refreshed every 2 seconds.
|
||||
- Network connections, processes and services: search and filter, process details, ending processes, starting, stopping and restarting systemd services, switching them on at boot, and their logs. These three and the monitor are for Linux only.
|
||||
- Docker: containers grouped by compose project, with start, stop, restart, details and logs, plus volumes, images and networks. When not root, passwordless sudo is used (for services as well).
|
||||
- Command history reads `~/.bash_history` on the host, and snippets, shared by every host, can be sorted into categories; click one to type it into the terminal, or run it straight away.
|
||||
|
||||
**SFTP**
|
||||
|
||||
- A dual-pane browser modeled on WinSCP Commander: symmetric local and remote panes with WinSCP's columns, path labels, bookmarks and shortcuts (F5, F2, F7, F8…).
|
||||
@@ -56,6 +64,12 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
|
||||
- Delete (local items go to the Trash), rename, create, and change permissions (a 3×3 grid plus octal, optionally recursive).
|
||||
- Each SFTP tab has its own connection and asks only for the SFTP subsystem, so no shell is needed on the server.
|
||||
|
||||
**Opening from a bastion host**
|
||||
|
||||
- Launched the way Xshell and WinSCP are: in the client of a bastion host such as JumpServer, set ShellRS as the SSH or SFTP client. An `ssh://` link opens a terminal, an `sftp://` link an SFTP tab.
|
||||
- Understands Xshell's `-url` and `-newtab` and WinSCP's `/sessionname=`. When ShellRS is already running, the link opens there.
|
||||
- What opens is an unsaved external connection: it stays out of the host list, keeps the link's password in memory only and goes away with its tabs. Each connection uses a single channel, so bastion hosts that allow no more still work. See the [user manual](docs/manual.md#从堡垒机打开外部连接) (in Chinese).
|
||||
|
||||
**Port forwarding**
|
||||
|
||||
- Local (`-L`), remote (`-R`) and dynamic (`-D`, SOCKS5 / SOCKS4) forwards.
|
||||
@@ -84,6 +98,11 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
|
||||
<em>Dock splits: a remote terminal and SFTP stacked in one window</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/tools.png" alt="Five right sidebar tools side by side: system monitor, processes, services, Docker and network connections"><br>
|
||||
<em>Right sidebar tools: system monitor, processes, services, Docker and network connections</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/sftp.png" alt="Dual-pane SFTP: three local items selected on the left, remote /etc on the right, and a transfer queue with one batch uploading and one waiting"><br>
|
||||
<em>WinSCP-style SFTP: highlight selection and a transfer queue with progress, speed and time left</em>
|
||||
@@ -96,7 +115,7 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
|
||||
|
||||
## Download
|
||||
|
||||
Download from [shellrs.com/download](https://shellrs.com/download), which offers the package for your system, or from GitHub [Releases](https://github.com/since2006/shell-rs/releases). All releases so far are betas (marked Pre-release).
|
||||
Download from [shellrs.com/download](https://shellrs.com/download), which offers the package for your system, or from GitHub [Releases](https://github.com/since2006/shell-rs/releases). Betas are marked Pre-release there; to get new features early, switch the update channel to Beta in Settings › About (设置 › 关于).
|
||||
|
||||
| System | Package | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
|
||||
## 简介
|
||||
|
||||
ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SFTP 文件管理、SSH 端口转发放进同一个跨平台应用。它用 Rust 编写,界面基于 Zed 编辑器的 GPU 加速 UI 框架 GPUI(通过 [gpui-kit](https://gpui-kit.com)),不依赖 Electron 或 JVM。
|
||||
ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SFTP 文件管理、SSH 端口转发放进同一个跨平台应用,JumpServer 等堡垒机也可以像调用 Xshell、WinSCP 一样调用它。它用 Rust 编写,界面基于 Zed 编辑器的 GPU 加速 UI 框架 GPUI(通过 [gpui-kit](https://gpui-kit.com)),不依赖 Electron 或 JVM。
|
||||
|
||||
主机、分组、凭据和转发规则保存在本地的 SQLite 数据库里。密码和私钥口令只存进系统钥匙串(macOS 钥匙串、Windows 凭据管理器、Linux Secret Service),数据库里没有任何秘密。ShellRS 还带一个 `shellrs` 命令,让 Claude Code、Codex 等 AI Agent 用你保存的主机执行命令、传输文件,不用把密码交给它们。
|
||||
|
||||
> [!NOTE]
|
||||
> 目前是 Beta 版本,界面只有简体中文。
|
||||
> 界面目前只有简体中文。
|
||||
|
||||
## 功能
|
||||
|
||||
@@ -48,6 +48,14 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
- 支持查找(智能大小写)和本地清屏,字体、字号、行高都可以设置。
|
||||
- 标签可以拖成左右或上下分栏,终端和 SFTP 并排看。
|
||||
|
||||
**右侧栏工具**
|
||||
|
||||
- 跟着当前的远程终端,在这条终端自己的连接上读取和执行,不另外登录;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏。
|
||||
- 系统监控:系统信息、CPU(含每个核)、内存、网卡速率和磁盘占用,每 2 秒刷新。
|
||||
- 网络连接、进程管理和系统服务:搜索和筛选,查看进程详情、结束进程,启动、停止、重启 systemd 服务,开关开机启动,查看日志。这三项和系统监控只支持 Linux。
|
||||
- Docker:按 compose 项目列出容器,启停、重启、看详情和日志,管理卷、镜像和网络。不是 root 时用免密码的 sudo(系统服务也是)。
|
||||
- 历史命令读主机上的 `~/.bash_history`,命令片段所有主机共用、可以分类;点一下输入到终端,或直接执行。
|
||||
|
||||
**SFTP**
|
||||
|
||||
- 对标 WinSCP Commander 的双栏文件浏览器:本地和远程两侧对称,WinSCP 的列、路径标签、书签和快捷键(F5、F2、F7、F8……)。
|
||||
@@ -56,6 +64,12 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
- 删除(本地移到废纸篓)、重命名、新建、修改权限(3×3 复选框加八进制,可递归)。
|
||||
- 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。
|
||||
|
||||
**从堡垒机打开**
|
||||
|
||||
- 兼容 Xshell 和 WinSCP 的命令行调用方式:在 JumpServer 等堡垒机的客户端里把 ShellRS 设成 SSH / SFTP 客户端,`ssh://` 链接打开终端,`sftp://` 链接打开 SFTP。
|
||||
- 认得 Xshell 的 `-url`、`-newtab` 和 WinSCP 的 `/sessionname=`;ShellRS 已在运行时,链接交给正在运行的那个打开。
|
||||
- 打开的是不保存的外部连接:不进主机列表,链接里的密码只留在内存里,标签关掉就没了。每条连接只用一个通道,不允许多开通道的堡垒机也能用。详见[使用手册](docs/manual.md#从堡垒机打开外部连接)。
|
||||
|
||||
**端口转发**
|
||||
|
||||
- 本地(`-L`)、远程(`-R`)和动态(`-D`,SOCKS5 / SOCKS4)转发。
|
||||
@@ -84,6 +98,11 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
<em>Dock 分栏:远程终端和 SFTP 上下并排</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/tools.png" alt="右侧栏的五个工具并排:系统监控、进程管理、系统服务、Docker 和网络连接"><br>
|
||||
<em>右侧栏工具:系统监控、进程管理、系统服务、Docker 和网络连接</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/sftp.png" alt="双栏 SFTP:左侧本地目录多选了三项,右侧是远程 /etc,下方传输队列里一批正在上传、一批等待中"><br>
|
||||
<em>WinSCP 式双栏 SFTP:高亮多选,传输队列显示进度、速度和剩余时间</em>
|
||||
@@ -96,7 +115,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
|
||||
## 下载
|
||||
|
||||
到官网 [shellrs.com/download](https://shellrs.com/download) 下载,页面会按你的系统给出对应的安装包;也可以到 GitHub [Releases](https://github.com/since2006/shell-rs/releases) 下载。目前发布的都是 Beta 版(在 Releases 里标为 Pre-release)。
|
||||
到官网 [shellrs.com/download](https://shellrs.com/download) 下载,页面会按你的系统给出对应的安装包;也可以到 GitHub [Releases](https://github.com/since2006/shell-rs/releases) 下载。Beta 版在 Releases 里标为 Pre-release,想提前用上新功能,可以在「设置 › 关于」里把更新渠道切到 Beta。
|
||||
|
||||
| 系统 | 安装包 | 说明 |
|
||||
| --- | --- | --- |
|
||||
|
||||
+25
-9
@@ -20,7 +20,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
|
||||
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
|
||||
|
||||
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。
|
||||
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。「测试连接」时选了「密码」却没填,就像「无密码」那样试一遍(免认证、SSH Agent、默认私钥),连不上就说明原因。
|
||||
|
||||
主机对话框「分组」下面的「连接方式」决定怎么连到这台主机,终端、SFTP、端口转发、外部 CLI 和「测试连接」都照它走:
|
||||
|
||||
@@ -36,8 +36,21 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
|
||||
鼠标停在主机上,行的右侧会显示 `用户@地址:端口`,主机有备注时下面接着显示备注(太长的只显示前 6 行)。右键主机可连接、打开 SFTP、编辑、复制、复制 IP 地址(地址是域名时叫「复制主机名」)、复制 ID 或删除。「复制 ID」复制的是主机的 16 位随机标识(形如 `Jwg5rHvXCxw89paM`),供其他工具标识这台主机:它在主机创建时生成,改名、改地址都不变,复制出来的主机得到新的 ID,删掉的 ID 不会再分配。没有打开任何标签时,中间区显示「最近连接」列表,双击或回车连接;右键其中的主机弹出同一个菜单,并选中该主机。
|
||||
|
||||
列表上方「新建主机…」左边的「快速连接」打开一个搜索框,列出所有保存的主机:最近连接过的在前,其余按名称排,每行写着分组和 `用户@地址:端口`。输入名称、地址、用户名、备注或分组里的字就能筛选,几个词用空格隔开时都要匹配。打开时不高亮任何一台;输入后高亮第一个匹配的,回车就连它;也可以 ↑↓ 移动,或者直接单击一台连接。
|
||||
|
||||
拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。
|
||||
|
||||
## 临时连接
|
||||
|
||||
标题栏右上角的「临时连接…」连一台主机而不保存它,适合只登一次的机器。对话框和新建主机一样填地址、端口和认证方式(密码、使用凭据或无密码),没有分组、连接方式和备注,名称可以不填(用地址);可以先「测试连接」。点「连接」打开一个终端标签:
|
||||
|
||||
- **功能齐全**:在新标签页中连接、打开 SFTP、重新连接和右侧栏的全部工具都能用。连接方式是直接连接。
|
||||
- **不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」。
|
||||
- **密码只在内存里**:填的密码不进钥匙串,重新连接时照样用它;没填时服务器要密码就弹框问。选「使用凭据」时用凭据保存的密码或私钥。
|
||||
- 外部 CLI 的 `shellrs list` 也列出它(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时可以用它的 ID。
|
||||
|
||||
要保存的主机在主机列表标题栏的「+」、右键菜单或 ⌘N(其他平台 Ctrl+N)新建。
|
||||
|
||||
## 端口转发
|
||||
|
||||
点标题栏的端口转发图标,左侧栏换成转发列表。每条转发规则经由一台已保存的主机,启动时为自己建立一条 SSH 连接,不依赖该主机的终端或 SFTP 标签:关掉标签、对主机「断开连接」都不影响它,主机的连接状态也不把它算在内。有转发在运行时,标题栏的端口转发图标旁显示运行数量。
|
||||
@@ -118,31 +131,34 @@ shellrs upload <ID> <本地路径> <远程路径>
|
||||
shellrs download <ID> <远程路径> <本地路径>
|
||||
```
|
||||
|
||||
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接(`"temporary": true`),见「从堡垒机打开(临时连接)」。
|
||||
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。
|
||||
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
|
||||
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
|
||||
- 失败时在标准错误输出 `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 不会看到多余的回车符。
|
||||
|
||||
## 从堡垒机打开(临时连接)
|
||||
## 从堡垒机打开(外部连接)
|
||||
|
||||
JumpServer 等堡垒机的本地客户端可以像调用 Xshell 一样调用 ShellRS:把 ShellRS 设成 SSH 客户端,参数照 Xshell 的写法填。ShellRS 收到后打开一个终端标签连过去:
|
||||
JumpServer 等堡垒机的本地客户端可以像调用 Xshell 一样调用 ShellRS 打开终端,也可以像调用 WinSCP 一样调用它打开 SFTP:把 ShellRS 设成 SSH 客户端或 SFTP 客户端,参数照 Xshell 或 WinSCP 的写法填。ShellRS 收到 `ssh://` 链接就打开一个终端标签连过去,收到 `sftp://` 链接就打开一个 SFTP 标签:
|
||||
|
||||
```sh
|
||||
ShellRS ssh://用户[:密码]@地址[:端口]
|
||||
ShellRS -url ssh://用户[:密码]@地址[:端口] -newtab 标签名
|
||||
ShellRS sftp://用户[:密码]@地址[:端口]
|
||||
ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
```
|
||||
|
||||
- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),不分大小写;别的选项忽略。只支持 `ssh://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。
|
||||
- **临时连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。
|
||||
- **只用终端这一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话,所以临时连接不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。
|
||||
- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),以及 WinSCP 的 `/sessionname=标签名`(也可以写成 `-sessionname=`),不分大小写;`/newinstance`、`/ini=nul` 等别的选项忽略。只支持 `ssh://` 和 `sftp://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。`sftp://` 链接里的路径(WinSCP 用它指定打开哪个目录)目前不认,远程一侧照常打开主目录。
|
||||
- **标签占满窗口**:打开外部连接时,左侧的主机列表自动收起(同 ⌘B),按 ⌘B(其他平台 Ctrl+B)或双击标签即可展开。
|
||||
- **外部连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。
|
||||
- **只用一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话。SFTP 标签本来就只开一个;终端则不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。
|
||||
- 链接里的密码只留在内存里,不进钥匙串,重新连接时照样用它;没带密码时,服务器要密码就弹框问。第一次连到某台堡垒机要信任它的主机密钥,记在 ShellRS 自己的 `known_hosts` 里,下次不再问。
|
||||
- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。
|
||||
- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://` 或 `sftp://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。
|
||||
- 堡垒机要调用的程序:
|
||||
- Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。
|
||||
- macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。
|
||||
- Linux:AppImage 本身。
|
||||
- 外部 CLI 的 `shellrs list` 也列出临时连接(JSON 里 `"temporary": true`,表格的分组一栏写「(临时连接)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的临时连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
|
||||
- 外部 CLI 的 `shellrs list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
|
||||
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
|
||||
|
||||
## 标签页
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 675 KiB |
@@ -21,6 +21,11 @@ gpui_kit::actions!(
|
||||
[
|
||||
/// Open the new-host dialog for a host at the root of the tree.
|
||||
NewHost,
|
||||
/// Open the 临时连接 dialog: connect to a host without saving it.
|
||||
NewTemporaryConnection,
|
||||
/// Open 快速连接: search the saved hosts and connect to one or
|
||||
/// several.
|
||||
QuickConnect,
|
||||
/// Open the new-group dialog for a top-level group.
|
||||
NewGroup,
|
||||
/// Open a new local login-shell terminal.
|
||||
|
||||
@@ -65,7 +65,6 @@ The exit code is in `$LASTEXITCODE`.
|
||||
- `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.
|
||||
- Hosts with `"temporary": true` in `shellrs list` were opened in ShellRS from a bastion host's link rather than saved. Their id stays the same while their tab is open in ShellRS, so keep using it for every command; there is no need to list before each one. Once the tab is closed the id is gone (`host_not_found`), and opening the host from the bastion again gives it a new id: list again only then. Every command logs in to them again, which a bastion host may refuse if it gave out a one-time login; report that to the user instead of retrying.
|
||||
- `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.
|
||||
|
||||
|
||||
+3
-1
@@ -253,8 +253,10 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
|
||||
[
|
||||
host.id.clone(),
|
||||
host.name.clone(),
|
||||
// 临时连接 and 外部连接 alike: an agent needs no telling
|
||||
// them apart, only that the host is not saved.
|
||||
if host.temporary {
|
||||
"(临时连接)".to_string()
|
||||
"(未保存)".to_string()
|
||||
} else {
|
||||
host.group.clone().unwrap_or_default()
|
||||
},
|
||||
|
||||
+81
-9
@@ -1,5 +1,7 @@
|
||||
//! The links ShellRS is opened with, the way bastion hosts open Xshell:
|
||||
//! `ShellRS ssh://user@host:port`, or `ShellRS -url ssh://… -newtab 名称`.
|
||||
//! `ShellRS ssh://user@host:port`, or `ShellRS -url ssh://… -newtab 名称`;
|
||||
//! and WinSCP: `ShellRS sftp://user@host:port`, or with
|
||||
//! `/sessionname=名称` in front.
|
||||
//!
|
||||
//! Only told apart from the `shellrs` command's arguments here; the link
|
||||
//! itself is read by the app (`host::SshLink`), which says what is wrong
|
||||
@@ -17,7 +19,7 @@ pub struct OpenLink {
|
||||
/// May hold a password. Empty when the arguments named no link, so the
|
||||
/// app can say so: a second ShellRS on Windows has no console to.
|
||||
pub url: String,
|
||||
/// What to call the tab: Xshell's `-newtab`.
|
||||
/// What to call the tab: Xshell's `-newtab`, WinSCP's `/sessionname`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab: Option<String>,
|
||||
}
|
||||
@@ -54,17 +56,35 @@ const URL_OPTION: &str = "-url";
|
||||
const TAB_OPTION: &str = "-newtab";
|
||||
const WINDOW_OPTION: &str = "-newwin";
|
||||
|
||||
/// WinSCP's switches, written `/name[=value]` or `-name[=value]`:
|
||||
/// `/sessionname=名称` names the tab, the others mean nothing to ShellRS
|
||||
/// (`/newinstance` asks for a window of its own, `/ini=nul` not to read
|
||||
/// WinSCP's settings, `/privatekey=` and `/hostkey=` are ShellRS's to
|
||||
/// know).
|
||||
const WINSCP_SWITCHES: &[&str] = &[
|
||||
SESSION_NAME,
|
||||
"newinstance",
|
||||
"privatekey",
|
||||
"hostkey",
|
||||
"passphrase",
|
||||
"rawsettings",
|
||||
"ini",
|
||||
"log",
|
||||
"timeout",
|
||||
];
|
||||
const SESSION_NAME: &str = "sessionname";
|
||||
|
||||
/// The link `args` (without the program) open, or `None` when they are the
|
||||
/// `shellrs` command's. Only the first argument decides, so `shellrs exec
|
||||
/// web "curl http://x"` stays a command: a link launch starts with one of
|
||||
/// Xshell's options or with the link itself.
|
||||
/// Xshell's options, one of WinSCP's switches or the link itself.
|
||||
pub fn link_arguments(args: &[OsString]) -> Option<OpenLink> {
|
||||
let args: Vec<String> = args
|
||||
.iter()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let first = args.first()?;
|
||||
if !is_option(first) && !looks_like_link(first) {
|
||||
if !is_option(first) && winscp_switch(first).is_none() && !looks_like_link(first) {
|
||||
return None;
|
||||
}
|
||||
let mut given = None;
|
||||
@@ -82,11 +102,11 @@ pub fn link_arguments(args: &[OsString]) -> Option<OpenLink> {
|
||||
.peek()
|
||||
.is_some_and(|next| !next.starts_with('-') && !looks_like_link(next))
|
||||
{
|
||||
let name = args.next().unwrap_or_default();
|
||||
let name = name.trim();
|
||||
if !name.is_empty() {
|
||||
tab = Some(name.to_string());
|
||||
}
|
||||
tab = tab_name(&args.next().unwrap_or_default()).or(tab);
|
||||
}
|
||||
} else if let Some((switch, value)) = winscp_switch(&arg) {
|
||||
if switch == SESSION_NAME {
|
||||
tab = value.and_then(tab_name).or(tab);
|
||||
}
|
||||
} else if option == WINDOW_OPTION || arg.starts_with('-') {
|
||||
// Ignored, like any option ShellRS does not know.
|
||||
@@ -104,6 +124,26 @@ fn is_option(arg: &str) -> bool {
|
||||
[URL_OPTION, TAB_OPTION, WINDOW_OPTION].contains(&arg.to_lowercase().as_str())
|
||||
}
|
||||
|
||||
/// The name and value of one of WinSCP's switches, the name in lower case.
|
||||
fn winscp_switch(arg: &str) -> Option<(String, Option<&str>)> {
|
||||
let switch = arg.strip_prefix(['/', '-'])?;
|
||||
let (name, value) = match switch.split_once('=') {
|
||||
Some((name, value)) => (name, Some(value)),
|
||||
None => (switch, None),
|
||||
};
|
||||
let name = name.to_lowercase();
|
||||
WINSCP_SWITCHES
|
||||
.contains(&name.as_str())
|
||||
.then_some((name, value))
|
||||
}
|
||||
|
||||
/// A tab's name as given, without the spaces or quotes around it; `None`
|
||||
/// when that leaves nothing.
|
||||
fn tab_name(given: &str) -> Option<String> {
|
||||
let name = given.trim().trim_matches('"').trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// `scheme://…`, whatever the scheme: the app says which ones it opens.
|
||||
fn looks_like_link(arg: &str) -> bool {
|
||||
let Some((scheme, _)) = arg.split_once("://") else {
|
||||
@@ -162,6 +202,36 @@ mod tests {
|
||||
assert_eq!(link(&["-newwin", "-folder", "x", url]), open(url, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winscps_switches_are_understood_too() {
|
||||
// How JumpServer opens WinSCP: the link alone.
|
||||
let url = "sftp://b478e26f:secret@172.16.0.28:12024";
|
||||
assert_eq!(link(&[url]), open(url, None));
|
||||
assert_eq!(
|
||||
link(&["/sessionname=生产库", url]),
|
||||
open(url, Some("生产库"))
|
||||
);
|
||||
assert_eq!(
|
||||
link(&[
|
||||
"/newinstance",
|
||||
"/ini=nul",
|
||||
"-SessionName=\"文件 服务器\"",
|
||||
url
|
||||
]),
|
||||
open(url, Some("文件 服务器"))
|
||||
);
|
||||
assert_eq!(
|
||||
link(&[
|
||||
"/rawsettings",
|
||||
"Compression=1",
|
||||
url,
|
||||
"/hostkey=ssh-ed25519 255 x"
|
||||
]),
|
||||
open(url, None)
|
||||
);
|
||||
assert_eq!(link(&["/sessionname=生产库"]), open("", Some("生产库")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_without_a_link_still_open_so_the_app_can_say_why() {
|
||||
assert_eq!(link(&["-newtab", "生产库"]), open("", Some("生产库")));
|
||||
@@ -180,6 +250,8 @@ mod tests {
|
||||
&["--version"],
|
||||
&["ssh-not-a-link"],
|
||||
&["://nothing-before"],
|
||||
&["/tmp/sessionname"],
|
||||
&["/usr/bin/sftp://x"],
|
||||
] {
|
||||
assert_eq!(link(args), None, "{args:?}");
|
||||
}
|
||||
|
||||
+5
-5
@@ -159,7 +159,7 @@ pub enum Reply {
|
||||
},
|
||||
}
|
||||
|
||||
/// A host as the CLI lists it: a saved one, or one opened from a link.
|
||||
/// A host as the CLI lists it: a saved one, or a temporary one.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostInfo {
|
||||
/// The host's public ID, the one 复制 ID copies.
|
||||
@@ -172,10 +172,10 @@ pub struct HostInfo {
|
||||
pub port: u16,
|
||||
/// The detected operating system, as stored (`ubuntu`, `macos`, …).
|
||||
pub os: Option<String>,
|
||||
/// Opened from a bastion host's link (临时连接) rather than saved: listed
|
||||
/// while its tab is open, under one ID for as long; opened again, it is
|
||||
/// another host with another ID. Missing from an older app's answer,
|
||||
/// which listed saved hosts only.
|
||||
/// Connected to without being saved (a 临时连接, or an 外部连接 a bastion
|
||||
/// host's link opened): listed while its tab is open, under one ID for
|
||||
/// as long; opened again, it is another host with another ID. Missing
|
||||
/// from an older app's answer, which listed saved hosts only.
|
||||
#[serde(default)]
|
||||
pub temporary: bool,
|
||||
}
|
||||
|
||||
+6
-6
@@ -86,7 +86,7 @@ impl CliTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marked as opened from a link, not saved.
|
||||
/// Marked as not saved.
|
||||
fn temporary(mut self) -> Self {
|
||||
self.info.temporary = true;
|
||||
self
|
||||
@@ -101,10 +101,10 @@ impl CliTarget {
|
||||
&self.login
|
||||
}
|
||||
|
||||
/// Every host in `store`, with its group path, then the ones opened
|
||||
/// from a link. A request logs in to those afresh too, with the link's
|
||||
/// user and password; a bastion host may refuse a login it gave out for
|
||||
/// one use.
|
||||
/// Every host in `store`, with its group path, then the ones connected
|
||||
/// to without saving them. A request logs in to those afresh too, with
|
||||
/// the password kept in memory; a bastion host may refuse a login it
|
||||
/// gave out for one use.
|
||||
pub fn all(store: &HostStore) -> Vec<Self> {
|
||||
let saved = store.hosts().iter().map(|host| {
|
||||
let names = host
|
||||
@@ -118,7 +118,7 @@ impl CliTarget {
|
||||
)
|
||||
});
|
||||
let temporary = store
|
||||
.transient_hosts()
|
||||
.temporary_hosts()
|
||||
.iter()
|
||||
.map(|host| Self::new(host, store.login_of(host), None).temporary());
|
||||
saved.chain(temporary).collect()
|
||||
|
||||
+3
-3
@@ -383,14 +383,14 @@ fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
|
||||
AuthKind::Password,
|
||||
None,
|
||||
));
|
||||
let link = store.insert_transient_unnotified(
|
||||
let link = store.insert_external_unnotified(
|
||||
SshLink::parse("ssh://token:secret@10.0.0.9:2222", Some("堡垒机")).unwrap(),
|
||||
);
|
||||
let targets = CliTarget::all(&store);
|
||||
let ids: Vec<HostId> = targets.iter().map(|target| target.host().id).collect();
|
||||
assert_eq!(ids, [saved, link]);
|
||||
// With the link's password, which only memory holds.
|
||||
assert_eq!(targets[1].login().password, SecretRef::transient(link.0));
|
||||
assert_eq!(targets[1].login().password, SecretRef::temporary(link.0));
|
||||
|
||||
let fixture = fixture();
|
||||
fixture.server.set_targets(targets);
|
||||
@@ -420,7 +420,7 @@ fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
|
||||
// A table says so where the group goes.
|
||||
let (_, table, _) = run(&fixture.socket, Request::List { query: None }, false);
|
||||
assert!(
|
||||
table.lines().nth(2).unwrap().contains("(临时连接)"),
|
||||
table.lines().nth(2).unwrap().contains("(未保存)"),
|
||||
"{table}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,6 +319,17 @@ impl LoginTest {
|
||||
pub fn login(&self) -> &crate::host::HostLogin {
|
||||
&self.login
|
||||
}
|
||||
|
||||
/// A login by password typed into the form with the password left
|
||||
/// empty. 测试连接 then goes without one, as 「无密码」 would, rather than
|
||||
/// stop at the missing password (the user's call).
|
||||
pub fn password_left_empty(&self) -> bool {
|
||||
self.login.method == crate::host::LoginMethod::Password
|
||||
&& self
|
||||
.typed
|
||||
.as_ref()
|
||||
.is_some_and(|typed| typed.password.is_none())
|
||||
}
|
||||
pub fn host(&self) -> &str {
|
||||
&self.login.host
|
||||
}
|
||||
|
||||
+194
-59
@@ -17,7 +17,9 @@ use gpui_kit::component::{
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::app::ConnectHost;
|
||||
use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, UnknownHostPrompt};
|
||||
|
||||
use super::secret_fields::SecretFields;
|
||||
@@ -78,6 +80,9 @@ impl AuthSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a 临时连接's dialog says under its fields.
|
||||
const TEMPORARY_NOTE: &str = "仅当前使用的临时会话,不会保存到主机列表。";
|
||||
|
||||
/// What 「无密码」 tries, under the choice.
|
||||
const NO_PASSWORD_NOTE: &str = "依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥。";
|
||||
|
||||
@@ -168,6 +173,9 @@ type JumpPicker = ComboboxState<SearchableVec<JumpHost>>;
|
||||
pub struct HostForm {
|
||||
store: Entity<HostStore>,
|
||||
editing: Option<HostId>,
|
||||
/// A 临时连接's form: no group, route or notes, a name it may leave out,
|
||||
/// and a commit that saves nothing.
|
||||
temporary: bool,
|
||||
name: Entity<InputState>,
|
||||
address: Entity<InputState>,
|
||||
port: Entity<InputState>,
|
||||
@@ -399,6 +407,7 @@ impl HostForm {
|
||||
Self {
|
||||
store,
|
||||
editing,
|
||||
temporary: false,
|
||||
name,
|
||||
address,
|
||||
port,
|
||||
@@ -431,6 +440,22 @@ impl HostForm {
|
||||
}
|
||||
}
|
||||
|
||||
/// The form of a 临时连接: the login of a new host, without what only a
|
||||
/// saved host has (its group, its route, its notes).
|
||||
pub fn temporary(
|
||||
store: Entity<HostStore>,
|
||||
tester: SharedConnectionTester,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let mut form = Self::new(None, None, store, tester, window, cx);
|
||||
form.temporary = true;
|
||||
form.name.update(cx, |input, cx| {
|
||||
input.set_placeholder("可选,默认用地址", window, cx)
|
||||
});
|
||||
form
|
||||
}
|
||||
|
||||
fn set_source(&mut self, source: AuthSource, cx: &mut Context<Self>) {
|
||||
if self.source != source {
|
||||
self.source = source;
|
||||
@@ -741,6 +766,54 @@ impl HostForm {
|
||||
true
|
||||
}
|
||||
|
||||
/// Validate and connect without saving: the host goes into the store's
|
||||
/// memory, its password into the store's memory too, never the
|
||||
/// keychain. The host to connect to, or `None` when the form said what
|
||||
/// is wrong.
|
||||
pub fn commit_temporary(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<HostId> {
|
||||
let checked = self
|
||||
.endpoint(cx)
|
||||
.and_then(|endpoint| Ok((endpoint, self.committed_login(cx)?)));
|
||||
let ((host, port), login) = match checked {
|
||||
Ok(checked) => checked,
|
||||
Err(error) => {
|
||||
window.push_notification(form_error_notification(error), cx);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let name = self.name.read(cx).value().trim().to_string();
|
||||
let name = if name.is_empty() { host.clone() } else { name };
|
||||
let mut password = None;
|
||||
let draft = match login {
|
||||
CommittedLogin::Saved { credential, user } => {
|
||||
HostDraft::new(name, host, port, user, AuthKind::default(), None)
|
||||
.with_credential(credential)
|
||||
}
|
||||
CommittedLogin::Own(auth) => {
|
||||
let user = self.user.read(cx).value().trim().to_string();
|
||||
let user = if user.is_empty() {
|
||||
DEFAULT_USER.to_string()
|
||||
} else {
|
||||
user
|
||||
};
|
||||
if auth == AuthKind::Password {
|
||||
password = Some(Zeroizing::new(self.fields.read(cx).password(cx)));
|
||||
}
|
||||
HostDraft::new(name, host, port, user, auth, None)
|
||||
}
|
||||
};
|
||||
let id = self
|
||||
.store
|
||||
.update(cx, |store, cx| store.insert_temporary(draft, password, cx));
|
||||
self.fields
|
||||
.update(cx, |fields, cx| fields.clear(window, cx));
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The fields of a host that logs in on its own: its user, and its
|
||||
/// password when it has one.
|
||||
fn own_fields(&self, form: Form, auth: AuthKind, cx: &App) -> Form {
|
||||
@@ -1093,7 +1166,7 @@ impl Render for HostForm {
|
||||
.child(
|
||||
Field::new()
|
||||
.label("名称")
|
||||
.required(true)
|
||||
.required(!self.temporary)
|
||||
.col_span(4)
|
||||
.child(Input::new(&self.name).id("host-name").small()),
|
||||
)
|
||||
@@ -1137,28 +1210,43 @@ impl Render for HostForm {
|
||||
Some(auth) => self.own_fields(form, auth, cx),
|
||||
None => form.child(self.credential_field(cx)),
|
||||
};
|
||||
// What only a saved host has.
|
||||
let form = if self.temporary {
|
||||
form
|
||||
} else {
|
||||
form.child(
|
||||
Field::new()
|
||||
.label("分组")
|
||||
.col_span(4)
|
||||
.child(Select::new(&self.group).small()),
|
||||
)
|
||||
.child(self.route_field(cx))
|
||||
.child(
|
||||
Field::new().label("备注").col_span(4).child(
|
||||
div()
|
||||
.id("host-notes")
|
||||
.test_support()
|
||||
.w_full()
|
||||
.child(Textarea::new(&self.notes).text_sm()),
|
||||
),
|
||||
)
|
||||
};
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(
|
||||
.child(form)
|
||||
.when(self.temporary, |form| {
|
||||
form.child(
|
||||
Field::new()
|
||||
.label("分组")
|
||||
.col_span(4)
|
||||
.child(Select::new(&self.group).small()),
|
||||
div()
|
||||
.id("temporary-note")
|
||||
.test_support()
|
||||
.aria_label(TEMPORARY_NOTE)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(TEMPORARY_NOTE),
|
||||
)
|
||||
.child(self.route_field(cx))
|
||||
.child(
|
||||
Field::new().label("备注").col_span(4).child(
|
||||
div()
|
||||
.id("host-notes")
|
||||
.test_support()
|
||||
.w_full()
|
||||
.child(Textarea::new(&self.notes).text_sm()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(source == AuthSource::Password, |form| {
|
||||
})
|
||||
.when(!self.temporary && source == AuthSource::Password, |form| {
|
||||
form.child(
|
||||
div()
|
||||
.text_sm()
|
||||
@@ -1300,47 +1388,7 @@ pub fn open_host_dialog(
|
||||
// Closed by its buttons or Escape, not by a click beside it.
|
||||
.overlay_closable(false)
|
||||
.child(form.clone())
|
||||
.footer(
|
||||
DialogFooter::new()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(
|
||||
Button::new("test-connection")
|
||||
.label("测试连接")
|
||||
.icon(crate::app::CatalogIcon::Plug)
|
||||
.small()
|
||||
.loading(form.read(cx).testing_connection)
|
||||
.on_click({
|
||||
let form = form.clone();
|
||||
move |event, window, cx| {
|
||||
form.update(cx, |form, cx| {
|
||||
form.test_connection(event, window, cx)
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Button::new("cancel").label("取消").small().on_click(
|
||||
|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Cancel), cx);
|
||||
},
|
||||
))
|
||||
.child(
|
||||
Button::new("commit")
|
||||
.primary()
|
||||
.label(commit_label.clone())
|
||||
.small()
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(Confirm { secondary: false }),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.footer(form_footer(&form, commit_label.clone(), cx))
|
||||
.on_ok({
|
||||
let form = form.clone();
|
||||
move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx))
|
||||
@@ -1350,6 +1398,93 @@ pub fn open_host_dialog(
|
||||
});
|
||||
}
|
||||
|
||||
/// The host form's footer: 「测试连接」 on the left, 「取消」 and the
|
||||
/// commit on the right. The new-host, edit-host and 临时连接 dialogs share it.
|
||||
fn form_footer(form: &Entity<HostForm>, commit_label: SharedString, cx: &App) -> DialogFooter {
|
||||
DialogFooter::new()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(
|
||||
Button::new("test-connection")
|
||||
.label("测试连接")
|
||||
.icon(crate::app::CatalogIcon::Plug)
|
||||
.small()
|
||||
.loading(form.read(cx).testing_connection)
|
||||
.on_click({
|
||||
let form = form.clone();
|
||||
move |event, window, cx| {
|
||||
form.update(cx, |form, cx| form.test_connection(event, window, cx));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("取消")
|
||||
.small()
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Cancel), cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("commit")
|
||||
.primary()
|
||||
.label(commit_label)
|
||||
.small()
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Open the 临时连接 dialog: a host's login, connected to in a terminal tab
|
||||
/// without being saved. `dispatch` is the workspace's focus handle, which the
|
||||
/// connection is asked of once the dialog has closed and left the focus
|
||||
/// nowhere.
|
||||
pub fn open_temporary_connection_dialog(
|
||||
store: Entity<HostStore>,
|
||||
tester: SharedConnectionTester,
|
||||
dispatch: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let form = cx.new(|cx| HostForm::temporary(store, tester, window, cx));
|
||||
window.open_dialog(cx, {
|
||||
let form = form.clone();
|
||||
move |dialog, window, cx| {
|
||||
dialog
|
||||
.title("临时连接")
|
||||
.margin_top(window.viewport_size().height * DIALOG_TOP)
|
||||
.max_h(window.viewport_size().height * DIALOG_MAX_HEIGHT)
|
||||
.overlay_closable(false)
|
||||
.child(form.clone())
|
||||
.footer(form_footer(&form, "连接".into(), cx))
|
||||
.on_ok({
|
||||
let form = form.clone();
|
||||
let dispatch = dispatch.clone();
|
||||
move |_, window, cx| {
|
||||
let Some(host) =
|
||||
form.update(cx, |form, cx| form.commit_temporary(window, cx))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// After the dialog is gone, so the new tab keeps the
|
||||
// focus it takes.
|
||||
let dispatch = dispatch.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
dispatch.dispatch_action(&ConnectHost(host), window, cx)
|
||||
});
|
||||
true
|
||||
}
|
||||
})
|
||||
.on_close(|_, window, cx| dismiss_form_error(window, cx))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What depends on hosts about to be deleted, beyond their own tabs.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Dependents {
|
||||
|
||||
+7
-2
@@ -11,6 +11,7 @@ mod login;
|
||||
mod model;
|
||||
mod outline;
|
||||
mod private_key;
|
||||
mod quick_connect;
|
||||
mod secret_fields;
|
||||
mod snippet;
|
||||
mod ssh_link;
|
||||
@@ -20,13 +21,17 @@ 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_dialog::{
|
||||
DeleteHandler, Dependents, HostForm, confirm_delete_host, open_host_dialog,
|
||||
open_temporary_connection_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 quick_connect::{QuickChoice, QuickHost, open_quick_connect_dialog, quick_hosts};
|
||||
pub use secret_fields::SecretFields;
|
||||
pub use snippet::*;
|
||||
pub use ssh_link::SshLink;
|
||||
pub use ssh_link::{LinkKind, SshLink};
|
||||
pub use store::{HostStore, HostStoreEvent};
|
||||
@@ -0,0 +1,545 @@
|
||||
//! 快速连接: the start page's palette for reaching saved hosts by typing a
|
||||
//! few letters of them.
|
||||
//!
|
||||
//! The list is gpui-kit's `List` (its search box, ↑↓, Enter and a click
|
||||
//! connect the highlighted host). Choosing several and connecting them
|
||||
//! together is built but switched off, see [`MULTIPLE`]: the search box
|
||||
//! would keep Tab, ⌘A and ⇧↵ for itself, so the palette takes those actions
|
||||
//! on their way to it, in the capture phase: Tab chooses the highlighted
|
||||
//! host, ⌘A every host shown, ⇧↵ connects the chosen ones.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, IconName, IndexPath, Sizable as _, WindowExt as _,
|
||||
dialog::DialogFooter,
|
||||
h_flex,
|
||||
input::{Enter, IndentInline, SelectAll},
|
||||
kbd::Kbd,
|
||||
list::{List, ListDelegate, ListItem, ListState},
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::*;
|
||||
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
|
||||
use crate::app::ConnectHost;
|
||||
use crate::shared::HostMark;
|
||||
|
||||
use super::{HostId, HostOs, HostStore};
|
||||
|
||||
/// Whether several hosts can be chosen (Tab, ⌘A, ⌘-click) and connected to
|
||||
/// together (⇧↵), with the keys in the footer. Off for now (the user's
|
||||
/// call); turning it on brings all of that back.
|
||||
const MULTIPLE: bool = false;
|
||||
|
||||
/// A host as the palette lists it, as it was when the palette opened: the
|
||||
/// dialog is modal, so nothing changes under it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct QuickHost {
|
||||
pub id: HostId,
|
||||
pub name: SharedString,
|
||||
pub user: SharedString,
|
||||
pub address: SharedString,
|
||||
/// `user@address:port`.
|
||||
pub endpoint: SharedString,
|
||||
/// The full group path (`公司 / MySQL 集群`), when it has a group.
|
||||
pub group: Option<SharedString>,
|
||||
pub notes: SharedString,
|
||||
pub os: Option<HostOs>,
|
||||
}
|
||||
|
||||
impl QuickHost {
|
||||
/// Whether every word of `query` is somewhere in the name, address,
|
||||
/// user, notes or group path, whatever the case.
|
||||
pub fn matches(&self, query: &str) -> bool {
|
||||
let fields = [
|
||||
&self.name,
|
||||
&self.address,
|
||||
&self.user,
|
||||
&self.notes,
|
||||
self.group.as_ref().unwrap_or(&self.name),
|
||||
]
|
||||
.map(|field| field.to_lowercase());
|
||||
query
|
||||
.to_lowercase()
|
||||
.split_whitespace()
|
||||
.all(|word| fields.iter().any(|field| field.contains(word)))
|
||||
}
|
||||
|
||||
/// The line under the name: the group, then where it logs in.
|
||||
pub fn detail(&self) -> String {
|
||||
match &self.group {
|
||||
Some(group) => format!("{group} · {}", self.endpoint),
|
||||
None => self.endpoint.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved hosts, those connected to lately first (the latest first), then
|
||||
/// the rest by name.
|
||||
pub fn quick_hosts(store: &HostStore) -> Vec<QuickHost> {
|
||||
let of = |host: &super::Host| QuickHost {
|
||||
id: host.id,
|
||||
name: host.name.clone(),
|
||||
user: host.user.clone(),
|
||||
address: host.address.clone(),
|
||||
endpoint: host.endpoint().into(),
|
||||
group: host
|
||||
.group
|
||||
.map(|group| SharedString::from(store.group_path(group))),
|
||||
notes: host.notes.clone(),
|
||||
os: host.os,
|
||||
};
|
||||
let mut hosts: Vec<QuickHost> = store
|
||||
.recent_hosts()
|
||||
.filter(|host| !store.is_temporary(host.id))
|
||||
.map(of)
|
||||
.collect();
|
||||
let recent: HashSet<HostId> = hosts.iter().map(|host| host.id).collect();
|
||||
let mut rest: Vec<QuickHost> = store
|
||||
.hosts()
|
||||
.iter()
|
||||
.filter(|host| !recent.contains(&host.id))
|
||||
.map(of)
|
||||
.collect();
|
||||
rest.sort_by_key(|host| host.name.to_lowercase());
|
||||
hosts.extend(rest);
|
||||
hosts
|
||||
}
|
||||
|
||||
/// What the palette shows, which row is highlighted and which hosts have
|
||||
/// been chosen: the list's state, apart from the list.
|
||||
pub struct QuickChoice {
|
||||
hosts: Vec<QuickHost>,
|
||||
/// Indices into `hosts` of the ones the query matches, in order.
|
||||
shown: Vec<usize>,
|
||||
/// Chosen with Tab or ⌘A, for ⇧↵ to connect together. Kept while the
|
||||
/// query changes, so hosts found by different searches go together.
|
||||
chosen: HashSet<HostId>,
|
||||
/// The highlighted row of `shown`.
|
||||
selected: Option<usize>,
|
||||
}
|
||||
|
||||
impl QuickChoice {
|
||||
pub fn new(hosts: Vec<QuickHost>) -> Self {
|
||||
Self {
|
||||
shown: (0..hosts.len()).collect(),
|
||||
hosts,
|
||||
chosen: HashSet::new(),
|
||||
selected: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the hosts `query` matches.
|
||||
pub fn search(&mut self, query: &str) {
|
||||
self.shown = (0..self.hosts.len())
|
||||
.filter(|ix| self.hosts[*ix].matches(query))
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Highlight a row of what is shown.
|
||||
pub fn highlight(&mut self, row: Option<usize>) {
|
||||
self.selected = row;
|
||||
}
|
||||
|
||||
/// The host on a row of what is shown.
|
||||
pub fn shown(&self, row: usize) -> Option<&QuickHost> {
|
||||
self.shown.get(row).map(|ix| &self.hosts[*ix])
|
||||
}
|
||||
|
||||
pub fn shown_count(&self) -> usize {
|
||||
self.shown.len()
|
||||
}
|
||||
|
||||
pub fn is_chosen(&self, id: HostId) -> bool {
|
||||
self.chosen.contains(&id)
|
||||
}
|
||||
|
||||
fn highlighted(&self) -> Option<HostId> {
|
||||
self.selected
|
||||
.and_then(|row| self.shown(row))
|
||||
.map(|host| host.id)
|
||||
}
|
||||
|
||||
/// Tab: choose the highlighted host, or take it back.
|
||||
pub fn toggle_highlighted(&mut self) {
|
||||
if let Some(id) = self.highlighted()
|
||||
&& !self.chosen.remove(&id)
|
||||
{
|
||||
self.chosen.insert(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌘A: choose every host shown; when they all are already, take them
|
||||
/// back.
|
||||
pub fn choose_all(&mut self) {
|
||||
let shown: Vec<HostId> = self.shown.iter().map(|ix| self.hosts[*ix].id).collect();
|
||||
if shown.iter().all(|id| self.chosen.contains(id)) {
|
||||
for id in &shown {
|
||||
self.chosen.remove(id);
|
||||
}
|
||||
} else {
|
||||
self.chosen.extend(shown);
|
||||
}
|
||||
}
|
||||
|
||||
/// What Enter connects: the highlighted host.
|
||||
pub fn to_connect_one(&self) -> Vec<HostId> {
|
||||
self.highlighted().into_iter().collect()
|
||||
}
|
||||
|
||||
/// What ⇧↵ connects: the chosen hosts in the list's order, or the
|
||||
/// highlighted one when none is chosen.
|
||||
pub fn to_connect(&self) -> Vec<HostId> {
|
||||
let chosen: Vec<HostId> = self
|
||||
.hosts
|
||||
.iter()
|
||||
.map(|host| host.id)
|
||||
.filter(|id| self.chosen.contains(id))
|
||||
.collect();
|
||||
if chosen.is_empty() {
|
||||
self.to_connect_one()
|
||||
} else {
|
||||
chosen
|
||||
}
|
||||
}
|
||||
|
||||
fn has_hosts(&self) -> bool {
|
||||
!self.hosts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The palette's list: its choice, and where to ask for connections.
|
||||
pub struct QuickConnectList {
|
||||
choice: QuickChoice,
|
||||
/// The workspace's focus handle, which connecting is asked of: once the
|
||||
/// dialog has closed, the focus is nowhere.
|
||||
dispatch: FocusHandle,
|
||||
}
|
||||
|
||||
impl QuickConnectList {
|
||||
pub fn new(hosts: Vec<QuickHost>, dispatch: FocusHandle) -> Self {
|
||||
Self {
|
||||
choice: QuickChoice::new(hosts),
|
||||
dispatch,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn choice(&self) -> &QuickChoice {
|
||||
&self.choice
|
||||
}
|
||||
|
||||
pub fn choice_mut(&mut self) -> &mut QuickChoice {
|
||||
&mut self.choice
|
||||
}
|
||||
|
||||
/// Close the palette and connect to `hosts`, each in a terminal tab.
|
||||
pub fn connect(&self, hosts: Vec<HostId>, window: &mut Window, cx: &mut App) {
|
||||
if hosts.is_empty() {
|
||||
return;
|
||||
}
|
||||
window.close_dialog(cx);
|
||||
let dispatch = self.dispatch.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
for host in hosts {
|
||||
dispatch.dispatch_action(&ConnectHost(host), window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ListDelegate for QuickConnectList {
|
||||
type Item = ListItem;
|
||||
|
||||
fn perform_search(
|
||||
&mut self,
|
||||
query: &str,
|
||||
_: &mut Window,
|
||||
_: &mut Context<ListState<Self>>,
|
||||
) -> Task<()> {
|
||||
self.choice.search(query);
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
fn items_count(&self, _: usize, _: &App) -> usize {
|
||||
self.choice.shown_count()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&mut self,
|
||||
ix: IndexPath,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Option<ListItem> {
|
||||
let host = self.choice.shown(ix.row)?;
|
||||
let chosen = self.choice.is_chosen(host.id);
|
||||
let detail = host.detail();
|
||||
let muted = cx.theme().muted_foreground;
|
||||
Some(
|
||||
ListItem::new(("quick-connect-row", host.id.0))
|
||||
.py_1p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(MULTIPLE, |item| {
|
||||
item.check_icon(IconName::Check).confirmed(chosen)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.id(("quick-connect-host", host.id.0))
|
||||
.test_support()
|
||||
.aria_label(SharedString::from(format!("{} · {detail}", host.name)))
|
||||
.aria_selected(chosen)
|
||||
.gap_3()
|
||||
.child(
|
||||
HostMark::new(
|
||||
("quick-connect-os", host.id.0),
|
||||
host.name.clone(),
|
||||
host.os,
|
||||
)
|
||||
.without_tooltip()
|
||||
.small(),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.child(div().truncate().child(host.name.clone()))
|
||||
.child(div().text_sm().text_color(muted).truncate().child(detail)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<IndexPath>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<ListState<Self>>,
|
||||
) {
|
||||
self.choice.highlight(ix.map(|ix| ix.row));
|
||||
}
|
||||
|
||||
/// Enter or a click connects the highlighted host; with ⌘ (Ctrl) and
|
||||
/// [`MULTIPLE`] on, it is chosen instead, as Tab does.
|
||||
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
|
||||
if secondary && MULTIPLE {
|
||||
self.choice.toggle_highlighted();
|
||||
cx.notify();
|
||||
} else {
|
||||
self.connect(self.choice.to_connect_one(), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_empty(
|
||||
&mut self,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> impl IntoElement {
|
||||
let text = if !self.choice.has_hosts() {
|
||||
"还没有保存的主机"
|
||||
} else {
|
||||
"没有匹配的主机"
|
||||
};
|
||||
h_flex()
|
||||
.id("quick-connect-empty")
|
||||
.test_support()
|
||||
.aria_label(text)
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// One key and what it does, for the footer.
|
||||
fn key_hint(keys: &str, label: &'static str) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.children(Keystroke::parse(keys).ok().map(Kbd::new))
|
||||
.child(label)
|
||||
}
|
||||
|
||||
/// Open 快速连接: search the saved hosts and connect to one, or choose
|
||||
/// several and connect to them together. `dispatch` is the workspace's
|
||||
/// focus handle.
|
||||
pub fn open_quick_connect_dialog(
|
||||
store: Entity<HostStore>,
|
||||
dispatch: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let hosts = quick_hosts(store.read(cx));
|
||||
// Nothing is highlighted until there is something to go by (the user's
|
||||
// call: a host picked in advance read as chosen for them). Searching
|
||||
// highlights the first match, as `List` does, and ↓ the first host.
|
||||
let list = cx.new(|cx| {
|
||||
ListState::new(QuickConnectList::new(hosts, dispatch), window, cx).searchable(true)
|
||||
});
|
||||
let select_all = if cfg!(target_os = "macos") {
|
||||
"cmd-a"
|
||||
} else {
|
||||
"ctrl-a"
|
||||
};
|
||||
window.open_dialog(cx, {
|
||||
let list = list.clone();
|
||||
move |dialog, _, cx| {
|
||||
let theme = cx.theme();
|
||||
dialog
|
||||
.title("快速连接")
|
||||
.overlay_closable(false)
|
||||
.child(
|
||||
div()
|
||||
.id("quick-connect")
|
||||
.test_support()
|
||||
.w_full()
|
||||
.h(rems(26.))
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded(theme.radius)
|
||||
.overflow_hidden()
|
||||
// The search box's own Tab, ⌘A and ⇧↵, taken on the
|
||||
// way to it.
|
||||
.when(MULTIPLE, |palette| {
|
||||
palette
|
||||
.capture_action({
|
||||
let list = list.clone();
|
||||
move |_: &IndentInline, _, cx| {
|
||||
list.update(cx, |list, cx| {
|
||||
list.delegate_mut().choice_mut().toggle_highlighted();
|
||||
cx.notify();
|
||||
});
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.capture_action({
|
||||
let list = list.clone();
|
||||
move |_: &SelectAll, _, cx| {
|
||||
list.update(cx, |list, cx| {
|
||||
list.delegate_mut().choice_mut().choose_all();
|
||||
cx.notify();
|
||||
});
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.capture_action({
|
||||
let list = list.clone();
|
||||
move |enter: &Enter, window, cx| {
|
||||
if enter.shift {
|
||||
list.update(cx, |list, cx| {
|
||||
let hosts = list.delegate().choice().to_connect();
|
||||
list.delegate().connect(hosts, window, cx);
|
||||
});
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(
|
||||
List::new(&list)
|
||||
.search_placeholder("搜索主机名称、地址、用户名、备注或分组"),
|
||||
),
|
||||
)
|
||||
.when(MULTIPLE, |dialog| {
|
||||
dialog.footer(
|
||||
DialogFooter::new()
|
||||
.w_full()
|
||||
.justify_end()
|
||||
.gap_4()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(key_hint("tab", "选中"))
|
||||
.child(key_hint(select_all, "全选"))
|
||||
.child(key_hint("shift-enter", "批量连接")),
|
||||
)
|
||||
})
|
||||
}
|
||||
});
|
||||
// The search box takes the keys at once: focusing it later in another
|
||||
// update would lose to the dialog's own focus.
|
||||
list.update(cx, |list, cx| list.focus(window, cx));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{
|
||||
AuthKind, ConnectionState, GroupDraft, GroupId, HostDraft, HostId, HostStore,
|
||||
};
|
||||
use super::{QuickChoice, QuickHost, quick_hosts};
|
||||
|
||||
fn host(name: &str, address: &str, group: Option<GroupId>) -> HostDraft {
|
||||
HostDraft::new(name, address, 22, "root", AuthKind::Password, group)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hosts_connected_to_lately_come_first_then_the_rest_by_name() {
|
||||
let mut store = HostStore::empty();
|
||||
let group = store.insert_group_unnotified(GroupDraft::new("公司", None));
|
||||
let zen = store.insert_unnotified(host("禅道", "8.138.95.125", None));
|
||||
let web = store.insert_unnotified(host("web", "10.0.0.1", Some(group)));
|
||||
let db = store.insert_unnotified(host("DB", "10.0.0.2", None));
|
||||
let api = store.insert_unnotified(host("api", "10.0.0.3", None));
|
||||
store.set_state_unnotified(web, ConnectionState::Connected);
|
||||
store.set_state_unnotified(zen, ConnectionState::Connected);
|
||||
|
||||
let hosts = quick_hosts(&store);
|
||||
let ids: Vec<HostId> = hosts.iter().map(|host| host.id).collect();
|
||||
assert_eq!(ids, [zen, web, api, db]);
|
||||
assert_eq!(hosts[0].detail(), "root@8.138.95.125:22");
|
||||
assert_eq!(hosts[1].detail(), "公司 · root@10.0.0.1:22");
|
||||
}
|
||||
|
||||
fn quick(id: u64, name: &str) -> QuickHost {
|
||||
QuickHost {
|
||||
id: HostId(id),
|
||||
name: name.into(),
|
||||
user: "deploy".into(),
|
||||
address: "172.16.1.166".into(),
|
||||
endpoint: "deploy@172.16.1.166:22".into(),
|
||||
group: Some("公司 / CI".into()),
|
||||
notes: "构建服务器".into(),
|
||||
os: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_word_is_looked_for_in_the_name_address_user_notes_and_group() {
|
||||
let host = quick(1, "teamcity");
|
||||
for query in ["", "TEAM", "172.16", "deploy", "构建", "公司 ci", "ci 166"] {
|
||||
assert!(host.matches(query), "{query:?}");
|
||||
}
|
||||
for query in ["jenkins", "公司 jenkins"] {
|
||||
assert!(!host.matches(query), "{query:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_select_all_choose_what_shift_enter_connects() {
|
||||
let mut choice =
|
||||
QuickChoice::new(vec![quick(1, "alpha"), quick(2, "beta"), quick(3, "gamma")]);
|
||||
// Nothing chosen: the highlighted host, Enter's as well.
|
||||
choice.highlight(Some(1));
|
||||
assert_eq!(choice.to_connect(), [HostId(2)]);
|
||||
assert_eq!(choice.to_connect_one(), [HostId(2)]);
|
||||
|
||||
// Tab chooses and takes back; the list's order, not the choosing's.
|
||||
choice.toggle_highlighted();
|
||||
choice.highlight(Some(0));
|
||||
choice.toggle_highlighted();
|
||||
assert_eq!(choice.to_connect(), [HostId(1), HostId(2)]);
|
||||
choice.toggle_highlighted();
|
||||
assert_eq!(choice.to_connect(), [HostId(2)]);
|
||||
|
||||
// ⌘A chooses what is shown, then takes it all back. A choice made
|
||||
// under another search stays.
|
||||
choice.search("gamma");
|
||||
assert_eq!(choice.shown_count(), 1);
|
||||
choice.choose_all();
|
||||
assert_eq!(choice.to_connect(), [HostId(2), HostId(3)]);
|
||||
choice.search("");
|
||||
choice.choose_all();
|
||||
assert_eq!(choice.to_connect(), [HostId(1), HostId(2), HostId(3)]);
|
||||
choice.choose_all();
|
||||
choice.highlight(Some(2));
|
||||
assert_eq!(choice.to_connect(), [HostId(3)]);
|
||||
}
|
||||
}
|
||||
+56
-9
@@ -1,6 +1,7 @@
|
||||
//! `ssh://user[:password]@host[:port]`: how a bastion host opens Xshell, and
|
||||
//! so ShellRS, on a host the user has not saved. ShellRS connects to it as a
|
||||
//! 临时连接 that lives only as long as its tabs.
|
||||
//! so ShellRS, on a host the user has not saved; `sftp://…` the same way is
|
||||
//! how it opens WinSCP. ShellRS connects to it as an 外部连接: not saved,
|
||||
//! gone with its tabs, a terminal or an SFTP tab alone.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
@@ -13,8 +14,18 @@ use super::{AuthKind, HostDraft};
|
||||
/// The port when the link names none.
|
||||
const SSH_PORT: u16 = 22;
|
||||
|
||||
/// What a link opens, by its scheme.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LinkKind {
|
||||
/// `ssh://`, as for Xshell: a terminal.
|
||||
Ssh,
|
||||
/// `sftp://`, as for WinSCP: an SFTP tab.
|
||||
Sftp,
|
||||
}
|
||||
|
||||
/// What a link says to connect to.
|
||||
pub struct SshLink {
|
||||
pub kind: LinkKind,
|
||||
/// The tab's name: what the bastion host called it (Xshell's
|
||||
/// `-newtab`), or else the address.
|
||||
pub name: String,
|
||||
@@ -32,9 +43,15 @@ impl SshLink {
|
||||
pub fn parse(link: &str, tab: Option<&str>) -> Result<Self, String> {
|
||||
let link = link.trim();
|
||||
if link.is_empty() {
|
||||
return Err("启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口]。".into());
|
||||
return Err(
|
||||
"启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口](打开 SFTP 用 sftp://)。"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let malformed = || "链接的格式不对,应为 ssh://用户[:密码]@地址[:端口]。".to_string();
|
||||
let malformed = || {
|
||||
"链接的格式不对,应为 ssh://用户[:密码]@地址[:端口](打开 SFTP 用 sftp://)。"
|
||||
.to_string()
|
||||
};
|
||||
// Without `://`, `user:password@host` would read as the scheme
|
||||
// `user`, and the error would show it.
|
||||
if !link.contains("://") {
|
||||
@@ -45,9 +62,15 @@ impl SshLink {
|
||||
ParseError::EmptyHost => "链接里没有地址。".to_string(),
|
||||
_ => malformed(),
|
||||
})?;
|
||||
if url.scheme() != "ssh" {
|
||||
return Err(format!("只支持 ssh:// 链接,不支持 {}://。", url.scheme()));
|
||||
}
|
||||
let kind = match url.scheme() {
|
||||
"ssh" => LinkKind::Ssh,
|
||||
"sftp" => LinkKind::Sftp,
|
||||
scheme => {
|
||||
return Err(format!(
|
||||
"只支持 ssh:// 和 sftp:// 链接,不支持 {scheme}://。"
|
||||
));
|
||||
}
|
||||
};
|
||||
// `ssh` is not one of the schemes the URL standard knows, so an IPv4
|
||||
// address and a domain both come back as an opaque domain, which is
|
||||
// percent-encoded when it is not ASCII.
|
||||
@@ -67,7 +90,10 @@ impl SshLink {
|
||||
};
|
||||
let user = decode(url.username())?;
|
||||
if user.is_empty() {
|
||||
return Err("链接里没有用户名,应为 ssh://用户@地址[:端口]。".into());
|
||||
return Err(format!(
|
||||
"链接里没有用户名,应为 {}://用户@地址[:端口]。",
|
||||
url.scheme()
|
||||
));
|
||||
}
|
||||
let password = match url.password() {
|
||||
Some(password) => Some(Zeroizing::new(decode(password)?)),
|
||||
@@ -79,6 +105,7 @@ impl SshLink {
|
||||
.filter(|tab| !tab.is_empty())
|
||||
.map_or_else(|| address.clone(), str::to_string);
|
||||
Ok(Self {
|
||||
kind,
|
||||
name,
|
||||
address,
|
||||
port,
|
||||
@@ -104,6 +131,7 @@ impl SshLink {
|
||||
impl fmt::Debug for SshLink {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("SshLink")
|
||||
.field("kind", &self.kind)
|
||||
.field("name", &self.name)
|
||||
.field("address", &self.address)
|
||||
.field("port", &self.port)
|
||||
@@ -137,6 +165,7 @@ mod tests {
|
||||
assert_eq!(link.port, 12024);
|
||||
assert!(link.password.is_none());
|
||||
assert_eq!(link.name, "172.16.0.28");
|
||||
assert_eq!(link.kind, LinkKind::Ssh);
|
||||
|
||||
let draft = link.draft();
|
||||
assert_eq!(draft.address.as_ref(), "172.16.0.28");
|
||||
@@ -170,6 +199,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_sftp_link_is_read_alike_and_opens_sftp() {
|
||||
// How JumpServer opens WinSCP.
|
||||
let link = parse("sftp://b478e26f:p%40ss@172.16.0.28:12024");
|
||||
assert_eq!(link.kind, LinkKind::Sftp);
|
||||
assert_eq!(link.user, "b478e26f");
|
||||
assert_eq!(link.address, "172.16.0.28");
|
||||
assert_eq!(link.port, 12024);
|
||||
assert_eq!(link.password.as_deref().map(String::as_str), Some("p@ss"));
|
||||
assert_eq!(link.draft().auth, AuthKind::Password);
|
||||
// The port is SSH's when not given, and a path is passed over.
|
||||
let link = parse("SFTP://root@files.example/srv/data/");
|
||||
assert_eq!(link.kind, LinkKind::Sftp);
|
||||
assert_eq!((link.address.as_str(), link.port), ("files.example", 22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_user_ends_at_the_last_at_sign() {
|
||||
// How JumpServer names the account and the asset in one user name.
|
||||
@@ -197,8 +242,10 @@ mod tests {
|
||||
for (link, expected) in [
|
||||
("", "没有要连接的地址"),
|
||||
(" ", "没有要连接的地址"),
|
||||
("sftp://root:secret@h", "只支持 ssh://"),
|
||||
("scp://root:secret@h", "只支持 ssh:// 和 sftp://"),
|
||||
("ftp://root:secret@h", "只支持 ssh:// 和 sftp://"),
|
||||
("ssh://h:22", "没有用户名"),
|
||||
("sftp://h:22", "应为 sftp://用户@地址"),
|
||||
("ssh://:secret@h", "没有用户名"),
|
||||
("ssh://root:secret@", "没有地址"),
|
||||
("ssh://root:secret@h:0", "端口无效"),
|
||||
|
||||
+174
-68
@@ -1,11 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui_kit::{Context, EventEmitter, SharedString};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TransientSecretStore};
|
||||
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TemporarySecretStore};
|
||||
|
||||
use super::database::now_seconds;
|
||||
use super::private_key::{is_kept_in, write_key_file};
|
||||
@@ -43,16 +44,22 @@ mod snippets;
|
||||
/// the system keychain through `secrets`, on a background thread, and report
|
||||
/// failures through the same event.
|
||||
///
|
||||
/// A host opened from an `ssh://` link (a 临时连接, see [`SshLink`]) is kept
|
||||
/// apart from the saved ones: found by id like any host, so its tabs work as
|
||||
/// a saved host's do, but never listed, never written to the database and
|
||||
/// gone with its last tab. Its link's password stays in `secrets`' memory.
|
||||
/// A temporary host is one connected to without saving it, kept apart from
|
||||
/// the saved ones: found by id like any host, so its tabs work as a saved
|
||||
/// host's do, but never listed, never written to the database and gone with
|
||||
/// its last tab; its password stays in `secrets`' memory. There are two
|
||||
/// kinds: a 临时连接 typed into ShellRS's own dialog, with every feature, and
|
||||
/// an external one, which a bastion host opened with an `ssh://` or
|
||||
/// `sftp://` link (外部连接, see [`SshLink`]) and whose terminals get one
|
||||
/// channel alone.
|
||||
pub struct HostStore {
|
||||
groups: Vec<HostGroup>,
|
||||
hosts: Vec<Host>,
|
||||
/// Hosts opened from an `ssh://` link: not saved, not listed. Their ids
|
||||
/// come from `next_host_id` like a saved host's, so they never meet.
|
||||
transient: Vec<Host>,
|
||||
/// Hosts connected to without saving them: not listed. Their ids come
|
||||
/// from `next_host_id` like a saved host's, so they never meet.
|
||||
temporary: Vec<Host>,
|
||||
/// The temporary hosts a link from outside opened (外部连接).
|
||||
external: HashSet<HostId>,
|
||||
next_host_id: u64,
|
||||
next_group_id: u64,
|
||||
active: Option<HostId>,
|
||||
@@ -76,7 +83,7 @@ pub struct HostStore {
|
||||
/// Where passwords go: the keychain given to `with_secrets`, under a
|
||||
/// layer that keeps the links' passwords in memory. Defaults to a store
|
||||
/// that keeps nothing, so unit tests never touch the machine's keychain.
|
||||
secrets: Arc<TransientSecretStore>,
|
||||
secrets: Arc<TemporarySecretStore>,
|
||||
/// Where the private keys pasted into or generated by the credential
|
||||
/// form are kept. `None` without a database to remember them, which is
|
||||
/// also how most tests run: the form then offers key files only.
|
||||
@@ -108,7 +115,8 @@ impl HostStore {
|
||||
Self {
|
||||
groups: Vec::new(),
|
||||
hosts: Vec::new(),
|
||||
transient: Vec::new(),
|
||||
temporary: Vec::new(),
|
||||
external: HashSet::new(),
|
||||
next_host_id: 1,
|
||||
next_group_id: 1,
|
||||
active: None,
|
||||
@@ -123,7 +131,7 @@ impl HostStore {
|
||||
snippet_categories: Vec::new(),
|
||||
next_snippet_category_id: 1,
|
||||
database: None,
|
||||
secrets: Arc::new(TransientSecretStore::new(Arc::new(NoSecretStore))),
|
||||
secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))),
|
||||
key_dir: None,
|
||||
}
|
||||
}
|
||||
@@ -131,7 +139,7 @@ impl HostStore {
|
||||
/// Attach the system keychain. `main` does this once at startup; the UI
|
||||
/// tests attach an in-memory store instead.
|
||||
pub fn with_secrets(mut self, secrets: SharedSecretStore) -> Self {
|
||||
self.secrets = Arc::new(TransientSecretStore::new(secrets));
|
||||
self.secrets = Arc::new(TemporarySecretStore::new(secrets));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -186,12 +194,13 @@ impl HostStore {
|
||||
snippet_categories,
|
||||
groups,
|
||||
hosts,
|
||||
transient: Vec::new(),
|
||||
temporary: Vec::new(),
|
||||
external: HashSet::new(),
|
||||
active: None,
|
||||
recent,
|
||||
bookmarks,
|
||||
database: Some(database),
|
||||
secrets: Arc::new(TransientSecretStore::new(Arc::new(NoSecretStore))),
|
||||
secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))),
|
||||
key_dir: None,
|
||||
})
|
||||
}
|
||||
@@ -269,15 +278,15 @@ impl HostStore {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
/// The saved hosts. Never the ones opened from a link.
|
||||
/// The saved hosts. Never the temporary ones.
|
||||
pub fn hosts(&self) -> &[Host] {
|
||||
&self.hosts
|
||||
}
|
||||
|
||||
/// A saved host, or one opened from a link.
|
||||
/// A saved host, or a temporary one.
|
||||
pub fn host(&self, id: HostId) -> Option<&Host> {
|
||||
self.saved_host(id)
|
||||
.or_else(|| self.transient.iter().find(|host| host.id == id))
|
||||
.or_else(|| self.temporary.iter().find(|host| host.id == id))
|
||||
}
|
||||
|
||||
/// A saved host only: what the database's foreign keys may point at.
|
||||
@@ -288,56 +297,92 @@ impl HostStore {
|
||||
fn host_mut(&mut self, id: HostId) -> Option<&mut Host> {
|
||||
self.hosts
|
||||
.iter_mut()
|
||||
.chain(self.transient.iter_mut())
|
||||
.chain(self.temporary.iter_mut())
|
||||
.find(|host| host.id == id)
|
||||
}
|
||||
|
||||
/// The hosts opened from a link, oldest first. Only the external CLI
|
||||
/// lists them; nothing else that lists hosts does.
|
||||
pub fn transient_hosts(&self) -> &[Host] {
|
||||
&self.transient
|
||||
/// The temporary hosts (临时连接 and 外部连接), oldest first. Only the
|
||||
/// external CLI lists them; nothing else that lists hosts does.
|
||||
pub fn temporary_hosts(&self) -> &[Host] {
|
||||
&self.temporary
|
||||
}
|
||||
|
||||
/// Whether `id` was opened from a link and is not saved.
|
||||
pub fn is_transient(&self, id: HostId) -> bool {
|
||||
self.transient.iter().any(|host| host.id == id)
|
||||
/// Whether `id` is connected to without being saved.
|
||||
pub fn is_temporary(&self, id: HostId) -> bool {
|
||||
self.temporary.iter().any(|host| host.id == id)
|
||||
}
|
||||
|
||||
/// Connect to what an `ssh://` link names without saving it: a host the
|
||||
/// tabs find by id, kept in memory until [`Self::remove_transient`],
|
||||
/// with the link's password in memory too.
|
||||
pub fn insert_transient(&mut self, link: SshLink, cx: &mut Context<Self>) -> HostId {
|
||||
let id = self.insert_transient_unnotified(link);
|
||||
/// Whether `id` is a temporary host a link from outside opened (外部连接),
|
||||
/// whose terminals get their own channel alone.
|
||||
pub fn is_external(&self, id: HostId) -> bool {
|
||||
self.external.contains(&id)
|
||||
}
|
||||
|
||||
/// Connect to what an `ssh://` or `sftp://` link from outside names
|
||||
/// without saving it (外部连接): a temporary host the tabs find by id,
|
||||
/// kept in memory until [`Self::remove_temporary`], with the link's
|
||||
/// password in memory too.
|
||||
pub fn insert_external(&mut self, link: SshLink, cx: &mut Context<Self>) -> HostId {
|
||||
let id = self.insert_external_unnotified(link);
|
||||
cx.notify();
|
||||
id
|
||||
}
|
||||
|
||||
pub fn insert_transient_unnotified(&mut self, link: SshLink) -> HostId {
|
||||
pub fn insert_external_unnotified(&mut self, link: SshLink) -> HostId {
|
||||
let id = self.insert_temporary_unnotified(link.draft(), link.password);
|
||||
self.external.insert(id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Connect to `draft` without saving it (临时连接): a temporary host the
|
||||
/// tabs find by id, kept in memory until [`Self::remove_temporary`], with
|
||||
/// `password` in memory too. It may log in with a saved credential; its
|
||||
/// group, route and notes are not looked at, it goes directly.
|
||||
pub fn insert_temporary(
|
||||
&mut self,
|
||||
draft: HostDraft,
|
||||
password: Option<Zeroizing<String>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> HostId {
|
||||
let id = self.insert_temporary_unnotified(draft, password);
|
||||
cx.notify();
|
||||
id
|
||||
}
|
||||
|
||||
pub fn insert_temporary_unnotified(
|
||||
&mut self,
|
||||
draft: HostDraft,
|
||||
password: Option<Zeroizing<String>>,
|
||||
) -> HostId {
|
||||
let mut draft = normalized(&self.credentials, draft);
|
||||
draft.group = None;
|
||||
draft.route = Route::Direct;
|
||||
let id = HostId(self.next_host_id);
|
||||
self.next_host_id += 1;
|
||||
let mut host = Host::new(id, link.draft());
|
||||
let mut host = Host::new(id, draft);
|
||||
// What the external CLI knows it by while it lasts.
|
||||
host.public_id = self.unused_public_id();
|
||||
self.transient.push(host);
|
||||
if let Some(password) = link.password {
|
||||
self.temporary.push(host);
|
||||
if let Some(password) = password.filter(|password| !password.is_empty()) {
|
||||
self.secrets.remember(id.0, password);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Forget a host opened from a link, with its password and the
|
||||
/// bookmarks its SFTP tabs made. Its tabs are gone by now.
|
||||
pub fn remove_transient(&mut self, id: HostId, cx: &mut Context<Self>) {
|
||||
if self.remove_transient_unnotified(id) {
|
||||
/// Forget a temporary host, with its password and the bookmarks its SFTP
|
||||
/// tabs made. Its tabs are gone by now.
|
||||
pub fn remove_temporary(&mut self, id: HostId, cx: &mut Context<Self>) {
|
||||
if self.remove_temporary_unnotified(id) {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_transient_unnotified(&mut self, id: HostId) -> bool {
|
||||
let Some(index) = self.transient.iter().position(|host| host.id == id) else {
|
||||
pub fn remove_temporary_unnotified(&mut self, id: HostId) -> bool {
|
||||
let Some(index) = self.temporary.iter().position(|host| host.id == id) else {
|
||||
return false;
|
||||
};
|
||||
self.transient.remove(index);
|
||||
self.temporary.remove(index);
|
||||
self.external.remove(&id);
|
||||
self.secrets.forget(id.0);
|
||||
self.bookmarks.retain(|(host, _), _| *host != id);
|
||||
if self.active == Some(id) {
|
||||
@@ -451,7 +496,7 @@ impl HostStore {
|
||||
PublicId::generate_unused(|id| {
|
||||
self.hosts
|
||||
.iter()
|
||||
.chain(&self.transient)
|
||||
.chain(&self.temporary)
|
||||
.any(|host| &host.public_id == id)
|
||||
})
|
||||
}
|
||||
@@ -659,7 +704,7 @@ impl HostStore {
|
||||
}
|
||||
// A link's host is not in the database: its bookmarks live as long
|
||||
// as it does.
|
||||
if !self.is_transient(id) {
|
||||
if !self.is_temporary(id) {
|
||||
self.persist("添加书签", cx, |db| db.insert_bookmark(id, side, path));
|
||||
}
|
||||
cx.notify();
|
||||
@@ -688,7 +733,7 @@ impl HostStore {
|
||||
if !self.remove_bookmark_unnotified(id, side, path) {
|
||||
return false;
|
||||
}
|
||||
if !self.is_transient(id) {
|
||||
if !self.is_temporary(id) {
|
||||
self.persist("删除书签", cx, |db| db.remove_bookmark(id, side, path));
|
||||
}
|
||||
cx.notify();
|
||||
@@ -722,7 +767,7 @@ impl HostStore {
|
||||
if !self.move_bookmark_unnotified(id, side, path, to) {
|
||||
return false;
|
||||
}
|
||||
if !self.is_transient(id) {
|
||||
if !self.is_temporary(id) {
|
||||
self.persist("调整书签顺序", cx, |db| {
|
||||
db.set_bookmark_order(id, side, self.bookmarks(id, side))
|
||||
});
|
||||
@@ -878,13 +923,14 @@ impl HostStore {
|
||||
let mut login = self
|
||||
.direct_login(host)
|
||||
.with_route(self.route_login(&host.route));
|
||||
// A link's password is the link's own, never a saved host's for
|
||||
// the same `user@host:port`; and a bastion host that opened a link
|
||||
// gets the terminal alone.
|
||||
if self.is_transient(host.id) {
|
||||
login.password = SecretRef::transient(host.id.0);
|
||||
login.shell_only = true;
|
||||
// A temporary host's password is its own, never a saved host's for
|
||||
// the same `user@host:port` (a password credential's is still the
|
||||
// credential's); and a bastion host that opened a link gets the
|
||||
// terminal alone.
|
||||
if self.is_temporary(host.id) && matches!(login.password, SecretRef::Password { .. }) {
|
||||
login.password = SecretRef::temporary(host.id.0);
|
||||
}
|
||||
login.shell_only = self.is_external(host.id);
|
||||
login
|
||||
}
|
||||
|
||||
@@ -924,10 +970,12 @@ impl HostStore {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Every host's login, to tell afterwards whose changed.
|
||||
/// Every host's login, temporary ones included, to tell afterwards
|
||||
/// whose changed.
|
||||
fn logins(&self) -> Vec<(HostId, HostLogin)> {
|
||||
self.hosts
|
||||
.iter()
|
||||
.chain(&self.temporary)
|
||||
.map(|host| (host.id, self.login_of(host)))
|
||||
.collect()
|
||||
}
|
||||
@@ -1037,7 +1085,7 @@ impl HostStore {
|
||||
credential.keychain_id = keychain_id;
|
||||
credential.sort_order = sort_order;
|
||||
let user = credential.user.clone();
|
||||
for host in &mut self.hosts {
|
||||
for host in self.hosts.iter_mut().chain(&mut self.temporary) {
|
||||
if host.credential == Some(id) {
|
||||
host.user = user.clone();
|
||||
}
|
||||
@@ -1075,7 +1123,9 @@ impl HostStore {
|
||||
};
|
||||
let auth = self.credentials.remove(index).kind.without_credential();
|
||||
let mut released = Vec::new();
|
||||
for host in &mut self.hosts {
|
||||
// A temporary host using it goes on as its saved ones do, though
|
||||
// the database never hears of it.
|
||||
for host in self.hosts.iter_mut().chain(&mut self.temporary) {
|
||||
if host.credential == Some(id) {
|
||||
host.credential = None;
|
||||
host.auth = auth;
|
||||
@@ -1262,7 +1312,7 @@ impl HostStore {
|
||||
if !self.set_state_unnotified(id, state) {
|
||||
return;
|
||||
}
|
||||
if state.is_connected() && !self.is_transient(id) {
|
||||
if state.is_connected() && !self.is_temporary(id) {
|
||||
self.persist("记录最近连接", cx, |db| {
|
||||
db.touch_connected(id, now_seconds())
|
||||
});
|
||||
@@ -1274,7 +1324,7 @@ impl HostStore {
|
||||
/// saved host to the front of the recent list; a link's host never
|
||||
/// joins it.
|
||||
pub fn set_state_unnotified(&mut self, id: HostId, state: ConnectionState) -> bool {
|
||||
let transient = self.is_transient(id);
|
||||
let temporary = self.is_temporary(id);
|
||||
let Some(host) = self.host_mut(id) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1282,7 +1332,7 @@ impl HostStore {
|
||||
return false;
|
||||
}
|
||||
host.state = state;
|
||||
if state.is_connected() && !transient {
|
||||
if state.is_connected() && !temporary {
|
||||
self.recent.retain(|recent| *recent != id);
|
||||
self.recent.insert(0, id);
|
||||
self.recent.truncate(MAX_RECENT);
|
||||
@@ -1296,7 +1346,7 @@ impl HostStore {
|
||||
if !self.set_host_os_unnotified(id, os) {
|
||||
return;
|
||||
}
|
||||
if !self.is_transient(id) {
|
||||
if !self.is_temporary(id) {
|
||||
self.persist("记录主机系统", cx, |db| db.set_host_os(id, os));
|
||||
}
|
||||
cx.notify();
|
||||
@@ -1417,7 +1467,7 @@ impl HostStore {
|
||||
(SecretRef::Proxy { .. }, true) => "代理的密码未能写入系统钥匙串",
|
||||
(SecretRef::Proxy { .. }, false) => "代理的密码未能从系统钥匙串删除",
|
||||
// Kept in memory, which does not fail.
|
||||
(SecretRef::Transient { .. }, _) => "临时连接的密码未能记下",
|
||||
(SecretRef::Temporary { .. }, _) => "这次连接的密码未能记下",
|
||||
};
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
@@ -2566,15 +2616,70 @@ mod tests {
|
||||
SshLink::parse(text, Some("堡垒机")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_temporary_connection_goes_directly_and_may_use_a_credential() {
|
||||
let mut store = HostStore::empty();
|
||||
let saved = store.insert_unnotified(draft("web", None));
|
||||
let ops = store.insert_credential_unnotified(CredentialDraft::new(
|
||||
"运维",
|
||||
CredentialKind::Password,
|
||||
"ops",
|
||||
));
|
||||
let deploy = store.insert_credential_unnotified(
|
||||
CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path("/tmp/id"),
|
||||
);
|
||||
// A group and a route are a saved host's, and are not looked at.
|
||||
let typed = store.insert_temporary_unnotified(
|
||||
draft("typed", Some(GroupId(9))).with_route(Route::Jump(vec![Some(saved)])),
|
||||
Some(Zeroizing::new("p@ss".into())),
|
||||
);
|
||||
let with_ops =
|
||||
store.insert_temporary_unnotified(draft("with-ops", None).with_credential(ops), None);
|
||||
let with_key = store
|
||||
.insert_temporary_unnotified(draft("with-key", None).with_credential(deploy), None);
|
||||
assert_eq!(store.hosts().len(), 1);
|
||||
let host = store.host(typed).unwrap();
|
||||
assert_eq!((host.group, &host.route), (None, &Route::Direct));
|
||||
assert!(!store.is_external(typed));
|
||||
|
||||
// Its own password is in memory; a password credential's is the
|
||||
// credential's, as for a saved host.
|
||||
let login = store.login(typed).unwrap();
|
||||
assert_eq!(login.password, SecretRef::temporary(typed.0));
|
||||
assert!(!login.shell_only);
|
||||
let login = store.login(with_ops).unwrap();
|
||||
assert_eq!(login.user, "ops");
|
||||
assert_eq!(
|
||||
login.password,
|
||||
store.credential(ops).unwrap().password_secret()
|
||||
);
|
||||
assert_eq!(
|
||||
store.login(with_key).unwrap().password,
|
||||
SecretRef::temporary(with_key.0)
|
||||
);
|
||||
|
||||
// It follows the credential, and logs in on its own once that goes.
|
||||
store.update_credential_unnotified(
|
||||
ops,
|
||||
CredentialDraft::new("运维", CredentialKind::Password, "admin"),
|
||||
);
|
||||
assert_eq!(store.host(with_ops).unwrap().user.as_ref(), "admin");
|
||||
assert_eq!(store.remove_credential_unnotified(deploy), [with_key]);
|
||||
let host = store.host(with_key).unwrap();
|
||||
assert_eq!((host.credential, host.user.as_ref()), (None, "deploy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_host_is_found_by_id_but_never_listed() {
|
||||
let keychain = Arc::new(crate::secrets::InMemorySecretStore::default());
|
||||
let mut store = HostStore::empty().with_secrets(keychain.clone());
|
||||
let saved = store.insert_unnotified(draft("web", None));
|
||||
let id = store.insert_transient_unnotified(link("ssh://token:p%40ss@10.0.0.9:2222"));
|
||||
let id = store.insert_external_unnotified(link("ssh://token:p%40ss@10.0.0.9:2222"));
|
||||
assert_ne!(id, saved);
|
||||
assert!(store.is_transient(id));
|
||||
assert!(!store.is_transient(saved));
|
||||
assert!(store.is_temporary(id));
|
||||
assert!(store.is_external(id));
|
||||
assert!(!store.is_temporary(saved));
|
||||
assert!(!store.is_external(saved));
|
||||
assert_eq!(store.hosts().len(), 1);
|
||||
assert_eq!(store.host(id).unwrap().name.as_ref(), "堡垒机");
|
||||
|
||||
@@ -2586,7 +2691,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(login.method, LoginMethod::Password);
|
||||
assert_eq!(login.route, LoginRoute::Direct);
|
||||
assert_eq!(login.password, SecretRef::transient(id.0));
|
||||
assert_eq!(login.password, SecretRef::temporary(id.0));
|
||||
assert!(login.shell_only);
|
||||
assert!(!store.login(saved).unwrap().shell_only);
|
||||
assert_eq!(
|
||||
@@ -2608,9 +2713,10 @@ mod tests {
|
||||
assert!(store.add_bookmark_unnotified(id, BookmarkSide::Remote, "/tmp"));
|
||||
store.active = Some(id);
|
||||
|
||||
assert!(store.remove_transient_unnotified(id));
|
||||
assert!(!store.remove_transient_unnotified(id));
|
||||
assert!(store.remove_temporary_unnotified(id));
|
||||
assert!(!store.remove_temporary_unnotified(id));
|
||||
assert!(store.host(id).is_none());
|
||||
assert!(!store.is_external(id));
|
||||
assert!(store.active().is_none());
|
||||
assert!(store.bookmarks(id, BookmarkSide::Remote).is_empty());
|
||||
assert!(store.secrets().get(&login.password).unwrap().is_none());
|
||||
@@ -2621,7 +2727,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_saved_host_cannot_lean_on_a_link_host() {
|
||||
let mut store = HostStore::empty();
|
||||
let id = store.insert_transient_unnotified(link("ssh://token@10.0.0.9"));
|
||||
let id = store.insert_external_unnotified(link("ssh://token@10.0.0.9"));
|
||||
let behind = store.insert_unnotified(
|
||||
HostDraft::new("db", "10.0.0.8", 22, "root", AuthKind::Password, None)
|
||||
.with_route(jump_through(&[id])),
|
||||
@@ -2655,7 +2761,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let id = store.update(cx, |store, cx| {
|
||||
let id = store.insert_transient(link("ssh://token@10.0.0.9"), cx);
|
||||
let id = store.insert_external(link("ssh://token@10.0.0.9"), cx);
|
||||
store.set_state(id, ConnectionState::Connected, cx);
|
||||
store.set_host_os(id, Some(HostOs::Debian), cx);
|
||||
store.add_bookmark(id, BookmarkSide::Remote, "/var/log", cx);
|
||||
|
||||
+9
-10
@@ -5,7 +5,7 @@
|
||||
|
||||
mod keychain;
|
||||
mod memory;
|
||||
mod transient;
|
||||
mod temporary;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -15,7 +15,7 @@ use zeroize::Zeroizing;
|
||||
|
||||
pub use keychain::KeychainSecretStore;
|
||||
pub use memory::{InMemorySecretStore, NoSecretStore};
|
||||
pub use transient::TransientSecretStore;
|
||||
pub use temporary::TemporarySecretStore;
|
||||
|
||||
/// 钥匙串里的服务名,所有条目共用。改了会让已保存的秘密全部失联。
|
||||
pub const SERVICE: &str = "shellrs";
|
||||
@@ -44,10 +44,9 @@ pub enum SecretRef {
|
||||
host: String,
|
||||
port: u16,
|
||||
},
|
||||
/// 临时连接(堡垒机用 `ssh://` 链接打开的、不保存的主机)链接里带的密码,
|
||||
/// 按这台临时主机的 id 归属。只在 [`TransientSecretStore`] 的内存里,
|
||||
/// 从不进钥匙串。
|
||||
Transient { host: u64 },
|
||||
/// 不保存的连接(外部连接,以后还有临时连接)的密码,按这台临时主机的 id
|
||||
/// 归属。只在 [`TemporarySecretStore`] 的内存里,从不进钥匙串。
|
||||
Temporary { host: u64 },
|
||||
}
|
||||
|
||||
impl SecretRef {
|
||||
@@ -79,8 +78,8 @@ impl SecretRef {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transient(host: u64) -> Self {
|
||||
Self::Transient { host }
|
||||
pub fn temporary(host: u64) -> Self {
|
||||
Self::Temporary { host }
|
||||
}
|
||||
|
||||
/// 钥匙串条目的账户名。前缀区分种类,在 Keychain Access 里直接可读。
|
||||
@@ -90,7 +89,7 @@ impl SecretRef {
|
||||
Self::Passphrase { key_path } => format!("passphrase:{key_path}"),
|
||||
Self::Credential { keychain_id } => format!("credential:{keychain_id}"),
|
||||
Self::Proxy { user, host, port } => format!("proxy:{user}@{host}:{port}"),
|
||||
Self::Transient { host } => format!("transient:{host}"),
|
||||
Self::Temporary { host } => format!("temporary:{host}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +156,7 @@ mod tests {
|
||||
SecretRef::passphrase("root@10.0.1.12:22"),
|
||||
SecretRef::credential("root@10.0.1.12:22"),
|
||||
SecretRef::proxy("root", "10.0.1.12", 22),
|
||||
SecretRef::transient(22),
|
||||
SecretRef::temporary(22),
|
||||
]
|
||||
.map(|secret| secret.account());
|
||||
for (ix, account) in accounts.iter().enumerate() {
|
||||
|
||||
@@ -6,17 +6,17 @@ use zeroize::Zeroizing;
|
||||
|
||||
use super::{SecretRef, SecretStore, SharedSecretStore};
|
||||
|
||||
/// 钥匙串外面包的一层:临时连接的密码([`SecretRef::Transient`])只记在这层
|
||||
/// 内存里,其余的原样交给里面的钥匙串。
|
||||
/// 钥匙串外面包的一层:不保存的连接(外部连接)的密码([`SecretRef::Temporary`])
|
||||
/// 只记在这层内存里,其余的原样交给里面的钥匙串。
|
||||
///
|
||||
/// `HostStore` 把注入的秘密存储包成这样,连接器读到的也是它,所以终端和 SFTP
|
||||
/// 用得上链接里的密码,而这个密码不会落进钥匙串;没有钥匙串的机器上照样能用。
|
||||
pub struct TransientSecretStore {
|
||||
pub struct TemporarySecretStore {
|
||||
keychain: SharedSecretStore,
|
||||
passwords: Mutex<HashMap<u64, Zeroizing<String>>>,
|
||||
}
|
||||
|
||||
impl TransientSecretStore {
|
||||
impl TemporarySecretStore {
|
||||
pub fn new(keychain: SharedSecretStore) -> Self {
|
||||
Self {
|
||||
keychain,
|
||||
@@ -41,17 +41,17 @@ impl TransientSecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStore for TransientSecretStore {
|
||||
impl SecretStore for TemporarySecretStore {
|
||||
fn get(&self, secret: &SecretRef) -> Result<Option<Zeroizing<String>>> {
|
||||
match secret {
|
||||
SecretRef::Transient { host } => Ok(self.passwords().get(host).cloned()),
|
||||
SecretRef::Temporary { host } => Ok(self.passwords().get(host).cloned()),
|
||||
_ => self.keychain.get(secret),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, secret: &SecretRef, value: &str) -> Result<()> {
|
||||
match secret {
|
||||
SecretRef::Transient { host } => {
|
||||
SecretRef::Temporary { host } => {
|
||||
self.remember(*host, Zeroizing::new(value.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
@@ -61,7 +61,7 @@ impl SecretStore for TransientSecretStore {
|
||||
|
||||
fn delete(&self, secret: &SecretRef) -> Result<()> {
|
||||
match secret {
|
||||
SecretRef::Transient { host } => {
|
||||
SecretRef::Temporary { host } => {
|
||||
self.forget(*host);
|
||||
Ok(())
|
||||
}
|
||||
@@ -84,8 +84,8 @@ mod tests {
|
||||
#[test]
|
||||
fn link_passwords_stay_in_memory_and_the_rest_reaches_the_keychain() {
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
let store = TransientSecretStore::new(keychain.clone());
|
||||
let link = SecretRef::transient(7);
|
||||
let store = TemporarySecretStore::new(keychain.clone());
|
||||
let link = SecretRef::temporary(7);
|
||||
store.remember(7, Zeroizing::new("token".into()));
|
||||
assert_eq!(
|
||||
store.get(&link).unwrap().as_deref().map(String::as_str),
|
||||
@@ -101,7 +101,7 @@ mod tests {
|
||||
Some("hunter2")
|
||||
);
|
||||
// Another host's password is not the link's.
|
||||
assert!(store.get(&SecretRef::transient(8)).unwrap().is_none());
|
||||
assert!(store.get(&SecretRef::temporary(8)).unwrap().is_none());
|
||||
|
||||
store.forget(7);
|
||||
assert!(store.get(&link).unwrap().is_none());
|
||||
@@ -110,10 +110,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn without_a_keychain_link_passwords_still_work() {
|
||||
let store = TransientSecretStore::new(Arc::new(NoSecretStore));
|
||||
let store = TemporarySecretStore::new(Arc::new(NoSecretStore));
|
||||
assert!(!store.is_available());
|
||||
store.set(&SecretRef::transient(1), "token").unwrap();
|
||||
assert!(store.get(&SecretRef::transient(1)).unwrap().is_some());
|
||||
store.set(&SecretRef::temporary(1), "token").unwrap();
|
||||
assert!(store.get(&SecretRef::temporary(1)).unwrap().is_some());
|
||||
assert!(
|
||||
store
|
||||
.set(&SecretRef::password("root", "h", 22), "x")
|
||||
+70
-11
@@ -2,8 +2,8 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret, Latency},
|
||||
host::{AuthKind, Host, HostDraft, HostId, HostLogin},
|
||||
secrets::InMemorySecretStore,
|
||||
host::{AuthKind, Host, HostDraft, HostId, HostLogin, HostStore, SshLink},
|
||||
secrets::{InMemorySecretStore, SharedSecretStore},
|
||||
ssh::SshConnector,
|
||||
};
|
||||
use russh::{
|
||||
@@ -61,8 +61,19 @@ impl Drop for Handler {
|
||||
self.ended.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
/// Who the server lets in without asking anything: a bastion host's token,
|
||||
/// which is the user name alone.
|
||||
const TOKEN_USER: &str = "b478e26f-811b-4a90-81c3-74929127898a";
|
||||
|
||||
impl server::Handler for Handler {
|
||||
type Error = anyhow::Error;
|
||||
async fn auth_none(&mut self, user: &str) -> Result<server::Auth, Self::Error> {
|
||||
Ok(if user == TOKEN_USER {
|
||||
server::Auth::Accept
|
||||
} else {
|
||||
server::Auth::reject()
|
||||
})
|
||||
}
|
||||
async fn auth_password(
|
||||
&mut self,
|
||||
user: &str,
|
||||
@@ -244,14 +255,6 @@ impl Drop for Worker {
|
||||
}
|
||||
}
|
||||
fn worker(port: u16, data: &std::path::Path) -> Worker {
|
||||
let provider = SshSftpTransportProvider::new(
|
||||
SshConnector::new(
|
||||
data.join("known_hosts"),
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
),
|
||||
data.join("upload-resume"),
|
||||
data.join("download-resume"),
|
||||
);
|
||||
let host = Host::new(
|
||||
HostId(1),
|
||||
HostDraft::new(
|
||||
@@ -263,7 +266,19 @@ fn worker(port: u16, data: &std::path::Path) -> Worker {
|
||||
None,
|
||||
),
|
||||
);
|
||||
let transport = provider.create(&HostLogin::of(&host, None));
|
||||
worker_for(
|
||||
&HostLogin::of(&host, None),
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
data,
|
||||
)
|
||||
}
|
||||
fn worker_for(login: &HostLogin, secrets: SharedSecretStore, data: &std::path::Path) -> Worker {
|
||||
let provider = SshSftpTransportProvider::new(
|
||||
SshConnector::new(data.join("known_hosts"), secrets),
|
||||
data.join("upload-resume"),
|
||||
data.join("download-resume"),
|
||||
);
|
||||
let transport = provider.create(login);
|
||||
let (commands, rx) = async_channel::unbounded();
|
||||
let (tx, events) = async_channel::unbounded();
|
||||
let thread = std::thread::spawn(move || transport.run(rx, tx).unwrap());
|
||||
@@ -438,6 +453,50 @@ fn a_connection_reports_its_round_trip_right_after_connecting() {
|
||||
});
|
||||
}
|
||||
|
||||
/// A bastion host lets WinSCP, and so ShellRS, in on its token alone: an
|
||||
/// `sftp://token@…` link without a password connects without asking for
|
||||
/// one, as Xshell's `ssh://` does.
|
||||
#[test]
|
||||
fn a_link_without_a_password_connects_where_the_server_asks_for_none() {
|
||||
runtime().block_on(async {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let Some(server) = server(temp.path().into(), 0, false).await else {
|
||||
return;
|
||||
};
|
||||
let mut store = HostStore::empty();
|
||||
let link = format!("sftp://{TOKEN_USER}@127.0.0.1:{}", server.port);
|
||||
let host = store.insert_external_unnotified(SshLink::parse(&link, None).unwrap());
|
||||
let worker = worker_for(&store.login(host).unwrap(), store.secrets(), temp.path());
|
||||
loop {
|
||||
let event = tokio::time::timeout(Duration::from_secs(25), worker.events.recv())
|
||||
.await
|
||||
.expect("worker timed out")
|
||||
.unwrap();
|
||||
match event {
|
||||
SftpEvent::Prompt(prompt) => {
|
||||
assert!(
|
||||
matches!(prompt.kind(), ConnectionPromptKind::UnknownHost(_)),
|
||||
"asked for more than trusting the host key"
|
||||
);
|
||||
worker
|
||||
.commands
|
||||
.send(SftpCommand::PromptReply {
|
||||
request_id: prompt.request_id(),
|
||||
reply: ConnectionPromptReply::TrustAndSave,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
SftpEvent::Connected { .. } => break,
|
||||
SftpEvent::Disconnected(error) => panic!("connect failed: {error}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(server.connections.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(server.shells.load(Ordering::SeqCst), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_sftp_does_not_launch_shell_or_loop_authentication() {
|
||||
runtime().block_on(async {
|
||||
|
||||
+15
-1
@@ -506,6 +506,20 @@ impl std::fmt::Display for MissingCredential {
|
||||
|
||||
impl std::error::Error for MissingCredential {}
|
||||
|
||||
/// A login without a password (「无密码」) that the server wanted a password
|
||||
/// from. Typed, so that a connection test with the password left empty can
|
||||
/// say that rather than point at 「无密码」.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PasswordWanted;
|
||||
|
||||
impl std::fmt::Display for PasswordWanted {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PasswordWanted {}
|
||||
|
||||
impl SshPrompts {
|
||||
pub fn new(
|
||||
events: Arc<dyn Fn(ConnectionPrompt) -> bool + Send + Sync>,
|
||||
@@ -902,7 +916,7 @@ async fn authenticate(
|
||||
if methods.contains(&MethodKind::Password)
|
||||
|| methods.contains(&MethodKind::KeyboardInteractive)
|
||||
{
|
||||
bail!("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」");
|
||||
return Err(PasswordWanted.into());
|
||||
}
|
||||
bail!("服务器未接受 SSH Agent 和 ~/.ssh 中的任何私钥");
|
||||
}
|
||||
|
||||
+22
-4
@@ -9,15 +9,15 @@ use tokio::sync::watch;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::connection::{
|
||||
MissingCredential, RouteFailure, SshConnectionConfig, SshConnector, SshPrompts, lock,
|
||||
timeout_excluding_prompts,
|
||||
MissingCredential, PasswordWanted, RouteFailure, SshConnectionConfig, SshConnector, SshPrompts,
|
||||
lock, timeout_excluding_prompts,
|
||||
};
|
||||
use crate::{
|
||||
connection::{
|
||||
ConnectionPrompt, ConnectionPromptKind, ConnectionPromptReply, ConnectionTester, LoginTest,
|
||||
TrustCallback,
|
||||
},
|
||||
host::{JumpLogin, LoginRoute},
|
||||
host::{JumpLogin, LoginMethod, LoginRoute},
|
||||
secrets::{SecretRef, SecretStore, SharedSecretStore},
|
||||
};
|
||||
|
||||
@@ -75,7 +75,16 @@ impl SshConnectionTester {
|
||||
return Err(format!("无法解析主机 {host}"));
|
||||
}
|
||||
|
||||
let config = SshConnectionConfig::from(request.login());
|
||||
// No password typed: go without one, as 「无密码」 does (the server's
|
||||
// leave, the SSH agent, the default keys), and fail if that fails.
|
||||
let without_password = request.password_left_empty();
|
||||
let config = if without_password {
|
||||
let mut login = request.login().clone();
|
||||
login.method = LoginMethod::NoPassword;
|
||||
SshConnectionConfig::from(&login)
|
||||
} else {
|
||||
SshConnectionConfig::from(request.login())
|
||||
};
|
||||
let secrets: SharedSecretStore =
|
||||
Arc::new(FormSecrets::new(&request, self.connector.secrets().clone()));
|
||||
let host_trust = Arc::new(Mutex::new(HostTrust::default()));
|
||||
@@ -139,6 +148,15 @@ impl SshConnectionTester {
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
// The server wanted the password that was left empty: that, not
|
||||
// 「无密码」, is what to tell someone who chose 「密码」.
|
||||
Err(error)
|
||||
if without_password
|
||||
&& error.downcast_ref::<RouteFailure>().is_none()
|
||||
&& error.chain().any(|cause| cause.is::<PasswordWanted>()) =>
|
||||
{
|
||||
Err(MissingCredential::Password { rejected: false }.to_string())
|
||||
}
|
||||
Err(error) => Err(describe_test_failure(&error, *lock(&host_trust))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1348,6 +1348,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试连接 with the password left empty goes without one, as 「无密码」
|
||||
/// does: the agent gets its turn, and a server that wants a password
|
||||
/// hears that none was typed.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_test_with_the_password_left_empty_goes_without_one() {
|
||||
let key = random_key();
|
||||
let Some(server) = start_server(TestAuth::PublicKey(key.public_key().clone())) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let Some(password_server) = start_server(TestAuth::Password) else {
|
||||
panic!("loopback became unavailable during the test")
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let agent = start_agent(&[key]);
|
||||
let connector = || {
|
||||
SshConnector::new(
|
||||
directory.path().join("known_hosts"),
|
||||
Arc::new(NoSecretStore),
|
||||
)
|
||||
.with_agent(crate::ssh::AgentLocation::At(agent.path.clone()))
|
||||
};
|
||||
let by_password = |port| HostLogin::manual("127.0.0.1", port, "tester", AuthKind::Password);
|
||||
assert_eq!(
|
||||
test_through(
|
||||
connector(),
|
||||
crate::connection::LoginTest::typed(by_password(server.port))
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
test_through(
|
||||
connector(),
|
||||
crate::connection::LoginTest::typed(by_password(password_server.port))
|
||||
),
|
||||
Err("未填写密码".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_login_without_a_password_refuses_a_server_that_wants_one() {
|
||||
let Some(server) = start_server(TestAuth::Password) else {
|
||||
|
||||
@@ -129,7 +129,7 @@ impl TerminalPanel {
|
||||
host_is_ip: store
|
||||
.host(self.host_id)
|
||||
.is_some_and(|host| host.address_is_ip()),
|
||||
editable: !store.is_transient(self.host_id),
|
||||
editable: !store.is_temporary(self.host_id),
|
||||
group: self.tab_group.clone(),
|
||||
panel: cx.entity_id(),
|
||||
}
|
||||
@@ -317,7 +317,7 @@ struct TabMenu {
|
||||
id: RemoteTerminalId,
|
||||
host_id: HostId,
|
||||
host_is_ip: bool,
|
||||
/// A saved host; one opened from a link has nothing to edit.
|
||||
/// A saved host; a temporary one has nothing to edit.
|
||||
editable: bool,
|
||||
group: Option<WeakEntity<TabGroup>>,
|
||||
panel: EntityId,
|
||||
|
||||
+29
-16
@@ -1,46 +1,59 @@
|
||||
//! The workspace's share of the links ShellRS is opened with (临时连接):
|
||||
//! The workspace's share of the links ShellRS is opened with (外部连接):
|
||||
//! a bastion host runs `ShellRS ssh://user@host:port`, the way it runs
|
||||
//! Xshell, and ShellRS connects without saving the host. The host goes with
|
||||
//! its last tab.
|
||||
//! Xshell, or `ShellRS sftp://…`, the way it runs WinSCP, and ShellRS
|
||||
//! connects without saving the host. The host goes with its last tab.
|
||||
|
||||
use gpui_kit::component::{WindowExt as _, notification::Notification};
|
||||
use gpui_kit::component::{WindowExt as _, dock::DockPlacement, notification::Notification};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::cli::OpenLink;
|
||||
use crate::host::{HostId, SshLink};
|
||||
use crate::host::{HostId, LinkKind, SshLink};
|
||||
|
||||
use super::workspace_view::Workspace;
|
||||
|
||||
impl Workspace {
|
||||
/// Connect to what `link` names in a terminal tab of its own, or say
|
||||
/// what is wrong with it.
|
||||
/// Connect to what `link` names in a tab of its own across the window,
|
||||
/// a terminal or an SFTP tab by the link's scheme, or say what is wrong
|
||||
/// with it.
|
||||
pub fn open_link(&mut self, link: OpenLink, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match SshLink::parse(&link.url, link.tab.as_deref()) {
|
||||
Ok(link) => {
|
||||
let kind = link.kind;
|
||||
let host = self
|
||||
.store
|
||||
.update(cx, |store, cx| store.insert_transient(link, cx));
|
||||
self.connect_host(host, window, cx);
|
||||
.update(cx, |store, cx| store.insert_external(link, cx));
|
||||
match kind {
|
||||
LinkKind::Ssh => self.connect_host(host, window, cx),
|
||||
LinkKind::Sftp => self.open_explorer(host, window, cx),
|
||||
}
|
||||
// The tab is what the bastion host opened ShellRS for: the
|
||||
// hosts make way, as with ⌘B, which brings them back.
|
||||
if self.dock_area.read(cx).is_dock_open(DockPlacement::Left) {
|
||||
self.dock_area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Left, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(problem) => {
|
||||
window.push_notification(Notification::error(problem).title("无法打开链接"), cx)
|
||||
}
|
||||
Err(problem) => window
|
||||
.push_notification(Notification::error(problem).title("无法打开 SSH 链接"), cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a host opened from a link once its last tab has closed,
|
||||
/// with whatever it was still asking.
|
||||
pub(super) fn forget_unused_link_host(
|
||||
/// Forget a temporary host once its last tab has closed, with whatever
|
||||
/// it was still asking.
|
||||
pub(super) fn forget_unused_temporary_host(
|
||||
&mut self,
|
||||
host: HostId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.store.read(cx).is_transient(host) || self.has_tabs(host, cx) {
|
||||
if !self.store.read(cx).is_temporary(host) || self.has_tabs(host, cx) {
|
||||
return;
|
||||
}
|
||||
self.cancel_prompts_for_host(host, window, cx);
|
||||
self.store
|
||||
.update(cx, |store, cx| store.remove_transient(host, cx));
|
||||
.update(cx, |store, cx| store.remove_temporary(host, cx));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, Icon, IconName, Sizable as _, button::Button, h_flex, list::ListItem,
|
||||
menu::ContextMenuExt as _, scroll::ScrollableElement as _, v_flex,
|
||||
ActiveTheme as _, Icon, IconName, Sizable as _,
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
list::ListItem,
|
||||
menu::ContextMenuExt as _,
|
||||
scroll::ScrollableElement as _,
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{CatalogIcon, ConnectHost, ConnectSelected, NewHost, RECENT_HOSTS_CONTEXT};
|
||||
use crate::app::{
|
||||
CatalogIcon, ConnectHost, ConnectSelected, NewHost, QuickConnect, RECENT_HOSTS_CONTEXT,
|
||||
};
|
||||
use crate::host::{HostId, HostOs, HostStore, host_menu};
|
||||
use crate::shared::HostMark;
|
||||
|
||||
@@ -103,10 +110,30 @@ impl RecentHosts {
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("recent-new-host")
|
||||
.icon(IconName::Plus)
|
||||
.label("新建主机…")
|
||||
.on_click(move |_, window, cx| target.dispatch_action(&NewHost, window, cx)),
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("recent-quick-connect")
|
||||
.primary()
|
||||
.small()
|
||||
.icon(IconName::Search)
|
||||
.label("快速连接")
|
||||
.on_click({
|
||||
let target = target.clone();
|
||||
move |_, window, cx| {
|
||||
target.dispatch_action(&QuickConnect, window, cx)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("recent-new-host")
|
||||
.small()
|
||||
.icon(IconName::Plus)
|
||||
.label("新建主机…")
|
||||
.on_click(move |_, window, cx| {
|
||||
target.dispatch_action(&NewHost, window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CatalogIcon, NewHost, NewLocalTerminal, ShowCredentials, ShowForwards, ShowHosts, ShowUpdate,
|
||||
ToggleHostPanel, ToggleTheme,
|
||||
CatalogIcon, NewLocalTerminal, NewTemporaryConnection, ShowCredentials, ShowForwards,
|
||||
ShowHosts, ShowUpdate, ToggleHostPanel, ToggleTheme,
|
||||
};
|
||||
use crate::shared::tinted;
|
||||
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_host, new_local, theme, show_update) = (
|
||||
let (mode, toggle, temporary, new_local, theme, show_update) = (
|
||||
target.clone(),
|
||||
target.clone(),
|
||||
target.clone(),
|
||||
@@ -121,13 +121,16 @@ pub fn render_title_bar(
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("new-host")
|
||||
// Saved hosts are made in the host list; up here is the
|
||||
// quick way in, to a host that is not saved.
|
||||
Button::new("temporary-connection")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(IconName::Plus)
|
||||
.label("新建主机…")
|
||||
.icon(CatalogIcon::Plug)
|
||||
.label("临时连接…")
|
||||
.tooltip("连接一台主机,不保存")
|
||||
.on_click(move |_, window, cx| {
|
||||
new_host.dispatch_action(&NewHost, window, cx)
|
||||
temporary.dispatch_action(&NewTemporaryConnection, window, cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
|
||||
@@ -56,15 +56,15 @@ impl Workspace {
|
||||
}
|
||||
|
||||
/// The tools the switch offers for the SSH terminal in front; none
|
||||
/// without one. A terminal a bastion host opened with a link gets only
|
||||
/// those that run nothing beside it.
|
||||
/// without one. An external connection (a bastion host's link) gets only
|
||||
/// those that run nothing beside the terminal.
|
||||
pub(super) fn offered_tools(&self, cx: &App) -> Vec<ToolKind> {
|
||||
let Some(terminal) = self.tool_terminal(cx) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let store = self.store.read(cx);
|
||||
let os = store.host(terminal.host).and_then(|host| host.os);
|
||||
let shell_only = store.is_transient(terminal.host);
|
||||
let shell_only = store.is_external(terminal.host);
|
||||
ToolKind::ALL
|
||||
.into_iter()
|
||||
.filter(|tool| tool.works_on(os) && !(shell_only && tool.runs_commands()))
|
||||
|
||||
@@ -26,10 +26,11 @@ use crate::app::{
|
||||
DisconnectTerminal, DismissTerminalFind, DuplicateHost, EditHost, ExpandAllGroups,
|
||||
ExplorerAction, ExplorerCommand, ExplorerShortcut, FindInTerminal, FindNextInTerminal,
|
||||
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,
|
||||
NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal, NewTemporaryConnection,
|
||||
OpenExplorer, OpenSettings, PasteTerminal, QuickConnect, 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::{
|
||||
@@ -45,6 +46,7 @@ use crate::forward::{
|
||||
use crate::host::{
|
||||
ConnectionState, Dependents, ForwardId, GroupId, HostId, HostNode, HostPanel, HostStore,
|
||||
HostStoreEvent, confirm_delete_group, confirm_delete_host, open_group_dialog, open_host_dialog,
|
||||
open_quick_connect_dialog, open_temporary_connection_dialog,
|
||||
};
|
||||
use crate::settings::{
|
||||
Appearance, SettingsPanel, SettingsPanelEvent, SettingsStore, SettingsStoreEvent,
|
||||
@@ -1039,7 +1041,15 @@ impl Workspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let host_id = action.0;
|
||||
self.open_explorer(action.0, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn open_explorer(
|
||||
&mut self,
|
||||
host_id: HostId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.store.read(cx).host(host_id).is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -1674,6 +1684,25 @@ impl Workspace {
|
||||
);
|
||||
}
|
||||
|
||||
fn on_new_temporary_connection(
|
||||
&mut self,
|
||||
_: &NewTemporaryConnection,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_temporary_connection_dialog(
|
||||
self.store.clone(),
|
||||
self.connection_tester.clone(),
|
||||
self.focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn on_quick_connect(&mut self, _: &QuickConnect, window: &mut Window, cx: &mut Context<Self>) {
|
||||
open_quick_connect_dialog(self.store.clone(), self.focus_handle.clone(), window, cx);
|
||||
}
|
||||
|
||||
fn on_edit_host(&mut self, action: &EditHost, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// A host opened from a link is not saved, so there is nothing to
|
||||
// edit.
|
||||
@@ -2000,7 +2029,7 @@ fn new_terminal_panel(
|
||||
this.store
|
||||
.update(cx, |store, cx| store.set_active(None, cx));
|
||||
}
|
||||
this.forget_unused_link_host(*host_id, window, cx);
|
||||
this.forget_unused_temporary_host(*host_id, window, cx);
|
||||
}
|
||||
TerminalPanelEvent::StatusChanged(_, host_id) => {
|
||||
this.refresh_host_connection_state(*host_id, cx);
|
||||
@@ -2110,7 +2139,7 @@ fn new_explorer_panel(
|
||||
this.set_active_tab(None, window, cx);
|
||||
}
|
||||
this.refresh_host_connection_state(*host_id, cx);
|
||||
this.forget_unused_link_host(*host_id, window, cx);
|
||||
this.forget_unused_temporary_host(*host_id, window, cx);
|
||||
}
|
||||
ExplorerPanelEvent::StateChanged(id, host_id) => {
|
||||
if let Some(panel) = this.explorers.get(id) {
|
||||
@@ -2207,6 +2236,8 @@ impl Render for Workspace {
|
||||
.key_context("Workspace")
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_new_host))
|
||||
.on_action(cx.listener(Self::on_new_temporary_connection))
|
||||
.on_action(cx.listener(Self::on_quick_connect))
|
||||
.on_action(cx.listener(Self::on_new_local_terminal))
|
||||
.on_action(cx.listener(Self::on_edit_host))
|
||||
.on_action(cx.listener(Self::on_duplicate_host))
|
||||
|
||||
@@ -194,6 +194,12 @@ fn shellrs_opened_with_a_link_hands_it_to_the_running_app() {
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(open_shellrs(data_dir.path(), &[token]), Some(0));
|
||||
// As for WinSCP.
|
||||
let winscp = "sftp://deploy:p%40ss@10.0.0.9:2222";
|
||||
assert_eq!(
|
||||
open_shellrs(data_dir.path(), &["/sessionname=文件", winscp]),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
server.take_activation(),
|
||||
Some(vec![
|
||||
@@ -205,6 +211,10 @@ fn shellrs_opened_with_a_link_hands_it_to_the_running_app() {
|
||||
url: token.into(),
|
||||
tab: None,
|
||||
},
|
||||
OpenLink {
|
||||
url: winscp.into(),
|
||||
tab: Some("文件".into()),
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
+189
-5
@@ -616,15 +616,17 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut
|
||||
);
|
||||
assert_eq!(login.method, LoginMethod::Password);
|
||||
assert_eq!(login.route, LoginRoute::Direct);
|
||||
assert_eq!(login.password, SecretRef::transient(LINK_HOST.0));
|
||||
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert_eq!(
|
||||
window.find(("terminal-tab", LINK_TERMINAL)).label(),
|
||||
Some("db-prod")
|
||||
);
|
||||
// Not saved: no row in the tree.
|
||||
// Not saved, and across the window: the hosts made way.
|
||||
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
|
||||
assert_eq!(window.find("show-hosts").checked(), Some(false));
|
||||
assert!(window.try_find("host-search").is_none());
|
||||
// The bastion host gets the terminal alone: of the tools, only the
|
||||
// snippets, which type into it.
|
||||
assert!(window.try_find("tool-snippets").is_some());
|
||||
@@ -636,14 +638,14 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut
|
||||
assert!(login.shell_only);
|
||||
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
|
||||
store.read_with(cx, |store, _| {
|
||||
assert!(store.is_transient(LINK_HOST));
|
||||
assert!(store.is_temporary(LINK_HOST));
|
||||
assert!(store.hosts().is_empty());
|
||||
assert!(store.recent_hosts().next().is_none());
|
||||
// The link's password is in memory, not in the keychain.
|
||||
assert_eq!(
|
||||
store
|
||||
.secrets()
|
||||
.get(&SecretRef::transient(LINK_HOST.0))
|
||||
.get(&SecretRef::temporary(LINK_HOST.0))
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
.map(String::as_str),
|
||||
@@ -697,7 +699,7 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut
|
||||
assert!(
|
||||
store
|
||||
.secrets()
|
||||
.get(&SecretRef::transient(LINK_HOST.0))
|
||||
.get(&SecretRef::temporary(LINK_HOST.0))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -709,6 +711,186 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut
|
||||
assert!(saved.bookmarks(LINK_HOST, BookmarkSide::Remote).is_empty());
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn an_sftp_link_opens_an_sftp_tab_across_the_window(cx: &mut TestAppContext) {
|
||||
use shellrs::app::CloseExplorer;
|
||||
|
||||
let remote = Arc::new(RecordingRemoteProvider::default());
|
||||
let (handle, workspace) = open_workspace_with_credentials(
|
||||
cx,
|
||||
HostStore::empty(),
|
||||
remote.clone(),
|
||||
Arc::new(FakeConnectionTester::default()),
|
||||
);
|
||||
// How JumpServer opens WinSCP: the link alone.
|
||||
open_link(
|
||||
cx,
|
||||
handle,
|
||||
&workspace,
|
||||
"sftp://token:p%40ss@10.0.0.9:2222",
|
||||
None,
|
||||
);
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.find("status-connection").label() == Some("已连接 10.0.0.9")
|
||||
})
|
||||
.await;
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert_eq!(
|
||||
window.find(("explorer-tab", SFTP_TAB)).label(),
|
||||
Some("10.0.0.9 · SFTP")
|
||||
);
|
||||
// An SFTP tab alone, across the window.
|
||||
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
|
||||
assert_eq!(window.find("show-hosts").checked(), Some(false));
|
||||
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
|
||||
})
|
||||
.unwrap();
|
||||
assert!(remote.logins().is_empty());
|
||||
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
|
||||
store.read_with(cx, |store, _| {
|
||||
assert!(store.is_external(LINK_HOST));
|
||||
let login = store.login(LINK_HOST).unwrap();
|
||||
assert_eq!(
|
||||
(login.host.as_str(), login.port, login.user.as_str()),
|
||||
("10.0.0.9", 2222, "token")
|
||||
);
|
||||
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
|
||||
});
|
||||
|
||||
// It goes with its tab.
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
store.read_with(cx, |store, _| assert!(store.host(LINK_HOST).is_none()));
|
||||
}
|
||||
|
||||
/// The title bar's 临时连接: a host's login without its group, route or
|
||||
/// notes, connected to with every feature and saved nowhere.
|
||||
#[gpui_kit::test]
|
||||
async fn a_temporary_connection_has_every_feature_and_saves_nothing(cx: &mut TestAppContext) {
|
||||
use shellrs::app::CloseTerminal;
|
||||
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let path = data.path().join("shellrs.db");
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
let store = HostStore::load(HostDatabase::open(&path).unwrap())
|
||||
.unwrap()
|
||||
.with_secrets(keychain.clone());
|
||||
let remote = Arc::new(RecordingRemoteProvider::default());
|
||||
let tester = Arc::new(FakeConnectionTester::default());
|
||||
let (handle, workspace) =
|
||||
open_workspace_with_credentials(cx, store, remote.clone(), tester.clone());
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("temporary-connection", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
// Only what a login needs.
|
||||
assert!(window.try_find("host-route").is_none());
|
||||
assert!(window.try_find("host-notes").is_none());
|
||||
assert!(window.try_find("test-connection").is_some());
|
||||
assert_eq!(
|
||||
window.find("temporary-note").label(),
|
||||
Some("仅当前使用的临时会话,不会保存到主机列表。")
|
||||
);
|
||||
// The name may be left out.
|
||||
window.click("host-address", cx);
|
||||
window.input("10.0.0.9", cx);
|
||||
window.click("host-port", cx);
|
||||
window.press("cmd-a", cx);
|
||||
window.input("2222", cx);
|
||||
window.click("host-user", cx);
|
||||
window.press("cmd-a", cx);
|
||||
window.input("deploy", cx);
|
||||
window.click("host-password", cx);
|
||||
window.input("p@ss", cx);
|
||||
window.click("test-connection", cx);
|
||||
});
|
||||
// 测试连接 tries it as it would connect: directly.
|
||||
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.notifications(cx).len() == 1
|
||||
})
|
||||
.await;
|
||||
assert_eq!(tester.routes(), [(LoginRoute::Direct, None)]);
|
||||
in_frame(cx, handle, |window, cx| window.click("commit", cx));
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.find("status-connection").label() == Some("已连接 10.0.0.9")
|
||||
})
|
||||
.await;
|
||||
|
||||
let login = remote.logins().pop().expect("connected");
|
||||
assert_eq!(
|
||||
(login.host.as_str(), login.port, login.user.as_str()),
|
||||
("10.0.0.9", 2222, "deploy")
|
||||
);
|
||||
assert_eq!(login.method, LoginMethod::Password);
|
||||
assert_eq!(login.route, LoginRoute::Direct);
|
||||
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
|
||||
// Every channel it likes, unlike a bastion host's link.
|
||||
assert!(!login.shell_only);
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert_eq!(
|
||||
window.find(("terminal-tab", LINK_TERMINAL)).label(),
|
||||
Some("10.0.0.9")
|
||||
);
|
||||
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
|
||||
// The hosts stay where they were.
|
||||
assert_eq!(window.find("show-hosts").checked(), Some(true));
|
||||
for tool in [
|
||||
"tool-snippets",
|
||||
"tool-history",
|
||||
"tool-docker",
|
||||
"tool-monitor",
|
||||
] {
|
||||
assert!(window.try_find(tool).is_some(), "{tool}");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
|
||||
store.read_with(cx, |store, _| {
|
||||
assert!(store.is_temporary(LINK_HOST));
|
||||
assert!(!store.is_external(LINK_HOST));
|
||||
assert!(store.hosts().is_empty());
|
||||
assert!(store.recent_hosts().next().is_none());
|
||||
assert_eq!(
|
||||
store
|
||||
.secrets()
|
||||
.get(&SecretRef::temporary(LINK_HOST.0))
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
.map(String::as_str),
|
||||
Some("p@ss")
|
||||
);
|
||||
});
|
||||
assert!(keychain.is_empty());
|
||||
|
||||
// It goes with its tab, password and all.
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
store.read_with(cx, |store, _| {
|
||||
assert!(store.host(LINK_HOST).is_none());
|
||||
assert!(
|
||||
store
|
||||
.secrets()
|
||||
.get(&SecretRef::temporary(LINK_HOST.0))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
});
|
||||
let saved = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
|
||||
assert!(saved.hosts().is_empty());
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppContext) {
|
||||
let (handle, workspace) = open_workspace_with_credentials(
|
||||
@@ -722,6 +904,8 @@ async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppC
|
||||
window.render_frame(cx);
|
||||
assert_eq!(window.notifications(cx).len(), 1);
|
||||
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
|
||||
// Nothing opened, so the hosts stay.
|
||||
assert_eq!(window.find("show-hosts").checked(), Some(true));
|
||||
})
|
||||
.unwrap();
|
||||
workspace.read_with(cx, |workspace, cx| {
|
||||
|
||||
@@ -232,7 +232,7 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC
|
||||
Arc::new(RecordingRemoteProvider::default()),
|
||||
Arc::new(FakeConnectionTester::default()),
|
||||
);
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("host-name", cx);
|
||||
window.input("web-01", cx);
|
||||
|
||||
+10
-10
@@ -8,7 +8,7 @@ async fn a_host_without_a_password_says_what_it_tries_and_is_saved_as_such(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty());
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
// A new host logs in with a password.
|
||||
let sources = window.within("host-auth-source");
|
||||
@@ -84,7 +84,7 @@ fn chain(window: &mut gpui_kit::Window) -> Option<String> {
|
||||
async fn a_host_goes_through_the_jump_hosts_it_lists_in_order(cx: &mut TestAppContext) {
|
||||
let (store, [aliyun, zentao, _]) = store_with_jump_hosts();
|
||||
let (handle, workspace) = open_workspace_with_store(cx, store);
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
// A new host connects directly, with nothing more to fill in.
|
||||
let routes = window.within("host-route");
|
||||
@@ -203,7 +203,7 @@ async fn testing_a_connection_through_a_jump_host_sends_its_login(cx: &mut TestA
|
||||
let (store, _) = store_with_jump_hosts();
|
||||
let tester = Arc::new(FakeConnectionTester::default());
|
||||
let (handle, _) = open_workspace_with_tester(cx, store, tester.clone());
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("host-name", cx);
|
||||
window.input("db", cx);
|
||||
@@ -238,7 +238,7 @@ async fn testing_a_connection_through_a_jump_host_sends_its_login(cx: &mut TestA
|
||||
#[gpui_kit::test]
|
||||
async fn a_hosts_notes_take_several_lines_and_come_back_when_edited(cx: &mut TestAppContext) {
|
||||
let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty());
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("host-name", cx);
|
||||
window.input("db", cx);
|
||||
@@ -334,7 +334,7 @@ async fn a_host_behind_a_proxy_keeps_the_proxys_password_in_the_keychain(cx: &mu
|
||||
HostStore::empty().with_secrets(secrets.clone()),
|
||||
tester.clone(),
|
||||
);
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("host-name", cx);
|
||||
window.input("abroad", cx);
|
||||
@@ -457,7 +457,7 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA
|
||||
#[gpui_kit::test]
|
||||
async fn a_form_error_sits_above_the_dialog_and_a_click_puts_it_away(cx: &mut TestAppContext) {
|
||||
let (handle, _) = open_workspace(cx);
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("host-name", cx);
|
||||
window.click("commit", cx);
|
||||
@@ -485,7 +485,7 @@ async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) {
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.click("new-host", cx);
|
||||
window.click("new-host-panel", cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
@@ -640,7 +640,7 @@ async fn a_connection_test_needs_a_host_and_a_user_first(cx: &mut TestAppContext
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.click("new-host", cx);
|
||||
window.click("new-host-panel", cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
@@ -770,7 +770,7 @@ async fn a_new_host_saves_its_password_to_the_keychain(cx: &mut TestAppContext)
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.click("new-host", cx);
|
||||
window.click("new-host-panel", cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
@@ -944,7 +944,7 @@ async fn a_dialogs_choices_are_equal_segments_of_one_track(cx: &mut TestAppConte
|
||||
Arc::new(RecordingRemoteProvider::default()),
|
||||
Arc::new(FakeConnectionTester::default()),
|
||||
);
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let name = window.find("host-name").bounds();
|
||||
let mut group = window.within("host-auth-source");
|
||||
|
||||
+67
-2
@@ -196,6 +196,69 @@ async fn enter_connects_the_selected_recent_host(cx: &mut TestAppContext) {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 快速连接 on the start page: search the saved hosts and connect to one
|
||||
/// with Enter. (Choosing several is switched off for now.)
|
||||
#[gpui_kit::test]
|
||||
async fn quick_connect_finds_saved_hosts_and_connects_one(cx: &mut TestAppContext) {
|
||||
use shellrs::host::GroupDraft;
|
||||
|
||||
let mut store = HostStore::empty();
|
||||
let group = store.insert_group_unnotified(GroupDraft::new("数据库", None));
|
||||
let [web, db, cache] = [
|
||||
("web-01", "10.0.0.1", None),
|
||||
("db-01", "10.0.0.2", Some(group)),
|
||||
("cache", "10.0.0.3", None),
|
||||
]
|
||||
.map(|(name, address, group)| {
|
||||
store.insert_unnotified(HostDraft::new(
|
||||
name,
|
||||
address,
|
||||
22,
|
||||
"root",
|
||||
AuthKind::Password,
|
||||
group,
|
||||
))
|
||||
});
|
||||
let (handle, _) = open_workspace_with_store(cx, store);
|
||||
let row = |id: HostId| ("quick-connect-host", id.0);
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("recent-quick-connect", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
// Every saved host, by name while none has been connected to.
|
||||
let tops: Vec<f32> = [cache, db, web]
|
||||
.map(|id| window.find(row(id)).bounds().origin.y.into())
|
||||
.to_vec();
|
||||
assert!(tops.is_sorted(), "{tops:?}");
|
||||
assert_eq!(
|
||||
window.find(row(db)).label(),
|
||||
Some("db-01 · 数据库 · root@10.0.0.2:22")
|
||||
);
|
||||
// The search box has the keys: a group's name finds its hosts.
|
||||
window.input("数据", cx);
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.try_find(row(web)).is_none()
|
||||
})
|
||||
.await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
assert!(window.try_find(row(db)).is_some());
|
||||
window.press("enter", cx);
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window
|
||||
.try_find(("terminal-tab", 1u64))
|
||||
.is_some_and(|tab| tab.label() == Some("db-01"))
|
||||
})
|
||||
.await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("quick-connect").is_none());
|
||||
});
|
||||
}
|
||||
|
||||
/// The start page's row menu is the host tree's menu. Menus are not
|
||||
/// driven here, so this dispatches what its items dispatch, from the page:
|
||||
/// the page is drawn deferred over the dock, and its actions must still
|
||||
@@ -628,7 +691,8 @@ async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
|
||||
for (open, field) in [
|
||||
("new-host", "host-name"),
|
||||
("new-host-panel", "host-name"),
|
||||
("temporary-connection", "host-address"),
|
||||
("new-group", "group-name"),
|
||||
("new-forward", "forward-name"),
|
||||
("new-credential", "credential-name"),
|
||||
@@ -681,7 +745,8 @@ async fn a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it(cx: &mu
|
||||
cx.run_until_parked();
|
||||
|
||||
for (open, field, error) in [
|
||||
("new-host", "host-name", "请输入名称"),
|
||||
("new-host-panel", "host-name", "请输入名称"),
|
||||
("temporary-connection", "host-address", "请输入地址"),
|
||||
("new-group", "group-name", "请输入分组名称"),
|
||||
("new-forward", "forward-name", "请选择端口转发经由的主机"),
|
||||
("new-credential", "credential-name", "请输入名称"),
|
||||
|
||||
Reference in new issue
Block a user