历史命令:右侧栏的新工具,列出主机上 bash 的历史命令,点一下放进终端输入行
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2576b46e43
commit
9143e387bc
18 files changed
+1379
-32
No files matched your search
@@ -10,7 +10,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP
|
||||
|
||||
主机和分组是**真实持久化**的,存在一个 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 和测试连接都照它走。功能的详细说明见 `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
|
||||
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
|
||||
|
||||
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
|
||||
|
||||
@@ -58,6 +58,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。
|
||||
- `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。
|
||||
- `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`、`ExecTarget`),不依赖 `workspace`。
|
||||
- `history/` — 历史命令,右侧栏的第六个真工具:`bash.rs`(读 `~/.bash_history` 的命令 `command()` 和解析 `parse`)、`model.rs`(`History::new` 去重、按最近排、计次数,`matching` 搜索,`format_when` 写「3 分钟前」,都是纯函数)、`history_panel.rs`(`HistoryPanel`:读取、搜索框、`uniform_list` 画的卡片和挂在列表上的右键菜单)。依赖 `terminal`(读取用 `TerminalView::exec`,把命令放进输入行是工作区经 `TerminalView::enter_command` 做的),不依赖 `workspace`。
|
||||
- `docker/` — Docker,右侧栏的第五个真工具:`linux.rs`(读数的命令 `command()` 和解析 `parse`(每行一个 JSON,`serde_json`),`valid_id`,容器命令 `control_command`、删除的 `remove_command`、详情的 `inspect_command` / `parse_details`、日志的 `logs_command`,结果的 `done`)、`model.rs`(`Container` / `Volume` / `Image` / `Network`,`DockerTable::new` 按项目分组、标出哪些卷 / 镜像 / 网络有容器在用,`rows` 按标签页和折叠排出行,都是纯函数)、`docker_panel.rs`(`DockerPanel`:读取、`count_tabs`、gpui `list` 画的项目卡片、容器行和卷 / 镜像 / 网络卡片)、`container_details.rs`(`open_container_dialog`:详情对话框,正文 `ContainerDetailsView` 打开时 `docker inspect`、切到日志页时读日志)、`object_details.rs`(`open_object_dialog`:卷、镜像、网络的详情对话框,正文 `ObjectDetailsView`)、`details.rs`(两种对话框共用的 `Read`、在终端连接上执行的 `run`,以及把 `DetailSection` 画成标题加内容的 `render_sections`)。卷、镜像、网络的卡片和详情都从 `ObjectSummary`(`Volume::summary` 等:名字、一行说明、标签及其 `Tone`、能不能删、被哪些容器用着)画,`DockerTable::new` 记下的是用着它们的容器名(`used_by`),不只是有没有。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区。
|
||||
- `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`)。
|
||||
@@ -113,10 +114,11 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。
|
||||
- **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。
|
||||
- **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。
|
||||
- **右侧栏是右侧 Dock 的一个面板,切换画在 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`),`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,主机改名跟着变。
|
||||
- **右侧栏是右侧 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`),`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 })`,系统服务的命令也用它。
|
||||
- **历史命令(`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 键:到行尾、删掉前面所有的,替换已经输入的内容),再把命令当粘贴发(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` 按第一项的高度排)。
|
||||
- **网络连接(`netstat/`)读一次,按需刷新。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:先 `export PATH=$PATH:/usr/sbin:/sbin`(Debian 上普通用户的 PATH 里没有 sbin,有的发行版把 `ss` 放在那里),`@@uid` 是 `id -u`(是不是 root),`@@users` 用内建命令读 `/etc/passwd` 得到「uid 名称」,然后有 `ss` 就 `ss -tuanpe | awk …`:awk 跳过表头、只印前 `LIMIT`(3000)行、在 `@@counts` 下按「协议 状态」数**全部**的,所以列表被截断时计数仍然准确(`ss` 先列 UDP 和监听的,截掉的只会是连接);没有 `ss` 就对 `/proc/net/{tcp,tcp6,udp,udp6}` 中存在的每个文件跑一个 awk(`-v p=$f`),印出地址、对端、状态、uid、inode,同样截断和计数。awk 程序按原样写在常量里,由 `double_quoted` 转义成 sh 的双引号串(`$`、`"`);不能用 `!`(csh 会展开,所以写 `$1 == "Netid" { next }` 而不是 `!=`),也不能出现 `\\`(fish 在单引号里把它读成一个反斜杠),`the_command_survives_any_login_shell` 守着。`ss` 省略 `uid:0`,所以有 inode、没有 uid 的是 root 的;`ino:0` 的没有主人(TIME_WAIT),用户留空。进程名可能带空格,所以 `parse_ss` 按位置跳过前 6 个字段再找 `users:((…))`,老版本 `ss` 的 `("name",pid,fd)` 也认。`/proc` 的地址是内存里的 32 位字(小端),IPv6 是四个这样的字,`proc_address` 照 `ss` 的写法输出(端口 0 写 `*`,IPv6 加方括号)。`the_script_reads_ss_or_else_proc` 在临时目录里摆好假的 `/proc/net`、`/etc/passwd` 和一个假 `ss` 脚本,用本机的 `sh` 和 awk 真跑两条路。面板:`set_target` 变了(换终端、显示 / 隐藏、换工具)就重读,之外只有刷新按钮读(派发 `RefreshConnections`,工作区经 `ToolSidebar::refresh_connections` 转给 `NetstatPanel::refresh`);搜索框和两个下拉是面板自己的状态(同左侧列表的搜索,不是动作),改了就滚回顶上。列表用 `uniform_list`,它按第一项的高度排所有项,所以**每张卡片的行数必须一样**(没有进程也画「进程 —」那一行),改卡片时别加只有部分卡片才有的行;滚动条挂在不滚动的 `netstat-list` 上。
|
||||
@@ -191,4 +193,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`(系统监控)、`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`,状态行的 `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-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@addLine 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`(系统监控)、`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`,状态行的 `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-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行Line truncated
|
||||
+5
-1
@@ -137,7 +137,11 @@ shellrs download <ID> <远程路径> <本地路径>
|
||||
|
||||
## 右侧栏
|
||||
|
||||
当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。目前 Docker、系统服务、进程管理、网络连接和系统监控可用,命令片段和历史命令即将推出。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统服务、进程管理、网络连接和系统监控的图标;Docker 除了 Windows 主机都有(Mac 上装了 Docker Desktop 也能用)。
|
||||
当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。目前历史命令、Docker、系统服务、进程管理、网络连接和系统监控可用,命令片段即将推出。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统服务、进程管理、网络连接和系统监控的图标;历史命令和 Docker 除了 Windows 主机都有(Mac 上装了 Docker Desktop 也能用)。
|
||||
|
||||
**历史命令**列出远程主机上 bash 的历史命令(`~/.bash_history`),最近执行的在最前,同一条命令只列一次,下面写着执行过几次;bash 记录了时间(设置了 `HISTTIMEFORMAT`)时还写明最近一次是什么时候,多行的命令也能认出来。可以搜索,输入几个词时列出同时包含这些词的命令,不分大小写。点一条命令把它放进终端的输入行(替换掉已经输入的内容,不执行),可以修改后回车;点右边的执行按钮直接执行;右键还可以复制。终端里正在运行 vim、less 这类全屏程序时不会输入。
|
||||
|
||||
bash 在 shell 退出时才把这次执行的命令写进历史文件,所以当前终端里刚执行的命令要等它退出后才会出现。列表在打开历史命令或切换终端时读取,之后点刷新按钮才重新读取;历史文件很长时只读最后 512 KB。
|
||||
|
||||
**Docker** 顶部是 Docker 和 Compose 的版本,下面「容器」「卷」「镜像」「网络」四个标签页带数量。「容器」页里,同一个 compose 项目的容器归在项目卡片下(项目名、运行中 / 总数、项目目录),点项目那一行折叠或展开,有容器在运行的项目默认展开;不属于任何项目的在「独立容器」下。每个项目和容器右边有「停止」(或「启动」)和「重启」,项目的按钮作用于它所有的容器;点容器或它的「…」打开详情:「详情」页是名称、ID、镜像、创建时间、入口、命令、端口映射、挂载、环境变量和标签,「日志」页是容器最近 200 行输出;底部可以启动、停止、重启,已停止的容器还可以删除。「卷」「镜像」「网络」页列出各自的名称和基本信息,标出是否有容器在用;没有容器在用的可以点右边的删除按钮删除(Docker 自带的 bridge、host、none 网络不能删)。点卡片打开详情:卷是驱动、挂载点、创建时间、使用它的容器、选项和标签;镜像是完整 ID、标签、摘要、创建时间、大小、平台、层数、使用它的容器、运行配置(入口、命令、工作目录、用户、暴露端口)、环境变量和标签;网络是驱动、范围、IPv6、子网和网关、连在上面的容器及其地址、选项和标签。详情底部也可以删除。
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ gpui_kit::actions!(
|
||||
RefreshServices,
|
||||
/// Read the Docker of the SSH terminal's host again.
|
||||
RefreshDocker,
|
||||
/// Read the bash history of the SSH terminal's host again.
|
||||
RefreshHistory,
|
||||
/// Show the host list in the left dock.
|
||||
ShowHosts,
|
||||
/// Show the port-forwarding list in the left dock.
|
||||
@@ -209,6 +211,8 @@ id_actions! {
|
||||
/// Fold a compose project of the Docker tool away, or unfold it, by
|
||||
/// its name.
|
||||
ToggleDockerProject(String);
|
||||
/// Copy a command of 历史命令 to the clipboard.
|
||||
CopyCommand(String);
|
||||
|
||||
/// Start a port forward over a connection of its own.
|
||||
StartForward(ForwardId);
|
||||
@@ -290,6 +294,15 @@ pub struct RemoveDockerObject {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Put a command on the input line of the SSH terminal in front, in place
|
||||
/// of what is typed there, and with `run` run it.
|
||||
#[derive(Action, Clone, PartialEq, Eq)]
|
||||
#[action(namespace = shellrs, no_json)]
|
||||
pub struct EnterCommand {
|
||||
pub command: String,
|
||||
pub run: bool,
|
||||
}
|
||||
|
||||
/// One tab of the center area, by the identity of what it shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CenterTab {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Reading bash's history file: one command, and the parser for what it
|
||||
//! prints.
|
||||
|
||||
use super::model::History;
|
||||
|
||||
/// The most of the history file a reading takes, from its end: thousands
|
||||
/// of commands, well within what a reading's output may hold. A file kept
|
||||
/// longer (`HISTFILESIZE` set high) is read for its newest commands.
|
||||
pub const LIMIT: u64 = 512 * 1024;
|
||||
|
||||
/// What 历史命令 runs on the host: how long `~/.bash_history` is, and its
|
||||
/// last `LIMIT` bytes, each under an `@@` line of its own; `@@missing` when
|
||||
/// there is no such file to read.
|
||||
///
|
||||
/// One line, in single quotes for `sh -c`, so that whatever the login shell
|
||||
/// is (bash, zsh, fish, csh) it hands the script to `sh` untouched: the
|
||||
/// script has no single quote, and no `!` for csh to expand.
|
||||
pub fn command() -> String {
|
||||
format!(
|
||||
"sh -c 'f=$HOME/.bash_history; if test -r \"$f\"; then \
|
||||
echo @@size; wc -c < \"$f\"; echo @@history; tail -c {LIMIT} \"$f\"; \
|
||||
else echo @@missing; fi'"
|
||||
)
|
||||
}
|
||||
|
||||
/// What the command's output said.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Parsed {
|
||||
History(History),
|
||||
/// No history file, or not one the login can read.
|
||||
Missing,
|
||||
/// Not what the command prints: no `sh` there to run it.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
pub fn parse(output: &str) -> Parsed {
|
||||
// The history comes last, as it is: a command may start with 「@@」.
|
||||
let (head, body) = match output.find("@@history\n") {
|
||||
Some(at) if at == 0 || output[..at].ends_with('\n') => {
|
||||
(&output[..at], &output[at + "@@history\n".len()..])
|
||||
}
|
||||
_ => (output, ""),
|
||||
};
|
||||
let mut lines = head.lines().map(str::trim);
|
||||
if !lines.clone().any(|line| line == "@@size") {
|
||||
return if head.lines().any(|line| line.trim() == "@@missing") {
|
||||
Parsed::Missing
|
||||
} else {
|
||||
Parsed::Unsupported
|
||||
};
|
||||
}
|
||||
let size = lines
|
||||
.find(|line| *line == "@@size")
|
||||
.and_then(|_| lines.next())
|
||||
.and_then(|size| size.parse::<u64>().ok())
|
||||
.unwrap_or_default();
|
||||
let truncated = size > LIMIT;
|
||||
let mut body = body;
|
||||
// Cut off in the middle of a line, which is not a command.
|
||||
if truncated {
|
||||
body = body.split_once('\n').map_or("", |(_, rest)| rest);
|
||||
}
|
||||
Parsed::History(History::new(commands(body), truncated))
|
||||
}
|
||||
|
||||
/// The commands of a history file, the oldest first, each with when it
|
||||
/// ran when bash kept that.
|
||||
///
|
||||
/// With `HISTTIMEFORMAT` set, bash writes 「#1727846400」 before each
|
||||
/// command, and a command of several lines as its lines; the lines up to
|
||||
/// the next time are one command, the way bash reads them back. Without
|
||||
/// times, every line is a command.
|
||||
fn commands(body: &str) -> Vec<(String, Option<i64>)> {
|
||||
let mut commands: Vec<(String, Option<i64>)> = Vec::new();
|
||||
let mut time = None;
|
||||
// Whether the last command came after a time, so a line with no time
|
||||
// of its own belongs to it.
|
||||
let mut timed = false;
|
||||
for line in body.lines() {
|
||||
if let Some(seconds) = timestamp(line) {
|
||||
time = Some(seconds);
|
||||
continue;
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match (time.take(), commands.last_mut()) {
|
||||
(None, Some((command, _))) if timed => {
|
||||
command.push('\n');
|
||||
command.push_str(line);
|
||||
}
|
||||
(at, _) => {
|
||||
timed = at.is_some();
|
||||
commands.push((line.to_owned(), at));
|
||||
}
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
/// The time bash writes before a command: 「#」 and the seconds since 1970.
|
||||
fn timestamp(line: &str) -> Option<i64> {
|
||||
let digits = line.strip_prefix('#')?.trim_end();
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn listed(parsed: Parsed) -> Vec<(String, Option<i64>, usize)> {
|
||||
let Parsed::History(history) = parsed else {
|
||||
panic!("no history: {parsed:?}");
|
||||
};
|
||||
history
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| (entry.command.clone(), entry.last_run, entry.runs))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn entry(command: &str, last_run: Option<i64>, runs: usize) -> (String, Option<i64>, usize) {
|
||||
(command.to_owned(), last_run, runs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_line_is_a_command_the_newest_first_once_each() {
|
||||
let output = "@@size\n38\n@@history\nls\ncd /var/log\nls\ntail -f syslog\n\n";
|
||||
assert_eq!(
|
||||
listed(parse(output)),
|
||||
[
|
||||
entry("tail -f syslog", None, 1),
|
||||
entry("ls", None, 2),
|
||||
entry("cd /var/log", None, 1),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_times_bash_keeps_go_with_their_commands() {
|
||||
let output = "@@size\n80\n@@history\n\
|
||||
#1727846400\nls\n\
|
||||
#1727846460\nfor f in *.log\ndo gzip $f\ndone\n\
|
||||
#1727846520\nls\n";
|
||||
assert_eq!(
|
||||
listed(parse(output)),
|
||||
[
|
||||
entry("ls", Some(1727846520), 2),
|
||||
entry("for f in *.log\ndo gzip $f\ndone", Some(1727846460), 1),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_from_before_the_times_stay_lines_of_their_own() {
|
||||
let output = "@@size\n40\n@@history\nuptime\ndf -h\n#1727846400\nfree -m\n";
|
||||
assert_eq!(
|
||||
listed(parse(output)),
|
||||
[
|
||||
entry("free -m", Some(1727846400), 1),
|
||||
entry("df -h", None, 1),
|
||||
entry("uptime", None, 1),
|
||||
]
|
||||
);
|
||||
// A comment is a command, not a time.
|
||||
let output = "@@size\n20\n@@history\n# backup\n#12a\n";
|
||||
assert_eq!(
|
||||
listed(parse(output)),
|
||||
[entry("#12a", None, 1), entry("# backup", None, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_read_from_its_middle_starts_at_a_whole_line() {
|
||||
let output = format!("@@size\n{}\n@@history\nt -lh\nps aux\n", LIMIT + 10);
|
||||
let Parsed::History(history) = parse(&output) else {
|
||||
panic!("no history");
|
||||
};
|
||||
assert!(history.truncated());
|
||||
assert_eq!(history.entries()[0].command, "ps aux");
|
||||
assert_eq!(history.entries().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_may_look_like_a_section() {
|
||||
let output = "@@size\n20\n@@history\necho @@size\n@@history\n";
|
||||
assert_eq!(
|
||||
listed(parse(output)),
|
||||
[entry("@@history", None, 1), entry("echo @@size", None, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_or_sh_it_says_so() {
|
||||
assert_eq!(parse("@@missing\n"), Parsed::Missing);
|
||||
assert_eq!(parse(""), Parsed::Unsupported);
|
||||
assert_eq!(
|
||||
parse("'sh' is not recognized as an internal or external command\r\n"),
|
||||
Parsed::Unsupported
|
||||
);
|
||||
// An empty file is a history with nothing in it.
|
||||
assert_eq!(listed(parse("@@size\n0\n@@history\n")), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_survives_any_login_shell() {
|
||||
let command = command();
|
||||
let script = command
|
||||
.strip_prefix("sh -c '")
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
.expect("one sh -c in single quotes");
|
||||
assert!(!script.contains('\''));
|
||||
assert!(!script.contains('!'));
|
||||
assert!(!script.contains('\n'));
|
||||
assert!(!script.contains("\\\\"));
|
||||
assert!(crate::testing::sh_accepts(script));
|
||||
}
|
||||
|
||||
/// The script, run by this machine's `sh` on a history file in a
|
||||
/// directory standing in for the home, gives what the parser reads;
|
||||
/// a file longer than `LIMIT` is read for its end.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_script_reads_the_end_of_the_history_file() {
|
||||
let home = tempfile::tempdir().expect("temp dir");
|
||||
let command = command();
|
||||
let script = command
|
||||
.strip_prefix("sh -c '")
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
.unwrap()
|
||||
.replace("$HOME", &home.path().display().to_string());
|
||||
let run = || {
|
||||
let output = crate::testing::sh(&script, None);
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
parse(&String::from_utf8(output.stdout).expect("utf-8"))
|
||||
};
|
||||
assert_eq!(run(), Parsed::Missing);
|
||||
|
||||
let file = home.path().join(".bash_history");
|
||||
std::fs::write(&file, "#1727846400\nls -l\n#1727846460\ngit status\n").unwrap();
|
||||
assert_eq!(
|
||||
listed(run()),
|
||||
[
|
||||
entry("git status", Some(1727846460), 1),
|
||||
entry("ls -l", Some(1727846400), 1),
|
||||
]
|
||||
);
|
||||
|
||||
let old = "make\n".repeat(LIMIT as usize / 5 + 100);
|
||||
std::fs::write(&file, format!("{old}cargo build --release\n")).unwrap();
|
||||
let Parsed::History(history) = run() else {
|
||||
panic!("no history");
|
||||
};
|
||||
assert!(history.truncated());
|
||||
assert_eq!(history.entries()[0].command, "cargo build --release");
|
||||
assert_eq!(history.entries()[1].command, "make");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, Icon, IconName, Sizable as _,
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
input::{Input, InputEvent, InputState},
|
||||
menu::{ContextMenuExt as _, PopupMenu},
|
||||
scroll::ScrollableElement as _,
|
||||
spinner::Spinner,
|
||||
tooltip::Tooltip,
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::bash::{self, Parsed};
|
||||
use super::model::{Entry, History};
|
||||
use crate::app::{CatalogIcon, CopyCommand, EnterCommand, RefreshHistory};
|
||||
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
|
||||
|
||||
/// How long a terminal not connected is left before it is asked again.
|
||||
const RETRY: Duration = Duration::from_secs(2);
|
||||
|
||||
/// About as many characters as a command line shows in the panel at its
|
||||
/// narrowest; a longer one has the whole of it in a tooltip.
|
||||
const SHOWN_CHARACTERS: usize = 32;
|
||||
|
||||
/// 历史命令: the commands bash kept in `~/.bash_history` on the host of the
|
||||
/// SSH terminal in front, the newest first, each once. A click puts one on
|
||||
/// the terminal's input line to edit; 执行 runs it there.
|
||||
///
|
||||
/// Read when the panel comes on screen and again on 刷新, beside the shell
|
||||
/// on the terminal's own connection: no second login. bash writes the file
|
||||
/// as a shell exits, so what this terminal ran shows up after it does,
|
||||
/// which the panel says. What each terminal's host showed last is kept, so
|
||||
/// coming back to a terminal shows its list at once while it is read
|
||||
/// again. The search stays as it is from one terminal to the next.
|
||||
pub struct HistoryPanel {
|
||||
target: Option<ExecTarget>,
|
||||
active: bool,
|
||||
hosts: HashMap<RemoteTerminalId, HostState>,
|
||||
/// The reading on its way; dropping it stops it.
|
||||
reading: Option<Task<()>>,
|
||||
/// Whether a reading is on its way.
|
||||
loading: bool,
|
||||
search: Entity<InputState>,
|
||||
query: String,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
/// The command a right click landed on, for the list's menu; `None`
|
||||
/// off every command.
|
||||
menu_hit: Rc<RefCell<Option<String>>>,
|
||||
/// The workspace's focus handle: the panel's buttons dispatch on it, so
|
||||
/// they work whatever is focused.
|
||||
dispatch: FocusHandle,
|
||||
focus_handle: FocusHandle,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HostState {
|
||||
history: Option<Rc<History>>,
|
||||
problem: Option<Problem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Problem {
|
||||
/// The terminal is not connected, so there is no connection to read on.
|
||||
NotConnected,
|
||||
/// No `~/.bash_history` to read.
|
||||
Missing,
|
||||
/// No `sh` to run the command: Windows.
|
||||
Unsupported,
|
||||
/// The command did not run, or did not finish.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl HistoryPanel {
|
||||
pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let search = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("搜索命令")
|
||||
.clean_on_escape()
|
||||
});
|
||||
let subscriptions = vec![
|
||||
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
this.query = input.read(cx).value().to_string();
|
||||
this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
cx.notify();
|
||||
}
|
||||
}),
|
||||
];
|
||||
Self {
|
||||
target: None,
|
||||
active: false,
|
||||
hosts: HashMap::new(),
|
||||
reading: None,
|
||||
loading: false,
|
||||
search,
|
||||
query: String::new(),
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
menu_hit: Rc::new(RefCell::new(None)),
|
||||
dispatch,
|
||||
focus_handle: cx.focus_handle(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `target`'s host when `active`, the panel showing it, and was
|
||||
/// not before.
|
||||
pub fn set_target(&mut self, target: Option<ExecTarget>, active: bool, cx: &mut Context<Self>) {
|
||||
if self.target == target && self.active == active {
|
||||
return;
|
||||
}
|
||||
self.target = target;
|
||||
self.active = active;
|
||||
self.read(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Read the history again: 刷新.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.read(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Start reading the target's host over, or stop when there is nothing
|
||||
/// to show. A terminal not connected is asked again every little while,
|
||||
/// so its history comes once it connects.
|
||||
fn read(&mut self, cx: &mut Context<Self>) {
|
||||
self.reading = None;
|
||||
self.loading = false;
|
||||
let Some(target) = self.target.clone().filter(|_| self.active) else {
|
||||
return;
|
||||
};
|
||||
self.loading = true;
|
||||
self.reading = Some(cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
let reply = this.update(cx, |_, cx| {
|
||||
let view = target.view.upgrade()?;
|
||||
view.read(cx).exec(bash::command(), cx)
|
||||
});
|
||||
let Ok(reply) = reply else { return };
|
||||
let answer = match reply {
|
||||
Some(reply) => exec_answer(reply, cx).await,
|
||||
None => None,
|
||||
};
|
||||
let connected = answer.is_some();
|
||||
if this
|
||||
.update(cx, |this, cx| this.record(target.terminal, answer, cx))
|
||||
.is_err()
|
||||
|| connected
|
||||
{
|
||||
return;
|
||||
}
|
||||
cx.background_executor().timer(RETRY).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn record(
|
||||
&mut self,
|
||||
terminal: RemoteTerminalId,
|
||||
answer: Option<ExecResult>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let host = self.hosts.entry(terminal).or_default();
|
||||
host.problem = match answer {
|
||||
None => Some(Problem::NotConnected),
|
||||
Some(Err(error)) => Some(Problem::Failed(error)),
|
||||
Some(Ok(output)) => match bash::parse(&output) {
|
||||
Parsed::History(history) => {
|
||||
host.history = Some(Rc::new(history));
|
||||
None
|
||||
}
|
||||
Parsed::Missing => {
|
||||
host.history = None;
|
||||
Some(Problem::Missing)
|
||||
}
|
||||
Parsed::Unsupported => Some(Problem::Unsupported),
|
||||
},
|
||||
};
|
||||
// Not connected, it keeps asking; the spinner stops all the same.
|
||||
self.loading = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_history(
|
||||
&self,
|
||||
history: Rc<History>,
|
||||
problem: Option<Problem>,
|
||||
cx: &Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let rows = Rc::new(history.matching(&self.query));
|
||||
let summary = format!("共 {} 条 · ~/.bash_history", history.entries().len());
|
||||
let refresh = self.dispatch.clone();
|
||||
let header = v_flex()
|
||||
.flex_shrink_0()
|
||||
.px_3()
|
||||
.pt_3()
|
||||
.pb_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id("history-summary")
|
||||
.test_support()
|
||||
.aria_label(summary.clone())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(summary),
|
||||
)
|
||||
.child(
|
||||
Button::new("history-refresh")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||
.loading(self.loading)
|
||||
.tooltip("刷新")
|
||||
.accessibility_label("刷新")
|
||||
.on_click(move |_, window, cx| {
|
||||
refresh.dispatch_action(&RefreshHistory, window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when_some(problem, |header, problem| {
|
||||
header.child(render_problem(&problem, cx))
|
||||
})
|
||||
.when(history.truncated(), |header| {
|
||||
header.child(note(
|
||||
"history-truncated",
|
||||
format!("历史文件太长,只读了最后 {} KB。", bash::LIMIT / 1024),
|
||||
cx,
|
||||
))
|
||||
})
|
||||
// Why the command just run is not there.
|
||||
.child(note(
|
||||
"history-note",
|
||||
"这个终端里执行的命令,bash 退出后才会出现在这里。".into(),
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
Input::new(&self.search)
|
||||
.id("history-search")
|
||||
.small()
|
||||
.cleanable(true)
|
||||
.prefix(Icon::new(IconName::Search).small()),
|
||||
);
|
||||
|
||||
let clear_hit = self.menu_hit.clone();
|
||||
let menu_hit = self.menu_hit.clone();
|
||||
// The scrollbar goes on the list's box, which does not scroll: on
|
||||
// the scrolled rows it would scroll away with them. The menu hangs
|
||||
// off the box too, not off the rows, which are drawn after layout
|
||||
// (see the host tree).
|
||||
let list = div()
|
||||
.id("history-list")
|
||||
.test_support()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.map(|list| {
|
||||
if rows.is_empty() {
|
||||
let empty = if self.query.trim().is_empty() {
|
||||
"没有历史命令"
|
||||
} else {
|
||||
"没有符合条件的命令"
|
||||
};
|
||||
list.child(
|
||||
div()
|
||||
.id("history-empty")
|
||||
.test_support()
|
||||
.aria_label(empty)
|
||||
.py_8()
|
||||
.text_sm()
|
||||
.text_center()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(empty),
|
||||
)
|
||||
} else {
|
||||
let count = rows.len();
|
||||
let (hit, focus, dispatch) = (
|
||||
self.menu_hit.clone(),
|
||||
self.focus_handle.clone(),
|
||||
self.dispatch.clone(),
|
||||
);
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
list.child(
|
||||
uniform_list("history-entries", count, move |range, _, cx| {
|
||||
range
|
||||
.map(|index| {
|
||||
let entry = &history.entries()[rows[index]];
|
||||
div().px_3().pt_2().child(render_entry(
|
||||
entry,
|
||||
now,
|
||||
hit.clone(),
|
||||
focus.clone(),
|
||||
dispatch.clone(),
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.size_full()
|
||||
.pb_3()
|
||||
.track_scroll(&self.scroll_handle),
|
||||
)
|
||||
}
|
||||
})
|
||||
.vertical_scrollbar(&self.scroll_handle)
|
||||
.capture_any_mouse_down(move |event, _, _| {
|
||||
if matches!(event.button, MouseButton::Left | MouseButton::Right) {
|
||||
clear_hit.borrow_mut().take();
|
||||
}
|
||||
})
|
||||
.context_menu(move |menu, _, _| build_context_menu(menu_hit.borrow().clone(), menu));
|
||||
v_flex().size_full().child(header).child(list)
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for HistoryPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for HistoryPanel {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let host = self
|
||||
.target
|
||||
.as_ref()
|
||||
.and_then(|target| self.hosts.get(&target.terminal));
|
||||
let history = host.and_then(|host| host.history.clone());
|
||||
let problem = host.and_then(|host| host.problem.clone());
|
||||
v_flex()
|
||||
.id("history")
|
||||
.test_support()
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.map(|panel| match (history, problem) {
|
||||
// A list from before a failed reading still says something;
|
||||
// the failure goes above it.
|
||||
(Some(history), problem @ (None | Some(Problem::Failed(_)))) => {
|
||||
panel.child(self.render_history(history, problem, cx))
|
||||
}
|
||||
(_, Some(problem)) => panel.child(div().p_3().child(render_problem(&problem, cx))),
|
||||
(None, None) => panel.child(
|
||||
h_flex()
|
||||
.id("history-loading")
|
||||
.test_support()
|
||||
.gap_2()
|
||||
.justify_center()
|
||||
.py_8()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Spinner::new().small())
|
||||
.child("正在读取…"),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
|
||||
let (text, color) = match problem {
|
||||
Problem::NotConnected => (
|
||||
"终端没有连接。连接后这里显示主机上 bash 的历史命令。".to_string(),
|
||||
cx.theme().muted_foreground,
|
||||
),
|
||||
Problem::Missing => (
|
||||
"这台主机上还没有 bash 的历史命令(~/.bash_history)。bash 退出时才会写入它。"
|
||||
.to_string(),
|
||||
cx.theme().muted_foreground,
|
||||
),
|
||||
Problem::Unsupported => (
|
||||
"暂不支持读取这台主机的历史命令,只支持 bash 的 ~/.bash_history。".to_string(),
|
||||
cx.theme().muted_foreground,
|
||||
),
|
||||
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
|
||||
};
|
||||
div()
|
||||
.id("history-message")
|
||||
.test_support()
|
||||
.aria_label(text.clone())
|
||||
.text_sm()
|
||||
.text_color(color)
|
||||
.child(text)
|
||||
}
|
||||
|
||||
/// A line under the summary.
|
||||
fn note(id: &'static str, text: String, cx: &App) -> impl IntoElement {
|
||||
div()
|
||||
.id(id)
|
||||
.test_support()
|
||||
.aria_label(text.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text)
|
||||
}
|
||||
|
||||
fn build_context_menu(hit: Option<String>, menu: PopupMenu) -> PopupMenu {
|
||||
let Some(command) = hit else {
|
||||
return menu;
|
||||
};
|
||||
menu.menu_with_icon(
|
||||
"输入到终端",
|
||||
Icon::new(CatalogIcon::SquareTerminal),
|
||||
Box::new(EnterCommand {
|
||||
command: command.clone(),
|
||||
run: false,
|
||||
}),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"执行",
|
||||
Icon::new(CatalogIcon::Play),
|
||||
Box::new(EnterCommand {
|
||||
command: command.clone(),
|
||||
run: true,
|
||||
}),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"复制",
|
||||
Icon::new(IconName::Copy),
|
||||
Box::new(CopyCommand(command)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A command's card: the command on one line, when it last ran and how
|
||||
/// often below. Every card has the same lines, so they are all as tall as
|
||||
/// the list takes them to be.
|
||||
fn render_entry(
|
||||
entry: &Entry,
|
||||
now: i64,
|
||||
menu_hit: Rc<RefCell<Option<String>>>,
|
||||
focus: FocusHandle,
|
||||
dispatch: FocusHandle,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let command = entry.command.clone();
|
||||
let line = entry.one_line();
|
||||
let summary = entry.summary(now);
|
||||
let id = SharedString::from(format!("history-entry:{command}"));
|
||||
let enter = EnterCommand {
|
||||
command: command.clone(),
|
||||
run: false,
|
||||
};
|
||||
let run = EnterCommand {
|
||||
command: command.clone(),
|
||||
run: true,
|
||||
};
|
||||
let long = line.chars().count() > SHOWN_CHARACTERS || command.contains('\n');
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
h_flex()
|
||||
.id(id.clone())
|
||||
.test_support()
|
||||
.aria_label(format!("{line} · {summary}"))
|
||||
.gap_2()
|
||||
.py_2()
|
||||
.pl_3()
|
||||
.pr_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().tokens.group_box)
|
||||
.text_color(cx.theme().group_box_foreground)
|
||||
// A click puts it on the terminal's line; the border says it can.
|
||||
.border_1()
|
||||
.border_color(cx.theme().tokens.group_box)
|
||||
.hover({
|
||||
let border = cx.theme().border;
|
||||
move |style| style.border_color(border)
|
||||
})
|
||||
.cursor_pointer()
|
||||
.on_click({
|
||||
let dispatch = dispatch.clone();
|
||||
move |_, window, cx| dispatch.dispatch_action(&enter, window, cx)
|
||||
})
|
||||
// A right click puts the menu on this command. The menu's commands
|
||||
// are dispatched from what is focused, which may be nothing.
|
||||
.on_mouse_down(MouseButton::Right, {
|
||||
let command = command.clone();
|
||||
move |_, window, cx| {
|
||||
menu_hit.replace(Some(command.clone()));
|
||||
focus.focus(window, cx);
|
||||
}
|
||||
})
|
||||
// The whole of a command too long for its line.
|
||||
.when(long, |card| {
|
||||
let (command, mono) = (command.clone(), mono.clone());
|
||||
card.tooltip(move |window, cx| {
|
||||
let (command, mono) = (command.clone(), mono.clone());
|
||||
Tooltip::element(move |_, _| {
|
||||
div()
|
||||
.max_w(rems(24.))
|
||||
.font_family(mono.clone())
|
||||
.text_xs()
|
||||
.child(command.clone())
|
||||
})
|
||||
.build(window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(div().truncate().font_family(mono).text_xs().child(line))
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(summary),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("history-run:{command}")))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(Icon::new(CatalogIcon::Play))
|
||||
.tooltip("执行")
|
||||
.accessibility_label("执行")
|
||||
.on_click(move |_, window, cx| {
|
||||
// Not the card's click too, which would only put it on
|
||||
// the line.
|
||||
cx.stop_propagation();
|
||||
dispatch.dispatch_action(&run, window, cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 历史命令: the right sidebar's tool that lists the commands bash kept in
|
||||
//! the history file of the host of the SSH terminal in front, read over
|
||||
//! that terminal's own connection. A click puts one on the terminal's input
|
||||
//! line; 执行 runs it there.
|
||||
|
||||
mod bash;
|
||||
mod history_panel;
|
||||
mod model;
|
||||
|
||||
pub use history_panel::HistoryPanel;
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The commands of a host's bash history, as the panel lists them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A command of the history, listed once however often it ran.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entry {
|
||||
pub command: String,
|
||||
/// When it last ran, in seconds since 1970, when bash kept the time
|
||||
/// (`HISTTIMEFORMAT` set).
|
||||
pub last_run: Option<i64>,
|
||||
/// How many times the history holds it.
|
||||
pub runs: usize,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The command on one line: a command of several lines shows its line
|
||||
/// breaks as 「↵」.
|
||||
pub fn one_line(&self) -> String {
|
||||
self.command.replace('\n', " ↵ ")
|
||||
}
|
||||
|
||||
/// 「3 分钟前 · 执行 5 次」, or without a time 「执行 5 次」.
|
||||
pub fn summary(&self, now: i64) -> String {
|
||||
match self.last_run {
|
||||
Some(at) => format!("{} · 执行 {} 次", format_when(at, now), self.runs),
|
||||
None => format!("执行 {} 次", self.runs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A host's history: each command once, the newest first.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct History {
|
||||
entries: Vec<Entry>,
|
||||
/// Whether the file was longer than a reading takes, so older commands
|
||||
/// are left out.
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
impl History {
|
||||
/// From the commands of the history file, the oldest first.
|
||||
pub fn new(commands: Vec<(String, Option<i64>)>, truncated: bool) -> Self {
|
||||
let mut entries: Vec<Entry> = Vec::new();
|
||||
let mut seen: HashMap<String, usize> = HashMap::new();
|
||||
for (command, at) in commands.into_iter().rev() {
|
||||
if let Some(&index) = seen.get(&command) {
|
||||
entries[index].runs += 1;
|
||||
continue;
|
||||
}
|
||||
seen.insert(command.clone(), entries.len());
|
||||
entries.push(Entry {
|
||||
command,
|
||||
last_run: at,
|
||||
runs: 1,
|
||||
});
|
||||
}
|
||||
Self { entries, truncated }
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[Entry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn truncated(&self) -> bool {
|
||||
self.truncated
|
||||
}
|
||||
|
||||
/// The entries holding every word of `query`, regardless of case: their
|
||||
/// indexes, the newest first.
|
||||
pub fn matching(&self, query: &str) -> Vec<usize> {
|
||||
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
|
||||
self.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| {
|
||||
let command = entry.command.to_lowercase();
|
||||
words.iter().all(|word| command.contains(word.as_str()))
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// How long ago `at` was, at `now`: 「刚刚」, 「5 分钟前」, 「3 小时前」,
|
||||
/// 「2 天前」, and further back the day, 「2026-08-15」.
|
||||
pub fn format_when(at: i64, now: i64) -> String {
|
||||
const MINUTE: i64 = 60;
|
||||
const HOUR: i64 = 60 * MINUTE;
|
||||
const DAY: i64 = 24 * HOUR;
|
||||
const MONTH: i64 = 30 * DAY;
|
||||
// A host whose clock runs ahead says its commands ran in the future.
|
||||
let ago = now - at;
|
||||
match ago {
|
||||
..MINUTE => "刚刚".into(),
|
||||
MINUTE..HOUR => format!("{} 分钟前", ago / MINUTE),
|
||||
HOUR..DAY => format!("{} 小时前", ago / HOUR),
|
||||
DAY..=MONTH => format!("{} 天前", ago / DAY),
|
||||
_ => chrono::DateTime::from_timestamp(at, 0)
|
||||
.map(|time| {
|
||||
time.with_timezone(&chrono::Local)
|
||||
.format("%Y-%m-%d")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn history(commands: &[&str]) -> History {
|
||||
History::new(
|
||||
commands
|
||||
.iter()
|
||||
.map(|command| (command.to_string(), None))
|
||||
.collect(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_run_again_moves_up_and_counts() {
|
||||
let history = history(&["ls", "top", "ls", "df -h", "ls"]);
|
||||
let listed: Vec<(&str, usize)> = history
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| (entry.command.as_str(), entry.runs))
|
||||
.collect();
|
||||
assert_eq!(listed, [("ls", 3), ("df -h", 1), ("top", 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_wants_every_word_in_any_case() {
|
||||
let history = history(&[
|
||||
"docker run -d --name web nginx",
|
||||
"docker ps",
|
||||
"systemctl restart Nginx",
|
||||
]);
|
||||
assert_eq!(history.matching(""), [0, 1, 2]);
|
||||
assert_eq!(history.matching("nginx"), [0, 2]);
|
||||
assert_eq!(history.matching("docker NGINX"), [2]);
|
||||
assert_eq!(history.matching("kubectl"), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_reads_as_how_long_ago_it_was() {
|
||||
let now = 1_727_846_400;
|
||||
assert_eq!(format_when(now - 20, now), "刚刚");
|
||||
assert_eq!(format_when(now + 300, now), "刚刚");
|
||||
assert_eq!(format_when(now - 5 * 60, now), "5 分钟前");
|
||||
assert_eq!(format_when(now - 3 * 3600 - 59, now), "3 小时前");
|
||||
assert_eq!(format_when(now - 2 * 86_400, now), "2 天前");
|
||||
assert_eq!(format_when(now - 30 * 86_400, now), "30 天前");
|
||||
let long_ago = format_when(now - 60 * 86_400, now);
|
||||
assert!(long_ago.starts_with("2024-08-0"), "{long_ago}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_of_several_lines_shows_on_one() {
|
||||
let entry = Entry {
|
||||
command: "for f in *\ndo echo $f\ndone".into(),
|
||||
last_run: Some(100),
|
||||
runs: 2,
|
||||
};
|
||||
assert_eq!(entry.one_line(), "for f in * ↵ do echo $f ↵ done");
|
||||
assert_eq!(entry.summary(100 + 120), "2 分钟前 · 执行 2 次");
|
||||
assert_eq!(
|
||||
Entry {
|
||||
last_run: None,
|
||||
..entry
|
||||
}
|
||||
.summary(0),
|
||||
"执行 2 次"
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -4,7 +4,7 @@
|
||||
//! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
|
||||
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
|
||||
//! (凭据), `docker` (Docker), `monitor` (系统监控), `services` (系统服务), `processes` (进程管理),
|
||||
//! `netstat` (网络连接),
|
||||
//! `netstat` (网络连接), `history` (历史命令),
|
||||
//! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与
|
||||
//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源).
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod credential;
|
||||
pub mod docker;
|
||||
pub mod explorer;
|
||||
pub mod forward;
|
||||
pub mod history;
|
||||
pub mod host;
|
||||
pub mod monitor;
|
||||
pub mod netstat;
|
||||
|
||||
@@ -255,6 +255,18 @@ impl TerminalEngine {
|
||||
self.send_user_input(encode_paste(text, self.mode()));
|
||||
}
|
||||
|
||||
/// Put `command` on the shell's input line in place of what is typed
|
||||
/// there, as a paste, and with `run` press Enter: what a click in 历史命令
|
||||
/// does. Says why not while the shell is not running or a full-screen
|
||||
/// program has the keys.
|
||||
pub fn enter_command(&self, command: &str, run: bool) -> Result<(), &'static str> {
|
||||
if !self.lifecycle.accepts_input() {
|
||||
return Err("终端没有连接");
|
||||
}
|
||||
self.send_user_input(encode_command(command, run, self.mode())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, size: TerminalSize, cx: &mut gpui_kit::Context<Self>) {
|
||||
if size == self.size {
|
||||
return;
|
||||
@@ -787,6 +799,28 @@ fn encode_paste(text: &str, mode: TermMode) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `TerminalEngine::enter_command` types: Ctrl-E and Ctrl-U first, to
|
||||
/// the line's end and everything before it away (the emacs keys of bash,
|
||||
/// zsh and fish alike), then the command as a paste, then Enter to run it.
|
||||
///
|
||||
/// A full-screen program would take those keys for its own. A command of
|
||||
/// several lines can only be put on the line in a bracketed paste: without
|
||||
/// one, its first newline would run it.
|
||||
fn encode_command(command: &str, run: bool, mode: TermMode) -> Result<Vec<u8>, &'static str> {
|
||||
if mode.contains(TermMode::ALT_SCREEN) {
|
||||
return Err("终端里正在运行全屏程序,先退出它");
|
||||
}
|
||||
if !run && command.contains('\n') && !mode.contains(TermMode::BRACKETED_PASTE) {
|
||||
return Err("这是一条多行命令,在这个终端里只能直接执行");
|
||||
}
|
||||
let mut bytes = b"\x05\x15".to_vec();
|
||||
bytes.extend(encode_paste(command, mode));
|
||||
if run {
|
||||
bytes.push(b'\r');
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// See `TerminalEngine::clear_keeping_prompt`. Returns whether anything was
|
||||
/// cleared.
|
||||
fn clear_keeping_cursor_line(term: &mut AlacrittyTerm) -> bool {
|
||||
@@ -1169,4 +1203,23 @@ mod tests {
|
||||
);
|
||||
assert_eq!(encode_paste("a\rb", TermMode::default()), b"a\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_takes_the_place_of_the_input_line_and_runs_on_enter() {
|
||||
assert_eq!(
|
||||
encode_command("ls -l", false, TermMode::BRACKETED_PASTE),
|
||||
Ok(b"\x05\x15\x1b[200~ls -l\x1b[201~".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_command("ls -l", true, TermMode::default()),
|
||||
Ok(b"\x05\x15ls -l\r".to_vec())
|
||||
);
|
||||
// Several lines wait on the line only in a bracketed paste.
|
||||
let lines = "for f in *\ndo echo $f\ndone";
|
||||
assert!(encode_command(lines, false, TermMode::BRACKETED_PASTE).is_ok());
|
||||
assert!(encode_command(lines, false, TermMode::default()).is_err());
|
||||
assert!(encode_command(lines, true, TermMode::default()).is_ok());
|
||||
// vim and less have the keys to themselves.
|
||||
assert!(encode_command("ls", true, TermMode::ALT_SCREEN).is_err());
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,22 @@ impl TerminalView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Put `command` on the shell's input line in place of what is typed
|
||||
/// there, and with `run` run it; see [`TerminalEngine::enter_command`].
|
||||
pub fn enter_command(
|
||||
&mut self,
|
||||
command: &str,
|
||||
run: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), &'static str> {
|
||||
self.engine.read(cx).enter_command(command, run)?;
|
||||
self.selecting = false;
|
||||
self.scroll_accumulator.reset();
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add the owner's commands below the terminal's own in the context menu.
|
||||
pub fn set_menu_items(&mut self, items: TerminalMenuItems) {
|
||||
self.menu_items = Some(items);
|
||||
|
||||
@@ -9,6 +9,7 @@ use gpui_kit::*;
|
||||
|
||||
use crate::app::{CatalogIcon, DOCKER_ICON, ToggleTool, ToolKind};
|
||||
use crate::docker::{Container, DockerObject, DockerPanel, ObjectSummary};
|
||||
use crate::history::HistoryPanel;
|
||||
use crate::host::{HostId, HostOs, HostStore};
|
||||
use crate::monitor::{MonitorDetail, MonitorPanel};
|
||||
use crate::netstat::NetstatPanel;
|
||||
@@ -51,9 +52,10 @@ impl ToolKind {
|
||||
| ToolKind::Processes
|
||||
| ToolKind::Connections
|
||||
| ToolKind::Monitor => os.is_none_or(HostOs::is_linux),
|
||||
ToolKind::Snippets | ToolKind::History => true,
|
||||
// Wherever `sh` runs `docker`: Linux, and a Mac with Docker Desktop.
|
||||
ToolKind::Docker => os != Some(HostOs::Windows),
|
||||
ToolKind::Snippets => true,
|
||||
// Wherever `sh` runs `docker`: Linux, and a Mac with Docker
|
||||
// Desktop; and bash's history file wherever `sh` reads it.
|
||||
ToolKind::Docker | ToolKind::History => os != Some(HostOs::Windows),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ pub struct ToolTerminal {
|
||||
///
|
||||
/// Each tool is a feature module's entity held here, alive while hidden so
|
||||
/// it keeps its state, told which terminal is in front and whether it is on
|
||||
/// screen; the tools still to come are placeholders.
|
||||
/// screen; the tool still to come is a placeholder.
|
||||
pub struct ToolSidebar {
|
||||
tool: ToolKind,
|
||||
/// The terminal in front; `None` while the sidebar is hidden for
|
||||
@@ -114,6 +116,7 @@ pub struct ToolSidebar {
|
||||
/// Whether the right dock is open.
|
||||
shown: bool,
|
||||
monitor: Entity<MonitorPanel>,
|
||||
history: Entity<HistoryPanel>,
|
||||
docker: Entity<DockerPanel>,
|
||||
services: Entity<ServicePanel>,
|
||||
processes: Entity<ProcessPanel>,
|
||||
@@ -139,6 +142,7 @@ impl ToolSidebar {
|
||||
terminal: None,
|
||||
shown: false,
|
||||
monitor: cx.new(|_| MonitorPanel::new(dispatch.clone())),
|
||||
history: cx.new(|cx| HistoryPanel::new(dispatch.clone(), window, cx)),
|
||||
docker: cx.new(|_| DockerPanel::new(dispatch.clone())),
|
||||
services: cx.new(|cx| ServicePanel::new(dispatch.clone(), window, cx)),
|
||||
processes: cx.new(|cx| ProcessPanel::new(dispatch.clone(), window, cx)),
|
||||
@@ -179,6 +183,11 @@ impl ToolSidebar {
|
||||
self.netstat.update(cx, |netstat, cx| netstat.refresh(cx));
|
||||
}
|
||||
|
||||
/// 历史命令's 刷新.
|
||||
pub fn refresh_history(&mut self, cx: &mut Context<Self>) {
|
||||
self.history.update(cx, |history, cx| history.refresh(cx));
|
||||
}
|
||||
|
||||
/// Docker's 刷新, and after a command.
|
||||
pub fn refresh_docker(&mut self, cx: &mut Context<Self>) {
|
||||
self.docker.update(cx, |docker, cx| docker.refresh(cx));
|
||||
@@ -252,13 +261,17 @@ impl ToolSidebar {
|
||||
view: terminal.view.clone(),
|
||||
});
|
||||
let showing = |tool| self.shown && self.tool == tool;
|
||||
let (monitor, netstat, processes, services, docker) = (
|
||||
let (monitor, netstat, processes, services, docker, history) = (
|
||||
showing(ToolKind::Monitor),
|
||||
showing(ToolKind::Connections),
|
||||
showing(ToolKind::Processes),
|
||||
showing(ToolKind::Services),
|
||||
showing(ToolKind::Docker),
|
||||
showing(ToolKind::History),
|
||||
);
|
||||
self.history.update(cx, |panel, cx| {
|
||||
panel.set_target(target.clone(), history, cx)
|
||||
});
|
||||
self.docker
|
||||
.update(cx, |panel, cx| panel.set_target(target.clone(), docker, cx));
|
||||
self.services.update(cx, |panel, cx| {
|
||||
@@ -376,6 +389,9 @@ impl Render for ToolSidebar {
|
||||
ToolKind::Docker => {
|
||||
sidebar.child(div().flex_1().min_h_0().child(self.docker.clone()))
|
||||
}
|
||||
ToolKind::History => {
|
||||
sidebar.child(div().flex_1().min_h_0().child(self.history.clone()))
|
||||
}
|
||||
_ => sidebar.child(self.render_placeholder(terminal.host, cx)),
|
||||
}
|
||||
})
|
||||
|
||||
+53
-4
@@ -18,10 +18,10 @@ use gpui_kit::component::{
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CenterTab, ControlContainers, ControlService, EndProcess, RefreshConnections, RefreshDocker,
|
||||
RefreshProcesses, RefreshServices, RemoveDockerObject, ShowDockerObject, ShowProcess,
|
||||
ShowService, SortProcesses, ToggleDockerProject, ToggleMonitorDetail, ToggleTool,
|
||||
ToggleToolSidebar, ToolKind,
|
||||
CenterTab, ControlContainers, ControlService, CopyCommand, EndProcess, EnterCommand,
|
||||
RefreshConnections, RefreshDocker, RefreshHistory, RefreshProcesses, RefreshServices,
|
||||
RemoveDockerObject, ShowDockerObject, ShowProcess, ShowService, SortProcesses,
|
||||
ToggleDockerProject, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind,
|
||||
};
|
||||
use crate::docker::{
|
||||
self, ContainerCommand, DockerObject, open_container_dialog, open_object_dialog,
|
||||
@@ -176,6 +176,55 @@ impl Workspace {
|
||||
.update(cx, |tools, cx| tools.sort_processes(by, cx));
|
||||
}
|
||||
|
||||
/// 历史命令's 刷新: read the host's bash history again.
|
||||
pub(super) fn on_refresh_history(
|
||||
&mut self,
|
||||
_: &RefreshHistory,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.tools.update(cx, |tools, cx| tools.refresh_history(cx));
|
||||
}
|
||||
|
||||
/// A command of 历史命令 onto the input line of the SSH terminal in
|
||||
/// front, run with `run`; the terminal takes the keyboard, to edit it
|
||||
/// or carry on. Why not, when it cannot, is a notification.
|
||||
pub(super) fn on_enter_command(
|
||||
&mut self,
|
||||
action: &EnterCommand,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(view) = self
|
||||
.tool_terminal(cx)
|
||||
.and_then(|terminal| terminal.view.upgrade())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let EnterCommand { command, run } = action;
|
||||
match view.update(cx, |view, cx| view.enter_command(command, *run, cx)) {
|
||||
Ok(()) => {
|
||||
let focus = view.read(cx).focus_handle();
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
Err(why) => {
|
||||
let verb = if *run { "执行" } else { "输入" };
|
||||
window.push_notification(Notification::error(format!("无法{verb}命令:{why}")), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史命令's 复制.
|
||||
pub(super) fn on_copy_command(
|
||||
&mut self,
|
||||
action: &CopyCommand,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(action.0.clone()));
|
||||
window.push_notification(Notification::success("已复制命令"), cx);
|
||||
}
|
||||
|
||||
/// Docker's 刷新: read the host's Docker again.
|
||||
pub(super) fn on_refresh_docker(
|
||||
&mut self,
|
||||
|
||||
@@ -2243,6 +2243,9 @@ impl Render for Workspace {
|
||||
.on_action(cx.listener(Self::on_refresh_services))
|
||||
.on_action(cx.listener(Self::on_show_service))
|
||||
.on_action(cx.listener(Self::on_control_service))
|
||||
.on_action(cx.listener(Self::on_refresh_history))
|
||||
.on_action(cx.listener(Self::on_enter_command))
|
||||
.on_action(cx.listener(Self::on_copy_command))
|
||||
.on_action(cx.listener(Self::on_refresh_docker))
|
||||
.on_action(cx.listener(Self::on_toggle_docker_project))
|
||||
.on_action(cx.listener(Self::on_show_docker_object))
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 历史命令: the right sidebar's tool that lists the commands in bash's
|
||||
//! history file on the SSH terminal's host, read over the terminal's own
|
||||
//! connection, and puts them on the terminal's line.
|
||||
|
||||
use crate::support::*;
|
||||
use shellrs::app::{CopyCommand, EnterCommand};
|
||||
|
||||
/// A history file bash wrote with times, 「ls -l」 run twice.
|
||||
const HISTORY: &str = "\
|
||||
@@size
|
||||
120
|
||||
@@history
|
||||
#1727846400
|
||||
ls -l
|
||||
#1727846460
|
||||
cd /var/log
|
||||
#1727846520
|
||||
ls -l
|
||||
#1727846580
|
||||
tail -f syslog
|
||||
";
|
||||
|
||||
fn open_history(cx: &mut TestAppContext, factory: Arc<FakeTerminalFactory>) -> WindowHandle<Root> {
|
||||
let (handle, _) = open_workspace_with_remote_factory(cx, HostStore::seed(), factory);
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.activate_window();
|
||||
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-history", cx));
|
||||
handle
|
||||
}
|
||||
|
||||
async fn wait_for_label(
|
||||
cx: &mut TestAppContext,
|
||||
handle: WindowHandle<Root>,
|
||||
id: &'static str,
|
||||
label: &str,
|
||||
) {
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| {
|
||||
window
|
||||
.try_find(id)
|
||||
.is_some_and(|element| element.label() == Some(label))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
fn entry(command: &str) -> ElementId {
|
||||
ElementId::Name(format!("history-entry:{command}").into())
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn the_history_lists_each_command_once_the_newest_first(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[HISTORY]));
|
||||
let handle = open_history(cx, factory.clone());
|
||||
wait_for_label(cx, handle, "history-summary", "共 3 条 · ~/.bash_history").await;
|
||||
assert!(factory.exec_commands()[0].contains(".bash_history"));
|
||||
|
||||
in_frame(cx, handle, |window, _| {
|
||||
let tops: Vec<_> = ["tail -f syslog", "ls -l", "cd /var/log"]
|
||||
.into_iter()
|
||||
.map(|command| window.find(entry(command)).bounds().top())
|
||||
.collect();
|
||||
assert!(tops.windows(2).all(|pair| pair[0] < pair[1]), "{tops:?}");
|
||||
let label = window.find(entry("ls -l")).label().unwrap().to_string();
|
||||
assert!(label.starts_with("ls -l · "), "{label}");
|
||||
assert!(label.ends_with(" · 执行 2 次"), "{label}");
|
||||
// Why the commands just run are not there yet.
|
||||
assert!(window.find("history-note").visible());
|
||||
});
|
||||
|
||||
// The search wants every word.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("history-search", cx);
|
||||
window.input("LS", cx);
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find(entry("ls -l")).is_some());
|
||||
assert!(window.try_find(entry("tail -f syslog")).is_none());
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.input(" kubectl", cx));
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find("history-empty").label(),
|
||||
Some("没有符合条件的命令")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn the_history_is_read_when_shown_and_on_refresh_only(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[HISTORY]));
|
||||
let handle = open_history(cx, factory.clone());
|
||||
wait_for_label(cx, handle, "history-summary", "共 3 条 · ~/.bash_history").await;
|
||||
cx.executor().advance_clock(Duration::from_secs(60));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(factory.exec_count(), 1);
|
||||
|
||||
in_frame(cx, handle, |window, cx| window.click("history-refresh", cx));
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| {
|
||||
factory.exec_count() == 2
|
||||
})
|
||||
.await;
|
||||
|
||||
// Another tool: nothing runs.
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx));
|
||||
cx.executor().advance_clock(Duration::from_secs(60));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(factory.exec_count(), 2);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_click_puts_a_command_on_the_terminals_line_and_run_runs_it(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[HISTORY]));
|
||||
let handle = open_history(cx, factory.clone());
|
||||
wait_for_label(cx, handle, "history-summary", "共 3 条 · ~/.bash_history").await;
|
||||
let typed_before = factory.written_text();
|
||||
|
||||
// On the line in place of what is typed there, not run; the terminal
|
||||
// takes the keyboard to edit it.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(entry("cd /var/log"), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find(("terminal", INITIAL_WEB_TERMINAL)).focused(),
|
||||
Some(true)
|
||||
);
|
||||
});
|
||||
let typed = factory.written_text();
|
||||
assert_eq!(&typed[typed_before.len()..], "\x05\x15cd /var/log");
|
||||
|
||||
// 执行 runs it, and only that: the card's click does not come too.
|
||||
let typed_before = typed;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(ElementId::Name("history-run:tail -f syslog".into()), cx)
|
||||
});
|
||||
let typed = factory.written_text();
|
||||
assert_eq!(&typed[typed_before.len()..], "\x05\x15tail -f syslog\r");
|
||||
|
||||
// The menu's 复制.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.dispatch_action(Box::new(CopyCommand("ls -l".into())), cx)
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read_from_clipboard().and_then(|item| item.text()),
|
||||
Some("ls -l".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_command_does_not_go_to_a_terminal_not_connected(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[HISTORY]));
|
||||
let handle = open_history(cx, factory.clone());
|
||||
wait_for_label(cx, handle, "history-summary", "共 3 条 · ~/.bash_history").await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(DisconnectTerminal(RemoteTerminalId(INITIAL_WEB_TERMINAL))),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let typed_before = factory.written_text();
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(EnterCommand {
|
||||
command: "ls -l".into(),
|
||||
run: true,
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
// Said in a notification, and nothing typed.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
assert_eq!(window.notifications(cx).len(), 1);
|
||||
});
|
||||
assert_eq!(factory.written_text(), typed_before);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_host_without_a_history_file_says_so(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&["@@missing\n"]));
|
||||
let handle = open_history(cx, factory);
|
||||
// Asked again once the shell runs.
|
||||
wait_for_label(
|
||||
cx,
|
||||
handle,
|
||||
"history-message",
|
||||
"这台主机上还没有 bash 的历史命令(~/.bash_history)。bash 退出时才会写入它。",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn the_history_is_not_offered_on_windows(cx: &mut TestAppContext) {
|
||||
let mut store = HostStore::seed();
|
||||
store.set_host_os_unnotified(HostId(WEB_01), Some(HostOs::Windows));
|
||||
let (handle, _) = open_workspace_with_store(cx, store);
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("tool-history").is_none());
|
||||
assert!(window.find("tool-snippets").visible());
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod connection;
|
||||
mod credential;
|
||||
mod docker;
|
||||
mod forward;
|
||||
mod history;
|
||||
mod host_dialog;
|
||||
mod host_tree;
|
||||
mod monitor;
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ async fn the_monitor_reads_the_host_only_while_it_shows(cx: &mut TestAppContext)
|
||||
.await;
|
||||
|
||||
// Nor while another tool is showing.
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-history", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx));
|
||||
let other = factory.exec_count();
|
||||
cx.executor().advance_clock(Duration::from_secs(10));
|
||||
cx.run_until_parked();
|
||||
|
||||
+1
-1
@@ -233,7 +233,7 @@ async fn the_connections_are_read_each_time_they_come_on_screen_and_only_then(
|
||||
.await;
|
||||
|
||||
// Another tool: nothing runs.
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-history", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx));
|
||||
cx.executor().advance_clock(Duration::from_secs(10));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(factory.exec_count(), 1);
|
||||
|
||||
+13
-16
@@ -757,17 +757,17 @@ fn the_tool_switch_shows_a_tool_and_hides_it_again(cx: &mut TestAppContext) {
|
||||
in_frame(cx, handle, |window, _| {
|
||||
// Hidden at first: the switch is there, none of it pressed.
|
||||
assert!(window.try_find(sidebar).is_none());
|
||||
assert_eq!(window.find("tool-history").checked(), Some(false));
|
||||
assert_eq!(window.find("tool-snippets").checked(), Some(false));
|
||||
});
|
||||
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-history", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx));
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(window.find(sidebar).label(), Some("历史命令"));
|
||||
assert_eq!(window.find(sidebar).label(), Some("命令片段"));
|
||||
assert_eq!(
|
||||
window.find("tool-placeholder").label(),
|
||||
Some("web-01 上执行过的命令,可以搜索、再次执行。")
|
||||
Some("常用的命令存在这里,点一下就发送到当前终端。")
|
||||
);
|
||||
assert_eq!(window.find("tool-history").checked(), Some(true));
|
||||
assert_eq!(window.find("tool-snippets").checked(), Some(true));
|
||||
// Between the center and the switch.
|
||||
let bounds = window.find(sidebar).bounds();
|
||||
let switch = window.find("tool-switch").bounds();
|
||||
@@ -781,7 +781,7 @@ fn the_tool_switch_shows_a_tool_and_hides_it_again(cx: &mut TestAppContext) {
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-docker", cx));
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(window.find(sidebar).label(), Some("Docker"));
|
||||
assert_eq!(window.find("tool-history").checked(), Some(false));
|
||||
assert_eq!(window.find("tool-snippets").checked(), Some(false));
|
||||
assert_eq!(window.find("tool-docker").checked(), Some(true));
|
||||
});
|
||||
|
||||
@@ -808,7 +808,7 @@ fn a_tool_follows_the_terminal_in_front_and_leaves_it_the_keyboard(cx: &mut Test
|
||||
window.activate_window();
|
||||
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx);
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-history", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx));
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(
|
||||
window
|
||||
@@ -828,10 +828,7 @@ fn a_tool_follows_the_terminal_in_front_and_leaves_it_the_keyboard(cx: &mut Test
|
||||
let sidebar = ("tool-sidebar", INITIAL_STAGING_TERMINAL);
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find(sidebar).is_some());
|
||||
assert_eq!(
|
||||
window.find("tool-placeholder").label(),
|
||||
Some("staging-api 上执行过的命令,可以搜索、再次执行。")
|
||||
);
|
||||
assert!(window.find("tool-placeholder").visible());
|
||||
});
|
||||
|
||||
// A click in the sidebar takes the focus there; hiding the sidebar
|
||||
@@ -842,7 +839,7 @@ fn a_tool_follows_the_terminal_in_front_and_leaves_it_the_keyboard(cx: &mut Test
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(window.find(sidebar).focused(), Some(true));
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-history", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx));
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find(sidebar).is_none());
|
||||
assert_eq!(
|
||||
@@ -861,7 +858,7 @@ async fn the_tool_sidebar_goes_with_ssh_terminals_only(cx: &mut TestAppContext)
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-history", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx));
|
||||
let shown = |cx: &mut TestAppContext| {
|
||||
in_frame(cx, handle, |window, _| {
|
||||
let switch = window.try_find("tool-switch").is_some();
|
||||
@@ -897,8 +894,8 @@ async fn the_tool_sidebar_goes_with_ssh_terminals_only(cx: &mut TestAppContext)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
let sidebar = window.find(("tool-sidebar", INITIAL_STAGING_TERMINAL));
|
||||
assert_eq!(sidebar.label(), Some("历史命令"));
|
||||
assert_eq!(window.find("tool-history").checked(), Some(true));
|
||||
assert_eq!(sidebar.label(), Some("命令片段"));
|
||||
assert_eq!(window.find("tool-snippets").checked(), Some(true));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -917,7 +914,7 @@ fn the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens(cx: &mut TestAp
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-history", cx));
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx));
|
||||
let sidebar = ("tool-sidebar", INITIAL_WEB_TERMINAL);
|
||||
let bounds =
|
||||
|cx: &mut TestAppContext| in_frame(cx, handle, |window, _| window.find(sidebar).bounds());
|
||||
|
||||
Reference in new issue
Block a user