Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd34edfc1c | ||
|
|
4fc7704954 | ||
|
|
67d36c8210 | ||
|
|
c0bc970de8 | ||
|
|
e7fcc7ca40 |
No files matched your search
@@ -17,6 +17,20 @@ ShellRS 的第一个公开版本。
|
||||
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
|
||||
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
|
||||
|
||||
## [0.1.0-beta.8]
|
||||
|
||||
### 修复
|
||||
|
||||
- 从堡垒机打开的连接登录后立即断开。
|
||||
|
||||
## [0.1.0-beta.7]
|
||||
|
||||
### 修复
|
||||
|
||||
- 连不上部分堡垒机:Apache SSHD 等不支持 curve25519、只能提供 2048 位 DH 组的 SSH 服务器,以前握手时会被断开。现在密钥交换和 OpenSSH 的默认一致,支持 ECDH(NIST 曲线),DH 组交换也接受 2048 位的组;OpenSSH 默认能连上的服务器,ShellRS 都能连上。
|
||||
- 连接失败时写明具体原因,比如连接被拒绝、连接超时,或者和服务器没有共同的算法(列出服务器支持哪些),不再只有一句「无法建立 SSH 连接,请检查地址、端口和主机密钥」。
|
||||
- Docker:同一个镜像打了多个标签时,每个标签单独一行,查看详情和删除都对应这一行的标签,删除只去掉这个标签。只有仓库名、没有标签的镜像显示为「仓库:<none>」,不再被当成「仓库:latest」。带仓库地址的镜像(如 `registry.example.com/app:1.0`)现在也能删除和查看详情。
|
||||
|
||||
## [0.1.0-beta.6]
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -44,7 +44,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- `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()` 给临时主机用,见下面「临时连接不保存」)。
|
||||
- `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` 一样,解析不了的名字也算网络错误。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`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`(`Latency` / `LatencyLevel` 在 `connection.rs`,画法是 `shared::LatencyLabel`)。SFTP 标签同样显示:工作线程另起一个 `measure_latency` 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 `SftpEvent::Latency`;`ExplorerPanel` 在 `Connecting` / `Disconnected` 时清掉,只在已连接时由 `title_suffix` 显示。这个任务只在一次 ping 期间攥着 `Arc<SftpClient>`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「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`。
|
||||
- `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` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
|
||||
@@ -118,7 +118,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity<TerminalView> }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`、`NetstatPanel`、`ProcessPanel`、`ServicePanel`、`DockerPanel`、`HistoryPanel`、`SnippetPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它)。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。
|
||||
- **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result<String, String>`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms,`terminal::exec_answer`,几个工具共用;它们的目标是 `terminal::ExecTarget`),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。
|
||||
- **系统监控(`monitor/`)是纯解析加一个面板。** `linux.rs` 是命令 `command(Parts { system, disks })`(每部分以 `@@名称` 行开头:`SYSTEM` 是 `uname`、`/etc/os-release` 和 CPU 型号,每次连接一次;`LOAD` 每次都有,只用 `read` / `case` / `echo` 这些内建命令读 `/proc/uptime`、`/proc/stat`(读到第一行不是 `cpu` 就停,后面很长的 `intr` 行不读)、`/proc/meminfo`(读到 `SwapFree` 就停)、`/proc/net/dev` 和 `/proc/net/route`(找默认路由的网卡);`DISKS` 是 `df`)和纯函数 `parse`(→ `Parsed::Reading(Box<Reading>)` 或 `Unsupported(uname -s)`;没有 `/proc/stat` 的读数就算不支持,cmd.exe 什么都不打印也落在这里;`system` 和 `disks` 只在这次读数要了时才有)。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`、把路径换过去,用本机的 `sh` 真跑脚本再解析,守着内建命令读到的和 `cat` / `grep` 一样;`every_command_is_valid_sh` 对四种组合跑 `sh -n`。`model.rs` 是读数 `Reading`(CPU 的 busy / idle 节拍、内存、网卡的累计字节,要了时才有的系统信息和磁盘)、`Tracker`(和上一次读数比出 `Snapshot`:CPU 百分比、每核百分比、网速;第一次、相隔超过 10 秒、计数变小(重启)都不出比率)和格式化(`format_bytes` 按 1024、两位小数,`format_rate`、`format_percent`、`format_uptime`)、`Load::of`(70 / 90 分档)。`MonitorPanel` 按终端 id 存每台的 `Tracker`、最近一次 `Snapshot`、系统信息(连同读它时终端的连接代次 `TerminalView::generation`,重连可能连到另一台机器,代次变了就重读)和磁盘(连同读它的时刻,满 30 秒 `DISK_INTERVAL` 再读),切回来马上有数;每次读数前 `HostState::parts_due` 决定要哪几部分;`set_target(target, active)` 变了就重启读数任务(丢掉任务就停);时间用 `cx.background_executor().now()`,所以测试时钟下比率也对。卡片是 gpui-kit 的 `GroupBox::fill()`,环是 `ProgressCircle`,条是 `Progress`,会变的数字用等宽字体(`numeric`)免得跳动。一行放几根竖条按上一帧量到的宽度算(同路径标签:竖条那一行里的 `canvas` 在 prepaint 记下宽度,变了就 `on_next_frame` 通知面板重画;`bars_per_row` 是纯函数,竖条 0.375 rem、间隔 0.125 rem),还没量到之前那一行限高一行、`overflow_hidden`,不会先闪出几行。展开 / 折叠按钮(`unfold_button`:ghost xsmall,文字后面跟 ⌄ / ⌃,`toggled`)派发 `ToggleMonitorDetail(MonitorDetail::{Cores, Interfaces})`,工作区经 `ToolSidebar::toggle_monitor_detail` 转给 `MonitorPanel::toggle`(一个命令一个处理器,同 SFTP 队列的展开);按钮派发在工作区的 focus handle 上(`MonitorPanel::new(dispatch)`),终端没焦点也能用。主网卡的挑选是纯函数 `Snapshot::main_interface`(`busy_interfaces` 是有过流量、不是 `lo` 的那些)。面板的滚动用 `overflow_y_scrollbar()`(见下面「滚动条挂在不滚动的外层上」)。
|
||||
- **Docker(`docker/`)一次读出四类对象。** 命令(`linux::command()`)先补 PATH(`/usr/local/bin`、`/opt/homebrew/bin`:Docker Desktop、Homebrew),再定 `$d`:root 或 `docker version` 能连上 daemon(在 docker 组里)就是 `docker`,否则 `sudo -n docker`(所有 Docker 命令都用这一段前缀 `DOCKER`);`@@version` 是 `$d version --format "{{.Server.Version}}" 2>&1`,不以数字开头就是连不上,`complaint` 把「Cannot connect」翻成「Docker 没有运行」、权限问题和 sudo 要密码翻成「没有权限:以 root 登录、把这个用户加入 docker 组…」;其后 `ps -a --no-trunc`、`volume ls`、`images`、`network ls --no-trunc` 都用 `--format "{{json .}}"`,每行一个对象(Docker 1.13 以后都有),按行 `serde_json` 解析,认不出的行跳过。`ps` 的标签是「k=v,k=v」,值里也可能有逗号(compose 的配置文件列表),没有 `=` 的一段接到前一个值上;老 Docker 没有 `State` 字段,从 `Status`(「Up 3 days」)推。卷 / 镜像 / 网络是否在用由 `DockerTable::new` 按容器的 `Mounts`、`Image`(`nginx` 就是 `nginx:latest`;镜像没名字了容器会显示它的 ID)、`Networks` 算。拼进命令的 ID 和名字先过 `valid_id`(字母数字开头,只许字母数字和 `_.:-`),加双引号。`the_script_asks_docker_for_json` 用一个假 `docker` 脚本(按前两个参数回答)真跑读数命令,查模板 `{{json .}}` 原样到了 docker 手里。标签页是 `shared::count_tabs`(见下面「表单里的几选一用 `shared::SegmentedControl`」)。卷 / 镜像 / 网络卡片的名字用 GPUI 自带的中间省略(`overflow_hidden().whitespace_nowrap().text_ellipsis_middle()`,用户要的:「registry.ap-…hello-world:latest」),右边的标签 `flex_shrink_0` 不被挤;镜像的创建时间只写日期,带上时分在 320 px 的卡片里会被截掉。列表同系统服务用 gpui 的 `list`(项目卡片展开后高度变,行里带着展开状态,变了就 `splice`);项目那一行的单击派发 `ToggleDockerProject`,按钮 `stop_propagation`。详情一律是 `DetailSection { id, title, body }` 的列表(`SectionBody::Rows` 标签和值、`List` 一行一个名字、`Text` 等宽文本),`rows()` 把空值写成「—」;容器的 `ContainerDetails::sections()` 也转成它,所以五种详情长得一样,测试 id 都是「{前缀}-{区块}:{标签}」(`container-basics:名称`、`volume-basics:挂载点`、`image-users:<容器>`、`network-subnets:<子网>`),等宽文本是「{前缀}-{区块}」(`container-environment`、`image-environment`)。打开详情是一个动作 `ShowDockerObject { object, id }`(容器也是),工作区按种类开容器的或另外三种的对话框。`ControlContainers { subject, ids, command }` 和 `RemoveDockerObject { object, id, name }` 由工作区处理,执行、通知和之后重读列表走 `tools.rs` 的 `run_on_host(HostCommand { script, outcome, done, failed, then })`,系统服务的命令也用它。
|
||||
- **Docker(`docker/`)一次读出四类对象。** 命令(`linux::command()`)先补 PATH(`/usr/local/bin`、`/opt/homebrew/bin`:Docker Desktop、Homebrew),再定 `$d`:root 或 `docker version` 能连上 daemon(在 docker 组里)就是 `docker`,否则 `sudo -n docker`(所有 Docker 命令都用这一段前缀 `DOCKER`);`@@version` 是 `$d version --format "{{.Server.Version}}" 2>&1`,不以数字开头就是连不上,`complaint` 把「Cannot connect」翻成「Docker 没有运行」、权限问题和 sudo 要密码翻成「没有权限:以 root 登录、把这个用户加入 docker 组…」;其后 `ps -a --no-trunc`、`volume ls`、`images`、`network ls --no-trunc` 都用 `--format "{{json .}}"`,每行一个对象(Docker 1.13 以后都有),按行 `serde_json` 解析,认不出的行跳过。`ps` 的标签是「k=v,k=v」,值里也可能有逗号(compose 的配置文件列表),没有 `=` 的一段接到前一个值上;老 Docker 没有 `State` 字段,从 `Status`(「Up 3 days」)推。卷 / 镜像 / 网络是否在用由 `DockerTable::new` 按容器的 `Mounts`、`Image`(`nginx` 就是 `nginx:latest`;镜像没名字了容器会显示它的 ID)、`Networks` 算。拼进命令的 ID 和名字先过 `valid_id`(字母数字开头,只许字母数字和 `_.:-/`,`/` 是给带仓库地址的镜像名的),加双引号。`docker images` 每个「仓库:标签」一行,同一个镜像打了几个标签就有几行、镜像 ID 相同,所以镜像按 `Image::key` 认:有标签的是 `仓库:标签`,没有标签的是镜像 ID(只有仓库名的 `nginx` 会被 Docker 当成 `nginx:latest`,显示成 `nginx:<none>`,也只按 ID 算在用);卡片 id、详情(`image inspect`)和删除(`rmi`,有别的标签时只去掉这一个)都用它,`DockerTable::new` 按键去重。卡片 id 重复时调试版构建会因无障碍节点 id 重复直接 panic(用户在镜像页碰到过)。`the_script_asks_docker_for_json` 用一个假 `docker` 脚本(按前两个参数回答)真跑读数命令,查模板 `{{json .}}` 原样到了 docker 手里。标签页是 `shared::count_tabs`(见下面「表单里的几选一用 `shared::SegmentedControl`」)。卷 / 镜像 / 网络卡片的名字用 GPUI 自带的中间省略(`overflow_hidden().whitespace_nowrap().text_ellipsis_middle()`,用户要的:「registry.ap-…hello-world:latest」),右边的标签 `flex_shrink_0` 不被挤;镜像的创建时间只写日期,带上时分在 320 px 的卡片里会被截掉。列表同系统服务用 gpui 的 `list`(项目卡片展开后高度变,行里带着展开状态,变了就 `splice`);项目那一行的单击派发 `ToggleDockerProject`,按钮 `stop_propagation`。详情一律是 `DetailSection { id, title, body }` 的列表(`SectionBody::Rows` 标签和值、`List` 一行一个名字、`Text` 等宽文本),`rows()` 把空值写成「—」;容器的 `ContainerDetails::sections()` 也转成它,所以五种详情长得一样,测试 id 都是「{前缀}-{区块}:{标签}」(`container-basics:名称`、`volume-basics:挂载点`、`image-users:<容器>`、`network-subnets:<子网>`),等宽文本是「{前缀}-{区块}」(`container-environment`、`image-environment`)。打开详情是一个动作 `ShowDockerObject { object, id }`(容器也是),工作区按种类开容器的或另外三种的对话框。`ControlContainers { subject, ids, command }` 和 `RemoveDockerObject { object, id, name }` 由工作区处理,执行、通知和之后重读列表走 `tools.rs` 的 `run_on_host(HostCommand { script, outcome, done, failed, then })`,系统服务的命令也用它。
|
||||
- **历史命令(`history/`)读 bash 的历史文件,点了往终端的输入行里打字。** 命令(`bash::command()`)是一行 `sh -c '…'`:`~/.bash_history` 读得到就印 `@@size`(`wc -c`)和 `@@history` 加文件最后 `LIMIT`(512 KB)字节(`tail -c`),否则 `@@missing`;两样都没有(没有 `sh`)是不支持。`@@history` 之后原样是文件,命令本身可能以 `@@` 开头,所以只认第一个;文件比 `LIMIT` 长时丢掉第一行(从行中间切开的)。设了 `HISTTIMEFORMAT` 的 bash 在每条命令前写一行「#秒数」,多行命令写成几行:照 bash 自己读回的规则,跟在带时间的命令后面、自己没有时间的行接到它上面,没有时间的文件一行一条(`#` 后面不全是数字的是注释命令,不是时间)。`the_script_reads_the_end_of_the_history_file` 把 `$HOME` 换成临时目录、用本机的 `sh` 真跑。卡片的单击和执行按钮派发 `EnterCommand { command, run }`(按钮 `stop_propagation`),工作区的 `on_enter_command` 对当前远程终端调 `TerminalView::enter_command`,成功就把焦点给终端;引擎的 `encode_command` 先发 Ctrl-E、Ctrl-U(bash、zsh、fish 的 emacs 键:到行尾、删掉前面所有的,替换已经输入的内容;`replace` 为假时不发,工作区对已知是 Windows 的主机这样做),再把命令当粘贴发(bracketed paste 时多行命令留在输入行上),`run` 时再加回车;`ALT_SCREEN`(vim、less)拒绝,没有 bracketed paste 时多行命令只能执行、不能只输入(第一个换行就会执行它)。复制是 `CopyCommand(String)`。
|
||||
- **系统服务(`services/`)一次 `systemctl show` 读全部服务。** 列表命令(`linux::command()`)先判 `/run/systemd/system` 存不存在(sd_booted 的判法,不在就 `@@unsupported` 加 `uname -s`,Linux 没 systemd 时界面写「这台主机没有使用 systemd」),`@@version`、`@@state` 是 `systemctl --version` 和 `is-system-running`;然后 `list-unit-files` 和 `list-units --all --plain` 的第一列合起来,去掉模板(`*@.service`,只能以实例运行),交给**一次** `systemctl show -p Id -p Description … -- $names`(属性一个 `-p` 一个,systemd 230 以前不认逗号)。别名(`sshd.service`)在 `show` 里的 `Id` 是本名,`ServiceTable::new` 按名字去重;`LoadState=not-found`(被引用但没有 unit 文件)的只在失败时列出;自定义与否看 `FragmentPath` 在不在 `/etc/systemd/system/`。`$(…)` 里的 `case` 分支写成 `(pattern)`:bash 3.2 会把分支裸的 `)` 当成命令替换的结尾(macOS 的 `sh` 就是它,`the_commands_are_valid_sh` 在本机这样失败过);服务名用 `printf "%s\n"` 印,dash 的 `echo` 会解释反斜杠。**拼进命令的服务名先过 `valid_name`**(只许字母、数字和 `:_.@-\`,systemd 自己的规则,且不许连续两个反斜杠),放在双引号里(`\x2d` 这种转义原样留下),不合格就不执行。命令(`control_command`)是 `if test "$(id -u)" = 0; then systemctl <动词> -- "名字"; else sudo -n systemctl …; fi 2>&1; echo @@status $?`,`controlled` 把要密码、要交互认证、不在 sudoers 都翻成「需要 root 权限…」,别的取 systemctl 的第一行(「Job for … failed because …」)。日志是 `journalctl -u … -n 200 --no-pager`,非 root 先试 `sudo -n`、不行就看自己看得到的。`the_script_shows_every_service_but_templates` 用一个假 `systemctl` 脚本(按第一个参数回答)真跑列表命令。列表用 gpui 的 `list`(`ListState`,量每行的高度)而不是 `uniform_list`,因为分组标题和卡片不一样高;`ServicePanel::sync_list` 在行变了时 `splice`、换标签或搜索时 `reset`(回到顶上)。卡片上的按钮 `stop_propagation`,免得连卡片的单击(打开详情)一起触发。`ControlService { name, command }` 由工作区处理:停止、重启走 `confirm_danger`,其余直接执行;详情对话框的按钮同进程详情,先 `close_dialog` 再在工作区的 focus handle 上派发。
|
||||
- **进程管理(`processes/`)只用 shell 内建命令读 `/proc`。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:`@@uptime`、`@@memory`(`MemTotal`)、`@@clock` / `@@page` 是 `getconf CLK_TCK` / `getconf PAGESIZE`(没有 `getconf` 时按 100 和 4096),`@@users` 读 `/etc/passwd`,然后 `for d in /proc/[0-9]*` 每个进程读一行 `stat`、再读 `status` 读到 `Uid:` 为止,印出「uid stat 行」;进程在列出和读取之间退出时 `read … || continue` 跳过(dash、bash 遇到重定向失败都不退出),整个循环的错误输出丢掉;数到 `LIMIT`(2500,一行约 300 字节,exec 的输出最多 1 MB)就印 `@@more` 停下。`stat` 的名字在括号里、可能带空格和括号,所以 `parse_process` 从最后一个 `)` 往后数字段(第 9 个是 flags,14、15 是 utime、stime,22 是启动时刻,24 是 rss 页数);flags 带 `PF_KTHREAD`(0x200000)的是内核线程,不列。`Tracker` 以「PID + 启动时刻」认进程(PID 会被新进程重用),用主机自己的 uptime 差做分母,uptime 变小(重启、另一台机器)不出比率;终端重连(`TerminalView::generation` 变了)就换一个新的 `Tracker`。启动时间是「本机现在 − uptime + 启动节拍 / 每秒节拍」,按本机时区显示,和主机的时钟差多少就偏多少。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`(含一个内核线程和一个读不到 `stat` 的进程)用本机的 `sh` 真跑。结束进程:右键菜单挂在 `processes-list` 上(同主机树,卡片是 `uniform_list` 在布局之后画的),卡片的右键按下记下 PID(`menu_hit`)并把焦点交给面板,菜单项才有派发路径;菜单派发 `EndProcess { pid, force }`,工作区的 `on_end_process` 从 `ToolSidebar::process` 取名称和用户,用 `shared::confirm_danger` 确认(`confirm_delete` 也是它),确认后在同一个终端上执行 `end_command`(`kill -TERM|-KILL <pid> 2>&1; echo @@status $?`,PID 是数字,命令里不拼进程名之类主机给的文字),`ended` 把「Operation not permitted」「No such process」翻成中文,通知结果,再 `refresh_processes_soon`(1 秒后重读)。排序按钮派发 `SortProcesses(ProcessSort)`、刷新派发 `RefreshProcesses`,都经工作区转给面板;搜索框是面板自己的状态。详情:卡片的单击派发 `ShowProcess(pid)`,工作区的 `on_show_process` 从 `ToolSidebar::process_details` 取 `ProcessDetails`(`Snapshot::details`:进程、父进程名、按 ppid 数出的直接子进程和后代,列表里没有父进程时写「PID n」,ppid 0 是「无」),交给 `processes::open_process_dialog`;详情要的 `stat` 字段(父进程、进程组、会话、终端、前台进程组、优先级、nice、线程、虚拟内存)和读数一起解析进 `ProcessInfo`,`ps` 的状态字母(`<` / `N`、`s`、`l`、`+`)和终端名(136–143 是 `pts/n`,4 是 `ttyN` / `ttySN`)在 `parse_process` 里算好;命令行由 `ProcessDetailsView` 打开时在同一个终端上执行 `cat /proc/<pid>/cmdline`(`command_line_command`),NUL 分隔的参数用空格连起来(`command_line`),空的是僵尸或已退出。对话框的标题和页脚在 `open_dialog` 的构造闭包里,每帧读正文实体(复制按钮按一下变成「已复制」两秒,状态在正文实体里);「结束进程」「强制结束」先 `close_dialog` 再在工作区的 focus handle 上派发 `EndProcess`(关掉对话框后焦点是空的,`window.dispatch_action` 没有落点)。卡片的行数必须一样(同网络连接,`uniform_list` 按第一项的高度排)。
|
||||
@@ -136,6 +136,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 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 发布版里没有控制台,所以错误一律由正在运行的那个显示。
|
||||
@@ -200,4 +201,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.6"
|
||||
version = "0.1.0-beta.8"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.0-beta.6"
|
||||
version = "0.1.0-beta.8"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
|
||||
+2
-1
@@ -134,7 +134,8 @@ ShellRS -url ssh://用户[:密码]@地址[:端口] -newtab 标签名
|
||||
```
|
||||
|
||||
- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),不分大小写;别的选项忽略。只支持 `ssh://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。
|
||||
- **临时连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接、右侧栏的工具)和保存的主机一样。
|
||||
- **临时连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。
|
||||
- **只用终端这一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话,所以临时连接不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。
|
||||
- 链接里的密码只留在内存里,不进钥匙串,重新连接时照样用它;没带密码时,服务器要密码就弹框问。第一次连到某台堡垒机要信任它的主机密钥,记在 ShellRS 自己的 `known_hosts` 里,下次不再问。
|
||||
- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。
|
||||
- 堡垒机要调用的程序:
|
||||
|
||||
@@ -369,7 +369,7 @@ impl DockerPanel {
|
||||
DockerRow::Image(index) => {
|
||||
let image = &table.images()[*index];
|
||||
render_object(
|
||||
format!("docker-image:{}", image.id).into(),
|
||||
format!("docker-image:{}", image.key).into(),
|
||||
image.summary(),
|
||||
&dispatch,
|
||||
cx,
|
||||
|
||||
+66
-4
@@ -41,14 +41,15 @@ pub fn command() -> String {
|
||||
}
|
||||
|
||||
/// Whether `id` is an ID or a name Docker gave or allows, and nothing else,
|
||||
/// so that it can go into a command.
|
||||
/// so that it can go into a command. An image's `repository:tag` may name
|
||||
/// a registry, so `/` is allowed too (`registry.example.com/app:1.0`).
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 255
|
||||
&& id.starts_with(|character: char| character.is_ascii_alphanumeric())
|
||||
&& id
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || "_.:-".contains(character))
|
||||
.all(|character| character.is_ascii_alphanumeric() || "_.:-/".contains(character))
|
||||
}
|
||||
|
||||
/// `script` with `$d` for `docker`, saying how it went in an `@@status`
|
||||
@@ -274,10 +275,20 @@ fn volume(object: &Value) -> Volume {
|
||||
|
||||
fn image(object: &Value) -> Image {
|
||||
let (repository, tag) = (text(object, "Repository"), text(object, "Tag"));
|
||||
let id = text(object, "ID").trim_start_matches("sha256:").to_owned();
|
||||
let tagged = repository != "<none>" && !matches!(tag, "<none>" | "");
|
||||
Image {
|
||||
id: text(object, "ID").trim_start_matches("sha256:").to_owned(),
|
||||
// 「nginx」 alone would be read as nginx:latest, another image.
|
||||
key: if tagged {
|
||||
format!("{repository}:{tag}")
|
||||
} else {
|
||||
id.clone()
|
||||
},
|
||||
id,
|
||||
// 「nginx:<none>」 as `docker images` puts it: plain 「nginx」 would
|
||||
// say nginx:latest, and the containers of that would count as its.
|
||||
reference: (repository != "<none>").then(|| match tag {
|
||||
"<none>" | "" => repository.to_owned(),
|
||||
"<none>" | "" => format!("{repository}:<none>"),
|
||||
tag => format!("{repository}:{tag}"),
|
||||
}),
|
||||
size: format_size(text(object, "Size")),
|
||||
@@ -714,6 +725,54 @@ v2.25.0
|
||||
assert!(table.volumes()[0].used_by.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_with_several_tags_is_a_line_for_each_named_by_its_tag() {
|
||||
let table = table(
|
||||
"@@version\n26.0.0\n@@containers\n@@volumes\n@@images\n\
|
||||
{\"ID\":\"aaa111\",\"Repository\":\"nginx\",\"Tag\":\"latest\",\"Size\":\"187MB\"}\n\
|
||||
{\"ID\":\"aaa111\",\"Repository\":\"nginx\",\"Tag\":\"1.27\",\"Size\":\"187MB\"}\n\
|
||||
{\"ID\":\"aaa111\",\"Repository\":\"registry.example.com/web/nginx\",\"Tag\":\"prod\",\"Size\":\"187MB\"}\n\
|
||||
{\"ID\":\"bbb222\",\"Repository\":\"nginx\",\"Tag\":\"<none>\",\"Size\":\"180MB\"}\n\
|
||||
{\"ID\":\"bbb222\",\"Repository\":\"mirror/nginx\",\"Tag\":\"<none>\",\"Size\":\"180MB\"}\n\
|
||||
@@networks\n",
|
||||
);
|
||||
let keys: Vec<&str> = table
|
||||
.images()
|
||||
.iter()
|
||||
.map(|image| image.key.as_str())
|
||||
.collect();
|
||||
// 「nginx」 with no tag is not nginx:latest: it goes by its ID, and
|
||||
// once, though two repositories hold it.
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"nginx:1.27",
|
||||
"nginx:latest",
|
||||
"registry.example.com/web/nginx:prod",
|
||||
"bbb222"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
table.images()[3].reference.as_deref(),
|
||||
Some("mirror/nginx:<none>")
|
||||
);
|
||||
let summary = table
|
||||
.summary_of(DockerObject::Image, "nginx:1.27")
|
||||
.expect("by its tag");
|
||||
assert_eq!(summary.name, "nginx:1.27");
|
||||
assert_eq!(summary.id, "nginx:1.27");
|
||||
// Removing a line removes that tag, wherever the registry is.
|
||||
assert_eq!(
|
||||
remove_command(DockerObject::Image, "registry.example.com/web/nginx:prod").as_deref(),
|
||||
Some(
|
||||
"sh -c 'export LC_ALL=C PATH=$PATH:/usr/local/bin:/opt/homebrew/bin; \
|
||||
if test \"$(id -u)\" = 0 || docker version >/dev/null 2>&1; then d=docker; \
|
||||
else d=\"sudo -n docker\"; fi; $d rmi \"registry.example.com/web/nginx:prod\" 2>&1; \
|
||||
echo @@status $?'"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_docker_that_will_not_answer_says_why() {
|
||||
assert_eq!(
|
||||
@@ -853,7 +912,10 @@ v2.25.0
|
||||
assert!(valid_id("f8664a4a9b8d"));
|
||||
assert!(valid_id("vw-data"));
|
||||
assert!(valid_id("sha256:4a3b5c"));
|
||||
assert!(valid_id("registry.example.com:5000/team/app:1.0"));
|
||||
assert!(!valid_id("-rf"));
|
||||
assert!(!valid_id("/etc"));
|
||||
assert!(!valid_id("a\"b"));
|
||||
assert!(!valid_id("a b"));
|
||||
assert!(!valid_id("$(reboot)"));
|
||||
assert_eq!(remove_command(DockerObject::Volume, "a;b"), None);
|
||||
|
||||
+24
-9
@@ -88,8 +88,13 @@ pub struct Volume {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Image {
|
||||
/// 「4a3b5c6d7e8f」.
|
||||
/// 「4a3b5c6d7e8f」. Not unique in the list: an image tagged twice is
|
||||
/// two of its lines, one per tag.
|
||||
pub id: String,
|
||||
/// What a command names this line by, unique in the list: 「nginx:1.27」,
|
||||
/// or the ID for an image without a tag. Removing a tag removes the
|
||||
/// image only with its last one, as `docker rmi nginx:1.27` does.
|
||||
pub key: String,
|
||||
/// 「nginx:1.27」, `None` for a dangling image (「<none>」).
|
||||
pub reference: Option<String>,
|
||||
/// 「187.69 MB」.
|
||||
@@ -306,14 +311,23 @@ impl DockerTable {
|
||||
}) || (by_id && (id.starts_with(&image.id) || image.id.starts_with(id)))
|
||||
});
|
||||
}
|
||||
// Named ones first, by name; the dangling after.
|
||||
// Tagged ones first, by name; then those without a tag, the ones
|
||||
// without a repository either last.
|
||||
images.sort_by(|a, b| {
|
||||
(a.reference.is_none(), &a.reference, &a.id).cmp(&(
|
||||
b.reference.is_none(),
|
||||
&b.reference,
|
||||
&b.id,
|
||||
))
|
||||
let order = |image: &Image| {
|
||||
(
|
||||
image.key == image.id,
|
||||
image.reference.is_none(),
|
||||
image.reference.clone(),
|
||||
image.id.clone(),
|
||||
)
|
||||
};
|
||||
order(a).cmp(&order(b))
|
||||
});
|
||||
// One card per key: an image without a tag in two repositories is
|
||||
// one image.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
images.retain(|image| seen.insert(image.key.clone()));
|
||||
|
||||
for network in &mut networks {
|
||||
network.used_by = using(&|container| container.networks.contains(&network.name));
|
||||
@@ -352,7 +366,7 @@ impl DockerTable {
|
||||
DockerObject::Image => self
|
||||
.images
|
||||
.iter()
|
||||
.find(|image| image.id == id)
|
||||
.find(|image| image.key == id)
|
||||
.map(Image::summary),
|
||||
DockerObject::Network => self
|
||||
.networks
|
||||
@@ -496,7 +510,7 @@ impl Image {
|
||||
let in_use = !self.used_by.is_empty();
|
||||
ObjectSummary {
|
||||
object: DockerObject::Image,
|
||||
id: self.id.clone(),
|
||||
id: self.key.clone(),
|
||||
name: self
|
||||
.reference
|
||||
.clone()
|
||||
@@ -759,6 +773,7 @@ mod tests {
|
||||
};
|
||||
let image = |id: &str, reference: Option<&str>| Image {
|
||||
id: id.into(),
|
||||
key: reference.unwrap_or(id).into(),
|
||||
reference: reference.map(str::to_owned),
|
||||
size: String::new(),
|
||||
created: String::new(),
|
||||
|
||||
@@ -44,6 +44,12 @@ pub struct HostLogin {
|
||||
pub password: SecretRef,
|
||||
/// How the connection reaches the host.
|
||||
pub route: LoginRoute,
|
||||
/// The connection carries the terminal and nothing beside it: no
|
||||
/// channel of its own for the system probe or the right sidebar's
|
||||
/// commands. A bastion host that opened ShellRS with a link serves one
|
||||
/// session per connection and ends it when another channel comes and
|
||||
/// goes, as Xshell and OpenSSH never make it do.
|
||||
pub shell_only: bool,
|
||||
}
|
||||
|
||||
/// How a connection reaches a host, with every jump host's login looked up.
|
||||
@@ -149,6 +155,7 @@ impl HostLogin {
|
||||
method,
|
||||
key_path: None,
|
||||
route: LoginRoute::Direct,
|
||||
shell_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +184,7 @@ impl HostLogin {
|
||||
key_path,
|
||||
password,
|
||||
route: LoginRoute::Direct,
|
||||
shell_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -879,9 +879,11 @@ impl HostStore {
|
||||
.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`.
|
||||
// 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;
|
||||
}
|
||||
login
|
||||
}
|
||||
@@ -2585,6 +2587,8 @@ mod tests {
|
||||
assert_eq!(login.method, LoginMethod::Password);
|
||||
assert_eq!(login.route, LoginRoute::Direct);
|
||||
assert_eq!(login.password, SecretRef::transient(id.0));
|
||||
assert!(login.shell_only);
|
||||
assert!(!store.login(saved).unwrap().shell_only);
|
||||
assert_eq!(
|
||||
store
|
||||
.secrets()
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ use super::{
|
||||
};
|
||||
use crate::{
|
||||
host::HostLogin,
|
||||
ssh::{LATENCY_INTERVAL, SshConnectionConfig, SshConnector, SshPrompts},
|
||||
ssh::{LATENCY_INTERVAL, SshConnectionConfig, SshConnector, SshPrompts, describe_login_error},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_channel::{Receiver, Sender};
|
||||
@@ -261,7 +261,7 @@ impl SshSftpTransport {
|
||||
Ok(connected) => *client.write().await = Some(connected),
|
||||
Err(error) => {
|
||||
let _ = events
|
||||
.send(SftpEvent::Disconnected(error.to_string()))
|
||||
.send(SftpEvent::Disconnected(describe_login_error(&error)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -302,7 +302,7 @@ impl SshSftpTransport {
|
||||
Ok(connected) => *client.write().await = Some(connected),
|
||||
Err(error) => {
|
||||
events
|
||||
.send(SftpEvent::Disconnected(error.to_string()))
|
||||
.send(SftpEvent::Disconnected(describe_login_error(&error)))
|
||||
.await?;
|
||||
events.send(SftpEvent::Idle).await?;
|
||||
busy.store(false, Ordering::Release);
|
||||
|
||||
@@ -57,6 +57,13 @@ impl From<&HostLogin> for SshConnectionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl SshConnectionConfig {
|
||||
/// See [`HostLogin::shell_only`].
|
||||
pub(crate) fn shell_only(&self) -> bool {
|
||||
self.login.shell_only
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the SSH agent listens.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum AgentLocation {
|
||||
@@ -314,11 +321,50 @@ trait TunnelStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> TunnelStream for T {}
|
||||
|
||||
/// The key exchanges ShellRS offers, in OpenSSH's order: russh's own list
|
||||
/// leaves out the NIST curves, which servers without curve25519 (Apache
|
||||
/// SSHD, as many bastion hosts run) settle on with OpenSSH.
|
||||
const KEX_ORDER: &[russh::kex::Name] = &[
|
||||
russh::kex::MLKEM768X25519_SHA256,
|
||||
russh::kex::CURVE25519,
|
||||
russh::kex::CURVE25519_PRE_RFC_8731,
|
||||
russh::kex::ECDH_SHA2_NISTP256,
|
||||
russh::kex::ECDH_SHA2_NISTP384,
|
||||
russh::kex::ECDH_SHA2_NISTP521,
|
||||
russh::kex::DH_GEX_SHA256,
|
||||
russh::kex::DH_G16_SHA512,
|
||||
russh::kex::DH_G18_SHA512,
|
||||
russh::kex::DH_G17_SHA512,
|
||||
russh::kex::DH_G15_SHA512,
|
||||
russh::kex::DH_G14_SHA256,
|
||||
russh::kex::EXTENSION_SUPPORT_AS_CLIENT,
|
||||
russh::kex::EXTENSION_SUPPORT_AS_SERVER,
|
||||
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,
|
||||
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER,
|
||||
];
|
||||
|
||||
/// The smallest group a group exchange accepts, as OpenSSH's: russh asks
|
||||
/// for 3072 bits, which Apache SSHD on an older Java cannot give (its
|
||||
/// largest is 2048) and so ends the connection.
|
||||
const MIN_DH_GROUP_BITS: usize = 2048;
|
||||
|
||||
/// Connects wherever OpenSSH with its default settings does.
|
||||
fn ssh_config() -> Arc<client::Config> {
|
||||
let defaults = client::GexParams::default();
|
||||
Arc::new(client::Config {
|
||||
keepalive_interval: Some(KEEPALIVE_INTERVAL),
|
||||
keepalive_max: 3,
|
||||
nodelay: true,
|
||||
preferred: russh::Preferred {
|
||||
kex: KEX_ORDER.into(),
|
||||
..russh::Preferred::default()
|
||||
},
|
||||
gex: client::GexParams::new(
|
||||
MIN_DH_GROUP_BITS,
|
||||
defaults.preferred_group_size(),
|
||||
defaults.max_group_size(),
|
||||
)
|
||||
.expect("2048 bits is the smallest group russh allows"),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Loopback-only tests of reaching a host through jump hosts and proxies,
|
||||
//! against in-process SSH servers and proxies. No user hosts, no keychain.
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, OnceLock, Weak},
|
||||
time::Duration,
|
||||
@@ -49,6 +50,9 @@ struct Accepting {
|
||||
user: &'static str,
|
||||
password: &'static str,
|
||||
state: Arc<Mutex<RouteState>>,
|
||||
/// Groups for group exchange no larger than 2048 bits, as Apache SSHD
|
||||
/// has on an older Java: asked for larger, it has none to give.
|
||||
small_groups: bool,
|
||||
}
|
||||
|
||||
struct RouteHandler {
|
||||
@@ -73,6 +77,25 @@ impl server::Server for Accepting {
|
||||
impl server::Handler for RouteHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn lookup_dh_gex_group(
|
||||
&mut self,
|
||||
asked: &russh::client::GexParams,
|
||||
) -> Result<Option<russh::kex::dh::groups::DhGroup>> {
|
||||
use russh::kex::dh::groups::{DH_GROUP14, DH_GROUP16};
|
||||
let groups: &[_] = if self.accepting.small_groups {
|
||||
&[DH_GROUP14]
|
||||
} else {
|
||||
&[DH_GROUP14, DH_GROUP16]
|
||||
};
|
||||
Ok(groups
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|group| {
|
||||
(asked.min_group_size()..=asked.max_group_size()).contains(&group.bit_size())
|
||||
})
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<server::Auth> {
|
||||
Ok(
|
||||
if user == self.accepting.user && password == self.accepting.password {
|
||||
@@ -134,6 +157,15 @@ fn listen(runtime: &tokio::runtime::Runtime) -> Option<TcpListener> {
|
||||
}
|
||||
|
||||
fn start_server(user: &'static str, password: &'static str) -> Option<RouteServer> {
|
||||
start_server_offering(user, password, None)
|
||||
}
|
||||
|
||||
/// A server that only knows the key exchanges `kex`, when given.
|
||||
fn start_server_offering(
|
||||
user: &'static str,
|
||||
password: &'static str,
|
||||
kex: Option<&'static [russh::kex::Name]>,
|
||||
) -> Option<RouteServer> {
|
||||
let runtime = runtime();
|
||||
let listener = listen(&runtime)?;
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
@@ -142,6 +174,7 @@ fn start_server(user: &'static str, password: &'static str) -> Option<RouteServe
|
||||
user,
|
||||
password,
|
||||
state: state.clone(),
|
||||
small_groups: kex.is_some(),
|
||||
};
|
||||
let mut rng = russh::keys::key::safe_rng();
|
||||
let key = russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap();
|
||||
@@ -150,6 +183,10 @@ fn start_server(user: &'static str, password: &'static str) -> Option<RouteServe
|
||||
auth_rejection_time: Duration::ZERO,
|
||||
auth_rejection_time_initial: Some(Duration::ZERO),
|
||||
keys: vec![key],
|
||||
preferred: russh::Preferred {
|
||||
kex: kex.map_or(russh::Preferred::DEFAULT.kex, Cow::Borrowed),
|
||||
..russh::Preferred::default()
|
||||
},
|
||||
..server::Config::default()
|
||||
});
|
||||
runtime.spawn(async move {
|
||||
@@ -604,6 +641,37 @@ fn a_host_behind_an_http_proxy_is_reached_through_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// What Apache SSHD on an older Java offers, as the user's bastion host
|
||||
/// does: group exchange whose largest group is 2048 bits, or NIST curves.
|
||||
/// OpenSSH reaches both; ShellRS must too.
|
||||
#[test]
|
||||
fn a_server_with_only_2048_bit_groups_or_nist_curves_is_reached() {
|
||||
const GROUP_EXCHANGE: &[russh::kex::Name] = &[russh::kex::DH_GEX_SHA256];
|
||||
const NIST_CURVES: &[russh::kex::Name] = &[
|
||||
russh::kex::ECDH_SHA2_NISTP521,
|
||||
russh::kex::ECDH_SHA2_NISTP384,
|
||||
russh::kex::ECDH_SHA2_NISTP256,
|
||||
];
|
||||
for kex in [GROUP_EXCHANGE, NIST_CURVES] {
|
||||
let Some(server) = start_server_offering(TARGET_USER, TARGET_PASSWORD, Some(kex)) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let request = LoginTest::typed(manual("127.0.0.1", server.port, TARGET_USER))
|
||||
.with_password(TARGET_PASSWORD);
|
||||
assert_eq!(
|
||||
test_login(
|
||||
&directory.path().join("known_hosts"),
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
request
|
||||
),
|
||||
Ok(()),
|
||||
"{kex:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A loopback port nothing listens on.
|
||||
fn closed_port() -> u16 {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
|
||||
+42
-3
@@ -156,8 +156,9 @@ fn describe_test_failure(error: &anyhow::Error, host_trust: HostTrust) -> String
|
||||
}
|
||||
|
||||
/// Why a login failed, from the error alone: where on the way to the host
|
||||
/// it failed, a credential that was missing, the network's own answer, or
|
||||
/// whatever the error says.
|
||||
/// it failed, a credential that was missing, the network's own answer, the
|
||||
/// algorithms the server would not agree on, or whatever the error says,
|
||||
/// down to its cause: 「无法建立 SSH 连接」 alone does not say what to fix.
|
||||
pub fn describe_login_error(error: &anyhow::Error) -> String {
|
||||
if let Some(failure) = error.downcast_ref::<RouteFailure>() {
|
||||
return failure.to_string();
|
||||
@@ -173,10 +174,27 @@ pub fn describe_login_error(error: &anyhow::Error) -> String {
|
||||
// russh wraps the socket's own error rather than chaining it.
|
||||
Some(russh::Error::IO(io)) => return describe_connect_error(io),
|
||||
Some(russh::Error::ConnectionTimeout) => return "连接超时".into(),
|
||||
Some(russh::Error::NoCommonAlgo { kind, theirs, .. }) => {
|
||||
return format!(
|
||||
"无法建立 SSH 连接:和服务器没有共同的{}算法,服务器支持的是 {}",
|
||||
algorithm_kind(kind),
|
||||
theirs.join("、")
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
error.to_string()
|
||||
format!("{error:#}")
|
||||
}
|
||||
|
||||
fn algorithm_kind(kind: &russh::AlgorithmKind) -> &'static str {
|
||||
match kind {
|
||||
russh::AlgorithmKind::Kex => "密钥交换",
|
||||
russh::AlgorithmKind::Key => "主机密钥",
|
||||
russh::AlgorithmKind::Cipher => "加密",
|
||||
russh::AlgorithmKind::Mac => "消息校验(MAC)",
|
||||
russh::AlgorithmKind::Compression => "压缩",
|
||||
}
|
||||
}
|
||||
|
||||
/// A connect error in plain words. The common kinds get an explanation;
|
||||
@@ -321,5 +339,26 @@ mod tests {
|
||||
reason(anyhow!("服务器未接受指定的私钥")),
|
||||
"服务器未接受指定的私钥"
|
||||
);
|
||||
// With what lies under it, which says what to fix.
|
||||
let kex = anyhow::Error::from(russh::Error::Kex)
|
||||
.context("无法建立 SSH 连接,请检查地址、端口和主机密钥");
|
||||
assert_eq!(
|
||||
reason(kex),
|
||||
"无法建立 SSH 连接,请检查地址、端口和主机密钥: Key exchange failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_of_other_algorithms_says_which_it_has() {
|
||||
let mac = anyhow::Error::from(russh::Error::NoCommonAlgo {
|
||||
kind: russh::AlgorithmKind::Mac,
|
||||
ours: vec!["hmac-sha2-256".into()],
|
||||
theirs: vec!["hmac-sha1".into(), "hmac-md5".into()],
|
||||
})
|
||||
.context("无法建立 SSH 连接,请检查地址、端口和主机密钥");
|
||||
assert_eq!(
|
||||
reason(mac),
|
||||
"无法建立 SSH 连接:和服务器没有共同的消息校验(MAC)算法,服务器支持的是 hmac-sha1、hmac-md5"
|
||||
);
|
||||
}
|
||||
}
|
||||
+57
-2
@@ -2,6 +2,7 @@ use super::{
|
||||
connection::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts},
|
||||
latency::{LATENCY_INTERVAL, round_trip},
|
||||
probe::{HostOsProbe, ProbeOutcome},
|
||||
tester::describe_login_error,
|
||||
};
|
||||
use crate::{
|
||||
connection::Latency,
|
||||
@@ -37,6 +38,9 @@ const EXEC_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// dropped.
|
||||
const EXEC_OUTPUT_LIMIT: usize = 1 << 20;
|
||||
|
||||
/// The answer to a command for a terminal that carries nothing beside it.
|
||||
const SHELL_ONLY: &str = "这个终端经由堡垒机打开,只能使用终端本身,不能另外执行命令";
|
||||
|
||||
/// Production remote-terminal adapter for the shared SSH connector.
|
||||
pub struct SshTerminalTransportProvider {
|
||||
connector: SshConnector,
|
||||
@@ -147,7 +151,12 @@ impl SshTerminalTransport {
|
||||
}
|
||||
});
|
||||
|
||||
let (handle, _) = self.connector.connect(&self.config, broker.clone()).await?;
|
||||
// Said in the terminal: down to the cause, which says what to fix.
|
||||
let (handle, _) = self
|
||||
.connector
|
||||
.connect(&self.config, broker.clone())
|
||||
.await
|
||||
.map_err(|error| anyhow!(describe_login_error(&error)))?;
|
||||
let mut shutdown = broker.shutdown_receiver();
|
||||
let mut channel = tokio::select! {
|
||||
result = handle.channel_open_session() => {
|
||||
@@ -183,8 +192,15 @@ impl SshTerminalTransport {
|
||||
// it after `Started` keeps the terminal from waiting on a round trip,
|
||||
// and a host that refuses or ignores the probe simply keeps whatever
|
||||
// mark the host already had.
|
||||
// Not through a bastion host that opened a link: another channel
|
||||
// ends its session (see `HostLogin::shell_only`).
|
||||
let shell_only = self.config.shell_only();
|
||||
let mut probe = HostOsProbe::new();
|
||||
let mut probe_channel = open_probe(&handle, probe.command()).await;
|
||||
let mut probe_channel = if shell_only {
|
||||
None
|
||||
} else {
|
||||
open_probe(&handle, probe.command()).await
|
||||
};
|
||||
|
||||
// Round trips are measured with `keepalive@openssh.com`, which every
|
||||
// server answers (a refusal is an answer too), so this is the SSH-level
|
||||
@@ -236,6 +252,9 @@ impl SshTerminalTransport {
|
||||
router.abort();
|
||||
return Ok(());
|
||||
}
|
||||
Some(TerminalTransportCommand::Exec(request)) if shell_only => {
|
||||
let _ = request.reply.send(Err(SHELL_ONLY.into()));
|
||||
}
|
||||
Some(TerminalTransportCommand::Exec(request)) => exec_queue.push_back(request),
|
||||
Some(TerminalTransportCommand::PromptReply { .. }) => {}
|
||||
},
|
||||
@@ -1685,6 +1704,42 @@ mod tests {
|
||||
assert_eq!(execs, ["echo one", "echo two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_a_bastion_host_opened_carries_nothing_beside_it() {
|
||||
let Some(server) = start_server_replying(TestAuth::Password, tool_reply) else {
|
||||
eprintln!(
|
||||
"loopback sockets are unavailable in this sandbox; skipping integration body"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
let mut login = HostLogin::of(&password_host(server.port), None);
|
||||
login.shell_only = true;
|
||||
|
||||
let report = connect(
|
||||
login,
|
||||
&known_hosts,
|
||||
Arc::new(NoSecretStore),
|
||||
true,
|
||||
&["echo one"],
|
||||
trust_and_type_password,
|
||||
);
|
||||
|
||||
// No probe, and a tool's command answered here without asking the
|
||||
// server; the round trip is still measured.
|
||||
assert_eq!(report.execs, vec![Err(SHELL_ONLY.to_string())]);
|
||||
assert_eq!(report.host_os, None);
|
||||
assert!(report.latency.is_some());
|
||||
let execs = server
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.execs
|
||||
.clone();
|
||||
assert!(execs.is_empty(), "{execs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_that_has_no_uname_is_asked_again_the_windows_way() {
|
||||
let Some(server) = start_server_replying(TestAuth::Password, windows_probe_reply) else {
|
||||
|
||||
@@ -45,6 +45,13 @@ impl ToolKind {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the tool runs commands on a channel of its own beside the
|
||||
/// terminal, which a terminal a bastion host opened cannot have. Command
|
||||
/// snippets only type into the terminal.
|
||||
pub fn runs_commands(self) -> bool {
|
||||
!matches!(self, ToolKind::Snippets)
|
||||
}
|
||||
|
||||
/// Whether the tool works on a host running `os`. A host not identified
|
||||
/// yet gets every tool, and is told if one cannot work there.
|
||||
pub fn works_on(self, os: Option<HostOs>) -> bool {
|
||||
|
||||
@@ -56,19 +56,18 @@ impl Workspace {
|
||||
}
|
||||
|
||||
/// The tools the switch offers for the SSH terminal in front; none
|
||||
/// without one.
|
||||
/// without one. A terminal a bastion host opened with a link gets only
|
||||
/// those that run nothing beside it.
|
||||
pub(super) fn offered_tools(&self, cx: &App) -> Vec<ToolKind> {
|
||||
let Some(terminal) = self.tool_terminal(cx) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let os = self
|
||||
.store
|
||||
.read(cx)
|
||||
.host(terminal.host)
|
||||
.and_then(|host| host.os);
|
||||
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);
|
||||
ToolKind::ALL
|
||||
.into_iter()
|
||||
.filter(|tool| tool.works_on(os))
|
||||
.filter(|tool| tool.works_on(os) && !(shell_only && tool.runs_commands()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -625,8 +625,15 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut
|
||||
);
|
||||
// Not saved: no row in the tree.
|
||||
assert!(window.try_find(("host-row", LINK_HOST.0)).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());
|
||||
for tool in ["tool-history", "tool-docker", "tool-monitor"] {
|
||||
assert!(window.try_find(tool).is_none(), "{tool}");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
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));
|
||||
|
||||
+60
-3
@@ -193,7 +193,7 @@ async fn the_other_tabs_list_volumes_images_and_networks(cx: &mut TestAppContext
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find("docker-image:4a3b5c6d7e8f").label(),
|
||||
window.find("docker-image:hello-world:latest").label(),
|
||||
Some("hello-world:latest · 4a3b5c6d7e8f · 13.3 kB · 2026-09-07 · 使用中")
|
||||
);
|
||||
});
|
||||
@@ -326,6 +326,63 @@ const NETWORK: &str = r#"[{"Name":"bridge","Id":"n1","Driver":"bridge","Scope":"
|
||||
"IPAM":{"Config":[{"Subnet":"172.17.0.0/16","Gateway":"172.17.0.1"}]},
|
||||
"Containers":{"ccc333":{"Name":"forex-web","IPv4Address":"172.17.0.2/16"}}}]"#;
|
||||
|
||||
/// One image under two tags, and one without a tag.
|
||||
const TAGGED_TWICE: &str = r#"@@version
|
||||
26.0.0
|
||||
@@compose
|
||||
v2.25.0
|
||||
@@containers
|
||||
@@volumes
|
||||
@@images
|
||||
{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"latest"}
|
||||
{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"1.27"}
|
||||
{"CreatedAt":"2026-08-01 08:00:00 +0800 CST","ID":"ccc333ddd444","Repository":"nginx","Size":"180MB","Tag":"<none>"}
|
||||
@@networks
|
||||
"#;
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn an_image_under_two_tags_is_two_cards_each_removed_by_its_tag(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[
|
||||
TAGGED_TWICE,
|
||||
"Untagged: nginx:1.27\n@@status 0\n",
|
||||
TAGGED_TWICE,
|
||||
]));
|
||||
let handle = open_docker(cx, factory.clone());
|
||||
wait_for_reading(cx, handle).await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.within("docker-tabs").click(2usize, cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find("docker-image:nginx:1.27").label(),
|
||||
Some("nginx:1.27 · aaa111bbb222 · 187 MB · 2026-09-07")
|
||||
);
|
||||
assert_eq!(
|
||||
window.find("docker-image:nginx:latest").label(),
|
||||
Some("nginx:latest · aaa111bbb222 · 187 MB · 2026-09-07")
|
||||
);
|
||||
// Without a tag, by its ID.
|
||||
assert_eq!(
|
||||
window.find("docker-image:ccc333ddd444").label(),
|
||||
Some("nginx:<none> · ccc333ddd444 · 180 MB · 2026-08-01")
|
||||
);
|
||||
});
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("docker-image:nginx:1.27-remove", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.click("ok", cx));
|
||||
cx.wait_for(handle.into(), Duration::from_secs(3), |_, _| {
|
||||
factory.exec_count() >= 2
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
factory.exec_commands()[1].contains("$d rmi \"nginx:1.27\""),
|
||||
"{:?}",
|
||||
factory.exec_commands()
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[
|
||||
@@ -360,7 +417,7 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te
|
||||
window.within("docker-tabs").click(2usize, cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("docker-image:4a3b5c6d7e8f", cx)
|
||||
window.click("docker-image:hello-world:latest", cx)
|
||||
});
|
||||
wait_for_label(cx, handle, "image-basics:平台", "linux/amd64").await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
@@ -371,7 +428,7 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te
|
||||
Some("PATH=/usr/bin")
|
||||
);
|
||||
});
|
||||
assert!(factory.exec_commands()[2].contains("$d image inspect \"4a3b5c6d7e8f\""));
|
||||
assert!(factory.exec_commands()[2].contains("$d image inspect \"hello-world:latest\""));
|
||||
in_frame(cx, handle, |window, cx| window.press("escape", cx));
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
|
||||
Reference in new issue
Block a user