命令片段:右侧栏的新工具,按分类保存常用命令,点一下输入或执行
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9143e387bc
commit
ee8441e301
28 files changed
+2760
-138
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` 为准。
|
||||
|
||||
@@ -42,7 +42,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
|
||||
|
||||
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
|
||||
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`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`)。
|
||||
- `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`)。
|
||||
- `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`:`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;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
|
||||
@@ -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`。
|
||||
- `snippets/` — 命令片段,右侧栏的第七个真工具,片段本身在 `HostStore` 里(`host::Snippet`),这里是列表和对话框:`model.rs`(`rows`:分类标题和片段排成列表的行,纯函数)、`snippet_panel.rs`(`SnippetPanel`:搜索框、gpui `list` 画的分类标题和卡片、挂在列表上的右键菜单,菜单在打开时读 store)、`snippet_dialog.rs`(`open_snippet_dialog(SnippetDialog::{New(分类), Edit(id)}, ..)` 和 `open_category_dialog(CategoryDialog::{New, Rename(id)}, ..)`)。依赖 `host` 和 `shared`,不依赖 `terminal`(输入到终端是工作区处理 `EnterCommand`)。
|
||||
- `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`)。
|
||||
@@ -114,11 +115,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`、`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,主机改名跟着变。
|
||||
- **右侧栏是右侧 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 })`,系统服务的命令也用它。
|
||||
- **历史命令(`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)`。
|
||||
- **历史命令(`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` 按第一项的高度排)。
|
||||
- **网络连接(`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` 上。
|
||||
@@ -128,7 +129,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **`ListItem` 里会增长的文字要零宽加裁剪。** `ListItem` 把内容包在两层 `w_full` 里、`suffix` 是它们的兄弟,整行靠内容的最小宽度收缩来给 `suffix` 让位。`flex_1().min_w_0()` 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 `suffix`(状态和启停按钮)挤出列表。转发行的文字列因此是 `w_0().flex_grow(1.).overflow_x_hidden()`(`the_forward_dialog_and_a_long_row_fit_the_smallest_window` 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:`RadioGroup::horizontal` 的那一行是 `flex_wrap`,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 `flex_1().min_w_0().self_stretch()`(那一行是 `items_center`,不拉伸的话高度不齐),卡片里那句用途同样是 `w_0().flex_grow(1.).overflow_x_hidden()`,放不下时在卡片内折行。`Radio` 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(`RADIO_DOT_COLUMN`:small 圆点 `size_3p5` 加 `gap_x_2`)拉回到圆点下面,占满卡片的整个宽度。对话框宽 `rems(42.)` 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 `Radio` 的圆点尺寸或间距时 `RADIO_DOT_COLUMN` 要跟着改,`the_three_kinds_of_forward_share_one_row` 查用途这一行的两端离卡片边缘的距离。
|
||||
- **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。
|
||||
- **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。
|
||||
- **表结构(`SCHEMA_VERSION` 是 13)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件;`STEPS` 目前是空的,`Step` 枚举留着给下一次改表用。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。
|
||||
- **表结构(`SCHEMA_VERSION` 是 14)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件。14 加了命令片段的 `snippet_categories` 和 `snippets`(`category_id` 级联;`scope_group_id` / `scope_host_id` 是预留的「适用范围」,都为空就是所有主机,最多一个有值,对 `groups` / `hosts` 级联,内存里 `remove_unnotified` / `remove_group_unnotified` 照样丢掉;最后一列 `run_on_click` 是「点击时自动执行」,在 14 发布之前加进来的,所以没有再升版本),`STEPS` 里是 13 → 14 这一步(`Step::Sql`,步骤是历史,`SCHEMA` 以后再改它也不跟着改),`a_version_13_file_gets_the_snippets_and_keeps_its_hosts` 用冻结的 13 版 schema 守着。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。
|
||||
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path())`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate` 后直接退出。
|
||||
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
|
||||
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
|
||||
@@ -193,4 +194,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`(系统监控)、`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
|
||||
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`,状态行的 `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-tooltiLine 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、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统服务、进程管理、网络连接和系统监控的图标;历史命令和 Docker 除了 Windows 主机都有(Mac 上装了 Docker Desktop 也能用)。
|
||||
|
||||
**命令片段**保存常用的命令,所有主机共用。点顶部的加号新建片段:名称、分类和命令,命令可以有多行。片段按分类分组显示,没有分类的放在最后的「未分类」下;点分类标题折叠或展开,右键分类标题可以在这个分类里新建片段、重命名或删除分类(分类里的片段会一起删除,确认时写明个数);顶部的文件夹按钮新建分类。可以按名称或命令搜索,输入几个词时列出同时包含这些词的片段。
|
||||
|
||||
点一个片段就在终端里执行它的命令(片段默认「点击时自动执行」)。鼠标移到片段上时,右边会显示点它会怎样:闪电表示点击就执行,▶ 表示点击只输入。在片段对话框里取消勾选「点击时自动执行」,点它就只把命令放进终端的输入行(替换掉已经输入的内容),可以修改后回车。点右边的闪电或 ▶ 总是直接执行;右键还可以输入到终端、复制命令、编辑和删除。终端里正在运行 vim、less 这类全屏程序时不会输入。Windows 主机上不会替换已经输入的内容,命令接在后面输入。
|
||||
|
||||
**历史命令**列出远程主机上 bash 的历史命令(`~/.bash_history`),最近执行的在最前,同一条命令只列一次,下面写着执行过几次;bash 记录了时间(设置了 `HISTTIMEFORMAT`)时还写明最近一次是什么时候,多行的命令也能认出来。可以搜索,输入几个词时列出同时包含这些词的命令,不分大小写。点一条命令把它放进终端的输入行(替换掉已经输入的内容,不执行),可以修改后回车;点右边的执行按钮直接执行;右键还可以复制。终端里正在运行 vim、less 这类全屏程序时不会输入。
|
||||
|
||||
|
||||
+21
-2
@@ -7,7 +7,9 @@ use crate::{
|
||||
cli::AgentKind,
|
||||
docker::{ContainerCommand, DockerObject},
|
||||
explorer::{ExplorerId, FileSizeFormat},
|
||||
host::{CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop},
|
||||
host::{
|
||||
CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop, SnippetCategoryId, SnippetId,
|
||||
},
|
||||
monitor::MonitorDetail,
|
||||
processes::ProcessSort,
|
||||
services::ServiceCommand,
|
||||
@@ -52,6 +54,10 @@ gpui_kit::actions!(
|
||||
RefreshDocker,
|
||||
/// Read the bash history of the SSH terminal's host again.
|
||||
RefreshHistory,
|
||||
/// Open the new-snippet dialog, for a snippet of no category.
|
||||
NewSnippet,
|
||||
/// Open the new-category dialog of 命令片段.
|
||||
NewSnippetCategory,
|
||||
/// Show the host list in the left dock.
|
||||
ShowHosts,
|
||||
/// Show the port-forwarding list in the left dock.
|
||||
@@ -211,8 +217,21 @@ 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.
|
||||
/// Copy a command of 历史命令 or 命令片段 to the clipboard.
|
||||
CopyCommand(String);
|
||||
/// Open the new-snippet dialog with this category picked.
|
||||
NewSnippetIn(SnippetCategoryId);
|
||||
/// Open the edit-snippet dialog.
|
||||
EditSnippet(SnippetId);
|
||||
/// Ask for confirmation, then delete a snippet.
|
||||
DeleteSnippet(SnippetId);
|
||||
/// Open the rename dialog of a snippet category.
|
||||
RenameSnippetCategory(SnippetCategoryId);
|
||||
/// Ask for confirmation, then delete a snippet category with the
|
||||
/// snippets in it.
|
||||
DeleteSnippetCategory(SnippetCategoryId);
|
||||
/// Fold a category of 命令片段 away, or unfold it; `None` is 未分类.
|
||||
ToggleSnippetCategory(Option<SnippetCategoryId>);
|
||||
|
||||
/// Start a port forward over a connection of its own.
|
||||
StartForward(ForwardId);
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@ icon_assets!(
|
||||
CircleCheck,
|
||||
Ban,
|
||||
CircleDot,
|
||||
FileText
|
||||
FileText,
|
||||
Zap
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ use gpui_kit::component::{
|
||||
menu::{ContextMenuExt as _, PopupMenu},
|
||||
scroll::ScrollableElement as _,
|
||||
spinner::Spinner,
|
||||
tooltip::Tooltip,
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
@@ -20,15 +19,12 @@ use gpui_kit::*;
|
||||
use super::bash::{self, Parsed};
|
||||
use super::model::{Entry, History};
|
||||
use crate::app::{CatalogIcon, CopyCommand, EnterCommand, RefreshHistory};
|
||||
use crate::shared::command_tooltip;
|
||||
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.
|
||||
@@ -459,7 +455,7 @@ fn render_entry(
|
||||
command: command.clone(),
|
||||
run: true,
|
||||
};
|
||||
let long = line.chars().count() > SHOWN_CHARACTERS || command.contains('\n');
|
||||
let tooltip = command_tooltip(&command, cx);
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
h_flex()
|
||||
.id(id.clone())
|
||||
@@ -494,20 +490,7 @@ fn render_entry(
|
||||
}
|
||||
})
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
.when_some(tooltip, |card, tooltip| card.tooltip(tooltip))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
@@ -523,9 +506,9 @@ fn render_entry(
|
||||
),
|
||||
)
|
||||
.child(
|
||||
// As tall as the card's two lines allow: an easy target.
|
||||
Button::new(SharedString::from(format!("history-run:{command}")))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(Icon::new(CatalogIcon::Play))
|
||||
.tooltip("执行")
|
||||
.accessibility_label("执行")
|
||||
|
||||
@@ -17,7 +17,7 @@ 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', " ↵ ")
|
||||
crate::shared::one_line(&self.command)
|
||||
}
|
||||
|
||||
/// 「3 分钟前 · 执行 5 次」, or without a time 「执行 5 次」.
|
||||
|
||||
+379
-6
@@ -15,7 +15,8 @@ use rusqlite::{Connection, params};
|
||||
use super::{
|
||||
AuthKind, BookmarkSide, Credential, CredentialDraft, CredentialId, CredentialKind,
|
||||
ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GroupDraft, GroupId, Host,
|
||||
HostDraft, HostGroup, HostId, HostOs, ProxyKind, ProxySettings, PublicId, Route,
|
||||
HostDraft, HostGroup, HostId, HostOs, ProxyKind, ProxySettings, PublicId, Route, Snippet,
|
||||
SnippetCategory, SnippetCategoryId, SnippetDraft, SnippetId, SnippetScope,
|
||||
};
|
||||
|
||||
/// SQLite's only integer type is `i64`, so the `u64` ids cross the boundary
|
||||
@@ -33,8 +34,9 @@ fn from_sql(id: i64) -> u64 {
|
||||
/// schema bumps it and gives `migrate` a step from the version before.
|
||||
///
|
||||
/// 13 is the schema as it was rebuilt before the first release. Versions up
|
||||
/// to 12 were development builds, and their files are refused.
|
||||
const SCHEMA_VERSION: i64 = 13;
|
||||
/// to 12 were development builds, and their files are refused. 14 adds the
|
||||
/// command snippets.
|
||||
const SCHEMA_VERSION: i64 = 14;
|
||||
|
||||
/// The whole schema, as a new database gets it.
|
||||
///
|
||||
@@ -66,6 +68,11 @@ const SCHEMA_VERSION: i64 = 13;
|
||||
/// host, the way WinSCP drops a site's bookmarks with the site.
|
||||
/// - **forwards** go with the host they go through. A dynamic forward has
|
||||
/// no target; the other two kinds must have one.
|
||||
/// - **snippet_categories** are one level of folders for **snippets**, and
|
||||
/// take their snippets with them. A snippet with neither scope column is
|
||||
/// for every host, which every snippet is for now; one kept to a group or
|
||||
/// a host will go with it. `run_on_click` runs it on a click rather than
|
||||
/// only typing it.
|
||||
///
|
||||
/// A column added later goes after the table's last column here and is
|
||||
/// added to existing files with `ALTER TABLE ADD COLUMN`, which puts it in
|
||||
@@ -147,11 +154,28 @@ CREATE TABLE forwards (
|
||||
OR (kind <> 'dynamic' AND target_host IS NOT NULL
|
||||
AND target_port BETWEEN 1 AND 65535))
|
||||
) STRICT;
|
||||
CREATE INDEX forwards_host_id ON forwards(host_id);";
|
||||
CREATE INDEX forwards_host_id ON forwards(host_id);
|
||||
CREATE TABLE snippet_categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL CHECK (name <> '')
|
||||
) STRICT;
|
||||
CREATE TABLE snippets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
category_id INTEGER REFERENCES snippet_categories(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL CHECK (name <> ''),
|
||||
command TEXT NOT NULL CHECK (command <> ''),
|
||||
scope_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||
scope_host_id INTEGER REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
run_on_click INTEGER NOT NULL DEFAULT 0 CHECK (run_on_click IN (0, 1)),
|
||||
CHECK (scope_group_id IS NULL OR scope_host_id IS NULL)
|
||||
) STRICT;
|
||||
CREATE INDEX snippets_category_id ON snippets(category_id);
|
||||
CREATE INDEX snippets_scope_group_id ON snippets(scope_group_id);
|
||||
CREATE INDEX snippets_scope_host_id ON snippets(scope_host_id);";
|
||||
|
||||
/// One step from a version to the next: one transaction that ends by
|
||||
/// recording the version it reached.
|
||||
// No step exists yet; the first change to `SCHEMA` adds one.
|
||||
// No step needs `Code` yet.
|
||||
#[allow(dead_code)]
|
||||
enum Step {
|
||||
/// Statements that make their own transaction.
|
||||
@@ -163,7 +187,35 @@ enum Step {
|
||||
/// The steps from each older version to the next, in order. A file older
|
||||
/// than the first of them, or than `SCHEMA_VERSION` while there are none,
|
||||
/// cannot be opened.
|
||||
const STEPS: [(i64, Step); 0] = [];
|
||||
///
|
||||
/// A step is history: it says what that version's change was, and stays as
|
||||
/// written when `SCHEMA` changes again.
|
||||
const STEPS: [(i64, Step); 1] = [(
|
||||
13,
|
||||
// The command snippets.
|
||||
Step::Sql(
|
||||
"BEGIN;
|
||||
CREATE TABLE snippet_categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL CHECK (name <> '')
|
||||
) STRICT;
|
||||
CREATE TABLE snippets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
category_id INTEGER REFERENCES snippet_categories(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL CHECK (name <> ''),
|
||||
command TEXT NOT NULL CHECK (command <> ''),
|
||||
scope_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||
scope_host_id INTEGER REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
run_on_click INTEGER NOT NULL DEFAULT 0 CHECK (run_on_click IN (0, 1)),
|
||||
CHECK (scope_group_id IS NULL OR scope_host_id IS NULL)
|
||||
) STRICT;
|
||||
CREATE INDEX snippets_category_id ON snippets(category_id);
|
||||
CREATE INDEX snippets_scope_group_id ON snippets(scope_group_id);
|
||||
CREATE INDEX snippets_scope_host_id ON snippets(scope_host_id);
|
||||
PRAGMA user_version = 14;
|
||||
COMMIT;",
|
||||
),
|
||||
)];
|
||||
|
||||
/// Everything one launch reads back from disk.
|
||||
pub struct StoredData {
|
||||
@@ -181,6 +233,10 @@ pub struct StoredData {
|
||||
pub forwards: Vec<ForwardRule>,
|
||||
/// Credentials in the order the credential list shows them.
|
||||
pub credentials: Vec<Credential>,
|
||||
/// Snippet categories in id order.
|
||||
pub snippet_categories: Vec<SnippetCategory>,
|
||||
/// Snippets in id order.
|
||||
pub snippets: Vec<Snippet>,
|
||||
}
|
||||
|
||||
pub struct HostDatabase {
|
||||
@@ -385,6 +441,47 @@ impl HostDatabase {
|
||||
.filter_map(Result::transpose)
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
let snippet_categories = self
|
||||
.connection
|
||||
.prepare("SELECT id, name FROM snippet_categories ORDER BY id")?
|
||||
.query_map([], |row| {
|
||||
Ok(SnippetCategory {
|
||||
id: SnippetCategoryId(from_sql(row.get(0)?)),
|
||||
name: row.get::<_, String>(1)?.into(),
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
let snippets = self
|
||||
.connection
|
||||
.prepare(
|
||||
"SELECT id, category_id, name, command, scope_group_id, scope_host_id, \
|
||||
run_on_click FROM snippets ORDER BY id",
|
||||
)?
|
||||
.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let category: Option<i64> = row.get(1)?;
|
||||
let name: String = row.get(2)?;
|
||||
let command: String = row.get(3)?;
|
||||
let group: Option<i64> = row.get(4)?;
|
||||
let host: Option<i64> = row.get(5)?;
|
||||
let run_on_click: bool = row.get(6)?;
|
||||
let mut draft = SnippetDraft::new(
|
||||
name,
|
||||
command,
|
||||
category.map(|id| SnippetCategoryId(from_sql(id))),
|
||||
)
|
||||
.with_run_on_click(run_on_click);
|
||||
// The table's CHECK keeps at most one of the two.
|
||||
draft.scope = match (group, host) {
|
||||
(Some(group), _) => SnippetScope::Group(GroupId(from_sql(group))),
|
||||
(None, Some(host)) => SnippetScope::Host(HostId(from_sql(host))),
|
||||
(None, None) => SnippetScope::All,
|
||||
};
|
||||
Ok(Snippet::new(SnippetId(from_sql(id)), draft))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(StoredData {
|
||||
groups,
|
||||
hosts,
|
||||
@@ -392,6 +489,8 @@ impl HostDatabase {
|
||||
bookmarks,
|
||||
forwards,
|
||||
credentials,
|
||||
snippet_categories,
|
||||
snippets,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -723,6 +822,73 @@ impl HostDatabase {
|
||||
transaction.commit()
|
||||
}
|
||||
|
||||
pub fn insert_snippet_category(&self, category: &SnippetCategory) -> rusqlite::Result<()> {
|
||||
self.connection.execute(
|
||||
"INSERT INTO snippet_categories (id, name) VALUES (?1, ?2)",
|
||||
params![to_sql(category.id.0), category.name.as_ref()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_snippet_category(&self, category: &SnippetCategory) -> rusqlite::Result<()> {
|
||||
self.connection.execute(
|
||||
"UPDATE snippet_categories SET name = ?2 WHERE id = ?1",
|
||||
params![to_sql(category.id.0), category.name.as_ref()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a category with the snippets in it: the foreign key cascades.
|
||||
pub fn remove_snippet_category(&self, id: SnippetCategoryId) -> rusqlite::Result<()> {
|
||||
self.connection.execute(
|
||||
"DELETE FROM snippet_categories WHERE id = ?1",
|
||||
params![to_sql(id.0)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_snippet(&self, snippet: &Snippet) -> rusqlite::Result<()> {
|
||||
let (group, host) = scope_columns(snippet.scope);
|
||||
self.connection.execute(
|
||||
"INSERT INTO snippets (id, category_id, name, command, scope_group_id, scope_host_id, \
|
||||
run_on_click) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
to_sql(snippet.id.0),
|
||||
snippet.category.map(|id| to_sql(id.0)),
|
||||
snippet.name.as_ref(),
|
||||
snippet.command,
|
||||
group,
|
||||
host,
|
||||
snippet.run_on_click,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_snippet(&self, snippet: &Snippet) -> rusqlite::Result<()> {
|
||||
let (group, host) = scope_columns(snippet.scope);
|
||||
self.connection.execute(
|
||||
"UPDATE snippets SET category_id = ?2, name = ?3, command = ?4, \
|
||||
scope_group_id = ?5, scope_host_id = ?6, run_on_click = ?7 WHERE id = ?1",
|
||||
params![
|
||||
to_sql(snippet.id.0),
|
||||
snippet.category.map(|id| to_sql(id.0)),
|
||||
snippet.name.as_ref(),
|
||||
snippet.command,
|
||||
group,
|
||||
host,
|
||||
snippet.run_on_click,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_snippet(&self, id: SnippetId) -> rusqlite::Result<()> {
|
||||
self.connection
|
||||
.execute("DELETE FROM snippets WHERE id = ?1", params![to_sql(id.0)])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the operating system a probe found on the host. `None` clears
|
||||
/// it, which is what a failed probe on a rebuilt host leaves behind.
|
||||
pub fn set_host_os(&self, id: HostId, os: Option<HostOs>) -> rusqlite::Result<()> {
|
||||
@@ -846,6 +1012,15 @@ fn migrate(connection: &Connection) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A snippet's scope as its two columns: the group's, the host's.
|
||||
fn scope_columns(scope: SnippetScope) -> (Option<i64>, Option<i64>) {
|
||||
match scope {
|
||||
SnippetScope::All => (None, None),
|
||||
SnippetScope::Group(group) => (Some(to_sql(group.0)), None),
|
||||
SnippetScope::Host(host) => (None, Some(to_sql(host.0))),
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that refuses a file without touching it.
|
||||
fn refusal(message: String) -> rusqlite::Error {
|
||||
rusqlite::Error::SqliteFailure(
|
||||
@@ -1032,6 +1207,204 @@ ALTER TABLE t ADD COLUMN notes TEXT NOT NULL DEFAULT '';",
|
||||
assert_eq!(schema_of(&old), schema_of(&new));
|
||||
}
|
||||
|
||||
/// The schema of version 13, as files of that version have it.
|
||||
const SCHEMA_13: &str = "\
|
||||
CREATE TABLE groups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
parent_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL CHECK (name <> ''),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
expanded INTEGER NOT NULL DEFAULT 1 CHECK (expanded IN (0, 1))
|
||||
) STRICT;
|
||||
CREATE INDEX groups_parent_id ON groups(parent_id);
|
||||
CREATE TABLE credentials (
|
||||
id INTEGER PRIMARY KEY,
|
||||
keychain_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL CHECK (name <> ''),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('password', 'key', 'agent')),
|
||||
username TEXT NOT NULL,
|
||||
key_path TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
CHECK ((kind = 'key') = (key_path IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
public_id TEXT UNIQUE,
|
||||
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL CHECK (name <> ''),
|
||||
address TEXT NOT NULL CHECK (address <> ''),
|
||||
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
|
||||
auth TEXT NOT NULL CHECK (auth IN ('password', 'no-password', 'credential')),
|
||||
username TEXT,
|
||||
credential_id INTEGER REFERENCES credentials(id),
|
||||
route TEXT NOT NULL DEFAULT 'direct' CHECK (route IN ('direct', 'jump', 'proxy')),
|
||||
proxy_kind TEXT CHECK (proxy_kind IN ('http', 'socks5')),
|
||||
proxy_host TEXT,
|
||||
proxy_port INTEGER CHECK (proxy_port BETWEEN 1 AND 65535),
|
||||
proxy_username TEXT,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
os TEXT,
|
||||
last_connected_at INTEGER,
|
||||
CHECK ((auth = 'credential') = (credential_id IS NOT NULL)),
|
||||
CHECK ((auth = 'credential') = (username IS NULL)),
|
||||
CHECK ((route = 'proxy') = (proxy_kind IS NOT NULL)),
|
||||
CHECK ((proxy_kind IS NULL) = (proxy_host IS NULL)
|
||||
AND (proxy_kind IS NULL) = (proxy_port IS NULL)),
|
||||
CHECK (proxy_kind IS NOT NULL OR proxy_username IS NULL)
|
||||
) STRICT;
|
||||
CREATE INDEX hosts_group_id ON hosts(group_id);
|
||||
CREATE INDEX hosts_credential_id ON hosts(credential_id);
|
||||
CREATE TABLE host_jumps (
|
||||
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
jump_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
|
||||
PRIMARY KEY (host_id, position),
|
||||
CHECK (jump_id <> host_id)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
CREATE INDEX host_jumps_jump_id ON host_jumps(jump_id);
|
||||
CREATE TABLE bookmarks (
|
||||
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
side TEXT NOT NULL CHECK (side IN ('local', 'remote')),
|
||||
path TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL,
|
||||
PRIMARY KEY (host_id, side, path)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
CREATE TABLE forwards (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL CHECK (kind IN ('local', 'remote', 'dynamic')),
|
||||
bind_host TEXT NOT NULL,
|
||||
bind_port INTEGER NOT NULL CHECK (bind_port BETWEEN 1 AND 65535),
|
||||
target_host TEXT,
|
||||
target_port INTEGER,
|
||||
auto_start INTEGER NOT NULL DEFAULT 0 CHECK (auto_start IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
CHECK ((kind = 'dynamic' AND target_host IS NULL AND target_port IS NULL)
|
||||
OR (kind <> 'dynamic' AND target_host IS NOT NULL
|
||||
AND target_port BETWEEN 1 AND 65535))
|
||||
) STRICT;
|
||||
CREATE INDEX forwards_host_id ON forwards(host_id);";
|
||||
|
||||
#[test]
|
||||
fn a_version_13_file_gets_the_snippets_and_keeps_its_hosts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("shellrs.db");
|
||||
{
|
||||
let old = Connection::open(&path).unwrap();
|
||||
old.execute_batch(&format!(
|
||||
"BEGIN;\n{SCHEMA_13}\nPRAGMA user_version = 13;\nCOMMIT;"
|
||||
))
|
||||
.unwrap();
|
||||
old.execute(
|
||||
"INSERT INTO hosts (id, name, address, port, auth, username) \
|
||||
VALUES (1, 'web', '10.0.0.1', 22, 'password', 'root')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = HostDatabase::open(&path).unwrap();
|
||||
let version: i64 = db
|
||||
.connection
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(version, SCHEMA_VERSION);
|
||||
assert!(dir.path().join("shellrs.db.v13.bak").exists());
|
||||
let fresh = HostDatabase::in_memory().unwrap();
|
||||
assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection));
|
||||
let data = db.load().unwrap();
|
||||
assert_eq!(data.hosts.len(), 1);
|
||||
assert!(data.snippets.is_empty());
|
||||
}
|
||||
|
||||
fn snippet(id: u64, name: &str, category: Option<u64>, scope: SnippetScope) -> Snippet {
|
||||
let mut draft = SnippetDraft::new(
|
||||
name,
|
||||
format!("echo {name}"),
|
||||
category.map(SnippetCategoryId),
|
||||
);
|
||||
draft.scope = scope;
|
||||
Snippet::new(SnippetId(id), draft)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippets_round_trip_and_go_with_their_category() {
|
||||
let db = HostDatabase::in_memory().unwrap();
|
||||
let docker = SnippetCategory {
|
||||
id: SnippetCategoryId(1),
|
||||
name: "Docker".into(),
|
||||
};
|
||||
db.insert_snippet_category(&docker).unwrap();
|
||||
let mut ps = snippet(1, "列出容器", Some(1), SnippetScope::All);
|
||||
ps.command = "docker ps -a\ndocker images".into();
|
||||
db.insert_snippet(&ps).unwrap();
|
||||
db.insert_snippet(&snippet(2, "磁盘", None, SnippetScope::All))
|
||||
.unwrap();
|
||||
let data = db.load().unwrap();
|
||||
assert_eq!(data.snippet_categories, std::slice::from_ref(&docker));
|
||||
assert_eq!(data.snippets[0], ps);
|
||||
assert_eq!(data.snippets[1].category, None);
|
||||
|
||||
db.update_snippet_category(&SnippetCategory {
|
||||
name: "容器".into(),
|
||||
..docker
|
||||
})
|
||||
.unwrap();
|
||||
ps.name = "所有容器".into();
|
||||
ps.category = None;
|
||||
ps.run_on_click = true;
|
||||
db.update_snippet(&ps).unwrap();
|
||||
let data = db.load().unwrap();
|
||||
assert_eq!(data.snippet_categories[0].name, "容器");
|
||||
assert_eq!(data.snippets[0], ps);
|
||||
|
||||
db.insert_snippet(&snippet(3, "日志", Some(1), SnippetScope::All))
|
||||
.unwrap();
|
||||
db.remove_snippet_category(SnippetCategoryId(1)).unwrap();
|
||||
db.remove_snippet(SnippetId(2)).unwrap();
|
||||
let names: Vec<_> = db
|
||||
.load()
|
||||
.unwrap()
|
||||
.snippets
|
||||
.into_iter()
|
||||
.map(|snippet| snippet.name)
|
||||
.collect();
|
||||
assert_eq!(names, ["所有容器"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snippet_kept_to_a_group_or_a_host_goes_with_it() {
|
||||
let db = HostDatabase::in_memory().unwrap();
|
||||
db.insert_group(&group(1, "生产", None)).unwrap();
|
||||
db.insert_group(&group(2, "web", Some(1))).unwrap();
|
||||
db.insert_host(&host(1, "web-01", Some(2))).unwrap();
|
||||
db.insert_host(&host(2, "db-01", None)).unwrap();
|
||||
let scoped = [
|
||||
snippet(1, "全部", None, SnippetScope::All),
|
||||
snippet(2, "分组", None, SnippetScope::Group(GroupId(2))),
|
||||
snippet(3, "主机", None, SnippetScope::Host(HostId(2))),
|
||||
snippet(4, "分组里的主机", None, SnippetScope::Host(HostId(1))),
|
||||
];
|
||||
for snippet in &scoped {
|
||||
db.insert_snippet(snippet).unwrap();
|
||||
}
|
||||
assert_eq!(db.load().unwrap().snippets, scoped);
|
||||
|
||||
db.remove_host(HostId(2)).unwrap();
|
||||
// The group's subgroup and the host in it go, and so do theirs.
|
||||
db.remove_group(GroupId(1)).unwrap();
|
||||
let left: Vec<_> = db
|
||||
.load()
|
||||
.unwrap()
|
||||
.snippets
|
||||
.into_iter()
|
||||
.map(|snippet| snippet.id)
|
||||
.collect();
|
||||
assert_eq!(left, [SnippetId(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_database_from_an_early_development_build_is_refused_untouched() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -12,6 +12,7 @@ mod model;
|
||||
mod outline;
|
||||
mod private_key;
|
||||
mod secret_fields;
|
||||
mod snippet;
|
||||
mod store;
|
||||
|
||||
pub use credential::*;
|
||||
@@ -25,4 +26,5 @@ pub use model::*;
|
||||
pub use outline::{HostNode, NodeDrop, group_options, host_tree_items, matches_query};
|
||||
pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key};
|
||||
pub use secret_fields::SecretFields;
|
||||
pub use snippet::*;
|
||||
pub use store::{HostStore, HostStoreEvent};
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Command snippets: the commands 命令片段 keeps, in categories one level
|
||||
//! deep. They live with the hosts because they are stored in the same
|
||||
//! database, and a snippet may one day be kept to a group or a host and go
|
||||
//! with it; the `snippets` module that shows them depends on this one.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use gpui_kit::SharedString;
|
||||
|
||||
use super::{GroupId, HostId};
|
||||
|
||||
/// Stable identity of a snippet. Never reused within a process.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct SnippetId(pub u64);
|
||||
|
||||
/// Stable identity of a snippet category. Never reused within a process.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct SnippetCategoryId(pub u64);
|
||||
|
||||
/// Where a snippet is offered. Every snippet is for every host for now;
|
||||
/// the database has room for one kept to a group (with its subgroups) or
|
||||
/// to a host, which goes when they do, and nothing sets that yet.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum SnippetScope {
|
||||
#[default]
|
||||
All,
|
||||
Group(GroupId),
|
||||
Host(HostId),
|
||||
}
|
||||
|
||||
/// What the snippet form edits.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SnippetDraft {
|
||||
pub name: SharedString,
|
||||
/// What it types into the terminal, one line or several.
|
||||
pub command: String,
|
||||
/// `None` is 未分类.
|
||||
pub category: Option<SnippetCategoryId>,
|
||||
pub scope: SnippetScope,
|
||||
/// Whether a click on it runs it, rather than only putting it on the
|
||||
/// terminal's line.
|
||||
pub run_on_click: bool,
|
||||
}
|
||||
|
||||
impl SnippetDraft {
|
||||
pub fn new(
|
||||
name: impl Into<SharedString>,
|
||||
command: impl Into<String>,
|
||||
category: Option<SnippetCategoryId>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
command: command.into(),
|
||||
category,
|
||||
scope: SnippetScope::All,
|
||||
run_on_click: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_run_on_click(mut self, run_on_click: bool) -> Self {
|
||||
self.run_on_click = run_on_click;
|
||||
self
|
||||
}
|
||||
|
||||
/// The draft as the form's fields give it, tidied, or why it cannot be
|
||||
/// saved. Blank lines and spaces around the command go; inside it,
|
||||
/// every line stays as written.
|
||||
pub fn validated(
|
||||
name: &str,
|
||||
command: &str,
|
||||
category: Option<SnippetCategoryId>,
|
||||
) -> Result<Self, &'static str> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("请输入名称");
|
||||
}
|
||||
let command = command.trim_end().trim_start_matches(['\n', '\r']);
|
||||
if command.trim().is_empty() {
|
||||
return Err("请输入命令");
|
||||
}
|
||||
Ok(Self::new(name.to_owned(), command.to_owned(), category))
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved command.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Snippet {
|
||||
pub id: SnippetId,
|
||||
pub name: SharedString,
|
||||
pub command: String,
|
||||
pub category: Option<SnippetCategoryId>,
|
||||
pub scope: SnippetScope,
|
||||
pub run_on_click: bool,
|
||||
}
|
||||
|
||||
impl Snippet {
|
||||
pub fn new(id: SnippetId, draft: SnippetDraft) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: draft.name,
|
||||
command: draft.command,
|
||||
category: draft.category,
|
||||
scope: draft.scope,
|
||||
run_on_click: draft.run_on_click,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draft(&self) -> SnippetDraft {
|
||||
SnippetDraft {
|
||||
name: self.name.clone(),
|
||||
command: self.command.clone(),
|
||||
category: self.category,
|
||||
scope: self.scope,
|
||||
run_on_click: self.run_on_click,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the snippet holds every word of `query` in its name or its
|
||||
/// command, regardless of case.
|
||||
pub fn matches(&self, query: &str) -> bool {
|
||||
let name = self.name.to_lowercase();
|
||||
let command = self.command.to_lowercase();
|
||||
query.split_whitespace().all(|word| {
|
||||
let word = word.to_lowercase();
|
||||
name.contains(&word) || command.contains(&word)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder of snippets: one level, no nesting.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SnippetCategory {
|
||||
pub id: SnippetCategoryId,
|
||||
pub name: SharedString,
|
||||
}
|
||||
|
||||
/// The order snippets and categories are listed in: by name, regardless of
|
||||
/// case, then by when they were made.
|
||||
pub fn by_name(a: (&str, u64), b: (&str, u64)) -> Ordering {
|
||||
a.0.to_lowercase()
|
||||
.cmp(&b.0.to_lowercase())
|
||||
.then(a.0.cmp(b.0))
|
||||
.then(a.1.cmp(&b.1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_snippet_needs_a_name_and_a_command() {
|
||||
assert_eq!(SnippetDraft::validated(" ", "ls", None), Err("请输入名称"));
|
||||
assert_eq!(
|
||||
SnippetDraft::validated("列表", " \n \n", None),
|
||||
Err("请输入命令")
|
||||
);
|
||||
let draft = SnippetDraft::validated(
|
||||
" 清理日志 ",
|
||||
"\n\n find /var/log -mtime +7\n -delete \n\n",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(draft.name, "清理日志");
|
||||
assert_eq!(draft.command, " find /var/log -mtime +7\n -delete");
|
||||
assert_eq!(draft.scope, SnippetScope::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_looks_in_the_name_and_the_command() {
|
||||
let snippet = Snippet::new(
|
||||
SnippetId(1),
|
||||
SnippetDraft::new("查看容器", "docker ps -a\ndocker images", None),
|
||||
);
|
||||
assert!(snippet.matches(""));
|
||||
assert!(snippet.matches("容器"));
|
||||
assert!(snippet.matches("DOCKER images"));
|
||||
assert!(snippet.matches("容器 ps"));
|
||||
assert!(!snippet.matches("kubectl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_sort_regardless_of_case() {
|
||||
let mut names = vec![("nginx", 1), ("Docker", 2), ("apt", 3), ("docker", 4)];
|
||||
names.sort_by(|a, b| by_name(*a, *b));
|
||||
assert_eq!(
|
||||
names,
|
||||
[("apt", 3), ("Docker", 2), ("docker", 4), ("nginx", 1)]
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
-3
@@ -13,12 +13,14 @@ use super::{
|
||||
AuthKind, BookmarkSide, ConnectionState, Credential, CredentialDraft, CredentialId,
|
||||
CredentialKind, ForwardDraft, ForwardId, ForwardRule, GroupDraft, GroupId, Host, HostDatabase,
|
||||
HostDraft, HostGroup, HostId, HostLogin, HostNode, HostOs, JumpLogin, LoginRoute, NodeDrop,
|
||||
ProxyLogin, PublicId, Route, StoredData,
|
||||
ProxyLogin, PublicId, Route, Snippet, SnippetCategory, SnippetScope, StoredData,
|
||||
};
|
||||
|
||||
mod snippets;
|
||||
|
||||
/// The single source of truth for hosts, groups, the rules that belong
|
||||
/// to hosts (bookmarks, port forwards) and the credentials hosts log in
|
||||
/// with. Created once by the workspace and shared with every panel and
|
||||
/// to hosts (bookmarks, port forwards), the credentials hosts log in with
|
||||
/// and the command snippets. Created once by the workspace and shared with every panel and
|
||||
/// dialog; consumers observe it.
|
||||
///
|
||||
/// A host using a credential keeps a copy of the credential's user name,
|
||||
@@ -56,6 +58,11 @@ pub struct HostStore {
|
||||
/// Credentials, in the order the credential list shows them.
|
||||
credentials: Vec<Credential>,
|
||||
next_credential_id: u64,
|
||||
/// Command snippets and their categories, in the order they were made.
|
||||
snippets: Vec<Snippet>,
|
||||
next_snippet_id: u64,
|
||||
snippet_categories: Vec<SnippetCategory>,
|
||||
next_snippet_category_id: u64,
|
||||
/// `None` for a memory-only store, as used by tests.
|
||||
database: Option<HostDatabase>,
|
||||
/// Where passwords go. Defaults to a store that keeps nothing, so unit
|
||||
@@ -101,6 +108,10 @@ impl HostStore {
|
||||
next_forward_id: 1,
|
||||
credentials: Vec::new(),
|
||||
next_credential_id: 1,
|
||||
snippets: Vec::new(),
|
||||
next_snippet_id: 1,
|
||||
snippet_categories: Vec::new(),
|
||||
next_snippet_category_id: 1,
|
||||
database: None,
|
||||
secrets: Arc::new(NoSecretStore),
|
||||
key_dir: None,
|
||||
@@ -131,6 +142,8 @@ impl HostStore {
|
||||
bookmarks: stored_bookmarks,
|
||||
forwards,
|
||||
credentials,
|
||||
snippet_categories,
|
||||
snippets,
|
||||
} = database.load()?;
|
||||
recent.truncate(MAX_RECENT);
|
||||
let mut bookmarks: HashMap<_, Vec<String>> = HashMap::new();
|
||||
@@ -150,8 +163,17 @@ impl HostStore {
|
||||
next_host_id: hosts.iter().map(|s| s.id.0).max().unwrap_or(0) + 1,
|
||||
next_forward_id: forwards.iter().map(|rule| rule.id.0).max().unwrap_or(0) + 1,
|
||||
next_credential_id: credentials.iter().map(|c| c.id.0).max().unwrap_or(0) + 1,
|
||||
next_snippet_id: snippets.iter().map(|s| s.id.0).max().unwrap_or(0) + 1,
|
||||
next_snippet_category_id: snippet_categories
|
||||
.iter()
|
||||
.map(|category| category.id.0)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ 1,
|
||||
forwards,
|
||||
credentials,
|
||||
snippets,
|
||||
snippet_categories,
|
||||
groups,
|
||||
hosts,
|
||||
active: None,
|
||||
@@ -429,6 +451,8 @@ impl HostStore {
|
||||
// The database drops both through `ON DELETE CASCADE`.
|
||||
self.bookmarks.retain(|(host, _), _| *host != id);
|
||||
self.forwards.retain(|rule| rule.host != id);
|
||||
self.snippets
|
||||
.retain(|snippet| snippet.scope != SnippetScope::Host(id));
|
||||
if self.active == Some(id) {
|
||||
self.active = None;
|
||||
}
|
||||
@@ -1121,6 +1145,11 @@ impl HostStore {
|
||||
let mut doomed = self.descendant_groups(id);
|
||||
doomed.push(id);
|
||||
self.groups.retain(|group| !doomed.contains(&group.id));
|
||||
// As the database's `ON DELETE CASCADE` does.
|
||||
self.snippets.retain(|snippet| match snippet.scope {
|
||||
SnippetScope::Group(group) => !doomed.contains(&group),
|
||||
_ => true,
|
||||
});
|
||||
for host in &removed {
|
||||
self.remove_unnotified(*host);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! The command snippets' part of `HostStore`: the snippets and their
|
||||
//! categories, written through like everything else.
|
||||
|
||||
use gpui_kit::{Context, SharedString};
|
||||
|
||||
use super::HostStore;
|
||||
use crate::host::{
|
||||
Snippet, SnippetCategory, SnippetCategoryId, SnippetDraft, SnippetId, SnippetScope,
|
||||
};
|
||||
|
||||
impl HostStore {
|
||||
/// Every snippet, in the order they were made.
|
||||
pub fn snippets(&self) -> &[Snippet] {
|
||||
&self.snippets
|
||||
}
|
||||
|
||||
pub fn snippet(&self, id: SnippetId) -> Option<&Snippet> {
|
||||
self.snippets.iter().find(|snippet| snippet.id == id)
|
||||
}
|
||||
|
||||
/// Every snippet category, in the order they were made.
|
||||
pub fn snippet_categories(&self) -> &[SnippetCategory] {
|
||||
&self.snippet_categories
|
||||
}
|
||||
|
||||
pub fn snippet_category(&self, id: SnippetCategoryId) -> Option<&SnippetCategory> {
|
||||
self.snippet_categories
|
||||
.iter()
|
||||
.find(|category| category.id == id)
|
||||
}
|
||||
|
||||
/// How many snippets are in `category`: what deleting it takes along.
|
||||
pub fn snippets_in(&self, category: SnippetCategoryId) -> usize {
|
||||
self.snippets
|
||||
.iter()
|
||||
.filter(|snippet| snippet.category == Some(category))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn insert_snippet(&mut self, draft: SnippetDraft, cx: &mut Context<Self>) -> SnippetId {
|
||||
let id = self.insert_snippet_unnotified(draft);
|
||||
if let Some(snippet) = self.snippet(id) {
|
||||
self.persist("新建命令片段", cx, |db| db.insert_snippet(snippet));
|
||||
}
|
||||
cx.notify();
|
||||
id
|
||||
}
|
||||
|
||||
pub fn insert_snippet_unnotified(&mut self, draft: SnippetDraft) -> SnippetId {
|
||||
let id = SnippetId(self.next_snippet_id);
|
||||
self.next_snippet_id += 1;
|
||||
let draft = self.normalized_snippet(draft);
|
||||
self.snippets.push(Snippet::new(id, draft));
|
||||
id
|
||||
}
|
||||
|
||||
pub fn update_snippet(
|
||||
&mut self,
|
||||
id: SnippetId,
|
||||
draft: SnippetDraft,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if !self.update_snippet_unnotified(id, draft) {
|
||||
return false;
|
||||
}
|
||||
if let Some(snippet) = self.snippet(id) {
|
||||
self.persist("保存命令片段", cx, |db| db.update_snippet(snippet));
|
||||
}
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn update_snippet_unnotified(&mut self, id: SnippetId, draft: SnippetDraft) -> bool {
|
||||
let draft = self.normalized_snippet(draft);
|
||||
let Some(snippet) = self.snippets.iter_mut().find(|snippet| snippet.id == id) else {
|
||||
return false;
|
||||
};
|
||||
*snippet = Snippet::new(id, draft);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn remove_snippet(&mut self, id: SnippetId, cx: &mut Context<Self>) -> bool {
|
||||
if !self.remove_snippet_unnotified(id) {
|
||||
return false;
|
||||
}
|
||||
self.persist("删除命令片段", cx, |db| db.remove_snippet(id));
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn remove_snippet_unnotified(&mut self, id: SnippetId) -> bool {
|
||||
let before = self.snippets.len();
|
||||
self.snippets.retain(|snippet| snippet.id != id);
|
||||
self.snippets.len() != before
|
||||
}
|
||||
|
||||
pub fn insert_snippet_category(
|
||||
&mut self,
|
||||
name: impl Into<SharedString>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> SnippetCategoryId {
|
||||
let id = self.insert_snippet_category_unnotified(name);
|
||||
if let Some(category) = self.snippet_category(id) {
|
||||
self.persist("新建片段分类", cx, |db| {
|
||||
db.insert_snippet_category(category)
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
id
|
||||
}
|
||||
|
||||
pub fn insert_snippet_category_unnotified(
|
||||
&mut self,
|
||||
name: impl Into<SharedString>,
|
||||
) -> SnippetCategoryId {
|
||||
let id = SnippetCategoryId(self.next_snippet_category_id);
|
||||
self.next_snippet_category_id += 1;
|
||||
self.snippet_categories.push(SnippetCategory {
|
||||
id,
|
||||
name: name.into(),
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
pub fn rename_snippet_category(
|
||||
&mut self,
|
||||
id: SnippetCategoryId,
|
||||
name: impl Into<SharedString>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if !self.rename_snippet_category_unnotified(id, name) {
|
||||
return false;
|
||||
}
|
||||
if let Some(category) = self.snippet_category(id) {
|
||||
self.persist("重命名片段分类", cx, |db| {
|
||||
db.update_snippet_category(category)
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn rename_snippet_category_unnotified(
|
||||
&mut self,
|
||||
id: SnippetCategoryId,
|
||||
name: impl Into<SharedString>,
|
||||
) -> bool {
|
||||
let Some(category) = self
|
||||
.snippet_categories
|
||||
.iter_mut()
|
||||
.find(|category| category.id == id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
category.name = name.into();
|
||||
true
|
||||
}
|
||||
|
||||
/// Delete a category with the snippets in it.
|
||||
pub fn remove_snippet_category(
|
||||
&mut self,
|
||||
id: SnippetCategoryId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if !self.remove_snippet_category_unnotified(id) {
|
||||
return false;
|
||||
}
|
||||
// The database takes the snippets through `ON DELETE CASCADE`.
|
||||
self.persist("删除片段分类", cx, |db| {
|
||||
db.remove_snippet_category(id)
|
||||
});
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn remove_snippet_category_unnotified(&mut self, id: SnippetCategoryId) -> bool {
|
||||
let before = self.snippet_categories.len();
|
||||
self.snippet_categories.retain(|category| category.id != id);
|
||||
if self.snippet_categories.len() == before {
|
||||
return false;
|
||||
}
|
||||
self.snippets.retain(|snippet| snippet.category != Some(id));
|
||||
true
|
||||
}
|
||||
|
||||
/// A draft that names a category, a group or a host no longer there
|
||||
/// is saved without it: 未分类, for every host.
|
||||
fn normalized_snippet(&self, mut draft: SnippetDraft) -> SnippetDraft {
|
||||
if draft
|
||||
.category
|
||||
.is_some_and(|category| self.snippet_category(category).is_none())
|
||||
{
|
||||
draft.category = None;
|
||||
}
|
||||
let gone = match draft.scope {
|
||||
SnippetScope::All => false,
|
||||
SnippetScope::Group(group) => self.group(group).is_none(),
|
||||
SnippetScope::Host(host) => self.host(host).is_none(),
|
||||
};
|
||||
if gone {
|
||||
draft.scope = SnippetScope::All;
|
||||
}
|
||||
draft
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host::{AuthKind, GroupDraft, Host, HostDatabase, HostDraft, HostId};
|
||||
|
||||
fn draft(name: &str, category: Option<SnippetCategoryId>) -> SnippetDraft {
|
||||
SnippetDraft::new(name, format!("echo {name}"), category)
|
||||
}
|
||||
|
||||
fn names(store: &HostStore) -> Vec<String> {
|
||||
store
|
||||
.snippets()
|
||||
.iter()
|
||||
.map(|snippet| snippet.name.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_category_takes_its_snippets_with_it() {
|
||||
let mut store = HostStore::empty();
|
||||
let docker = store.insert_snippet_category_unnotified("Docker");
|
||||
let logs = store.insert_snippet_category_unnotified("日志");
|
||||
store.insert_snippet_unnotified(draft("ps", Some(docker)));
|
||||
store.insert_snippet_unnotified(draft("tail", Some(logs)));
|
||||
store.insert_snippet_unnotified(draft("df", None));
|
||||
assert_eq!(store.snippets_in(docker), 1);
|
||||
|
||||
assert!(store.remove_snippet_category_unnotified(docker));
|
||||
assert_eq!(names(&store), ["tail", "df"]);
|
||||
assert!(!store.remove_snippet_category_unnotified(docker));
|
||||
// A draft naming it now goes to 未分类.
|
||||
let moved = store.insert_snippet_unnotified(draft("images", Some(docker)));
|
||||
assert_eq!(store.snippet(moved).unwrap().category, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snippet_is_edited_in_place_and_removed() {
|
||||
let mut store = HostStore::empty();
|
||||
let ps = store.insert_snippet_unnotified(draft("ps", None));
|
||||
let df = store.insert_snippet_unnotified(draft("df", None));
|
||||
assert!(store.update_snippet_unnotified(ps, SnippetDraft::new("容器", "docker ps", None)));
|
||||
assert_eq!(store.snippet(ps).unwrap().command, "docker ps");
|
||||
assert_eq!(names(&store), ["容器", "df"]);
|
||||
assert!(store.remove_snippet_unnotified(df));
|
||||
assert!(!store.update_snippet_unnotified(df, draft("df", None)));
|
||||
assert_eq!(names(&store), ["容器"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snippet_kept_to_a_group_or_a_host_goes_with_it() {
|
||||
let mut store = HostStore::empty();
|
||||
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
|
||||
let web = store.insert_group_unnotified(GroupDraft::new("web", Some(production)));
|
||||
let host = store.insert_unnotified(HostDraft::new(
|
||||
"db-01",
|
||||
"10.0.0.2",
|
||||
22,
|
||||
"root",
|
||||
AuthKind::Password,
|
||||
None,
|
||||
));
|
||||
let scoped = |name: &str, scope| SnippetDraft {
|
||||
scope,
|
||||
..draft(name, None)
|
||||
};
|
||||
store.insert_snippet_unnotified(scoped("all", SnippetScope::All));
|
||||
store.insert_snippet_unnotified(scoped("web", SnippetScope::Group(web)));
|
||||
store.insert_snippet_unnotified(scoped("db", SnippetScope::Host(host)));
|
||||
|
||||
store.remove_unnotified(host);
|
||||
assert_eq!(names(&store), ["all", "web"]);
|
||||
// A subgroup goes with its group, and its snippets with it.
|
||||
store.remove_group_unnotified(production);
|
||||
assert_eq!(names(&store), ["all"]);
|
||||
// Kept to what is not there, a snippet is for every host.
|
||||
let stray = store.insert_snippet_unnotified(scoped("stray", SnippetScope::Host(host)));
|
||||
assert_eq!(store.snippet(stray).unwrap().scope, SnippetScope::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_resumes_the_snippet_id_sequences() {
|
||||
let database = HostDatabase::in_memory().unwrap();
|
||||
database
|
||||
.insert_host(&Host::new(
|
||||
HostId(1),
|
||||
HostDraft::new("web", "10.0.0.1", 22, "root", AuthKind::Password, None),
|
||||
))
|
||||
.unwrap();
|
||||
database
|
||||
.insert_snippet_category(&SnippetCategory {
|
||||
id: SnippetCategoryId(3),
|
||||
name: "Docker".into(),
|
||||
})
|
||||
.unwrap();
|
||||
database
|
||||
.insert_snippet(&Snippet::new(
|
||||
SnippetId(7),
|
||||
draft("ps", Some(SnippetCategoryId(3))),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let mut store = HostStore::load(database).unwrap();
|
||||
assert_eq!(names(&store), ["ps"]);
|
||||
assert_eq!(
|
||||
store.insert_snippet_category_unnotified("日志"),
|
||||
SnippetCategoryId(4)
|
||||
);
|
||||
assert_eq!(
|
||||
store.insert_snippet_unnotified(draft("df", None)),
|
||||
SnippetId(8)
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -4,7 +4,7 @@
|
||||
//! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
|
||||
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
|
||||
//! (凭据), `docker` (Docker), `monitor` (系统监控), `services` (系统服务), `processes` (进程管理),
|
||||
//! `netstat` (网络连接), `history` (历史命令),
|
||||
//! `netstat` (网络连接), `history` (历史命令), `snippets` (命令片段),
|
||||
//! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与
|
||||
//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源).
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod services;
|
||||
pub mod settings;
|
||||
pub mod sftp;
|
||||
pub mod shared;
|
||||
pub mod snippets;
|
||||
pub mod ssh;
|
||||
pub mod terminal;
|
||||
pub mod update;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! A shell command shown on one line of a card: 历史命令's and 命令片段's.
|
||||
|
||||
use gpui_kit::component::{ActiveTheme as _, tooltip::Tooltip};
|
||||
use gpui_kit::*;
|
||||
|
||||
/// About as many characters as a command line shows in a right-sidebar
|
||||
/// card at its narrowest; a longer one has the whole of it in a tooltip.
|
||||
const SHOWN_CHARACTERS: usize = 32;
|
||||
|
||||
/// `command` on one line: a command of several lines shows its line breaks
|
||||
/// as 「↵」.
|
||||
pub fn one_line(command: &str) -> String {
|
||||
command.replace('\n', " ↵ ")
|
||||
}
|
||||
|
||||
/// The tooltip that shows the whole of `command`, when its line is likely
|
||||
/// cut short; `None` for one that fits, which would only say it again.
|
||||
pub fn command_tooltip(
|
||||
command: &str,
|
||||
cx: &App,
|
||||
) -> Option<impl Fn(&mut Window, &mut App) -> AnyView + 'static> {
|
||||
if command.chars().count() <= SHOWN_CHARACTERS && !command.contains('\n') {
|
||||
return None;
|
||||
}
|
||||
let command = SharedString::from(command.to_owned());
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
Some(move |window: &mut Window, cx: &mut App| {
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Small presentation pieces shared by more than one feature.
|
||||
|
||||
mod closable_tab;
|
||||
mod command_line;
|
||||
mod count_tabs;
|
||||
mod dialog;
|
||||
mod format;
|
||||
@@ -13,6 +14,7 @@ mod tab_menu;
|
||||
mod tint;
|
||||
|
||||
pub use closable_tab::ClosableTabTitle;
|
||||
pub use command_line::{command_tooltip, one_line};
|
||||
pub use count_tabs::count_tabs;
|
||||
pub use dialog::{
|
||||
DeleteHandler, commit_footer, confirm_danger, confirm_delete, dismiss_form_error,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//! 命令片段: the right sidebar's tool that keeps commands for every host,
|
||||
//! in categories one level deep, and puts them on the input line of the
|
||||
//! SSH terminal in front. The snippets themselves are in the host store
|
||||
//! (`host::Snippet`); this is their list and their dialogs.
|
||||
|
||||
mod model;
|
||||
mod snippet_dialog;
|
||||
mod snippet_panel;
|
||||
|
||||
pub use snippet_dialog::{
|
||||
CategoryDialog, SnippetDialog, open_category_dialog, open_snippet_dialog,
|
||||
};
|
||||
pub use snippet_panel::SnippetPanel;
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The lines of the snippet list, from the store's snippets and categories.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::host::{Snippet, SnippetCategory, SnippetCategoryId, SnippetId, by_name};
|
||||
|
||||
/// A line of the snippet list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SnippetRow {
|
||||
/// A category's heading, its snippets after it unless `folded`. `None`
|
||||
/// is 未分类. `count` is the snippets it lists: while searching, those
|
||||
/// that match.
|
||||
Category {
|
||||
id: Option<SnippetCategoryId>,
|
||||
count: usize,
|
||||
folded: bool,
|
||||
},
|
||||
Snippet(SnippetId),
|
||||
}
|
||||
|
||||
/// The list: each category by name with its snippets by name, and the
|
||||
/// snippets of none last under 未分类. Without categories there are no
|
||||
/// headings at all. A search lists what matches, in every category, folded
|
||||
/// or not, and leaves out the categories with nothing that does.
|
||||
pub fn rows(
|
||||
categories: &[SnippetCategory],
|
||||
snippets: &[Snippet],
|
||||
query: &str,
|
||||
folded: &HashSet<Option<SnippetCategoryId>>,
|
||||
) -> Vec<SnippetRow> {
|
||||
let searching = !query.trim().is_empty();
|
||||
let mut matching: Vec<&Snippet> = snippets
|
||||
.iter()
|
||||
.filter(|snippet| snippet.matches(query))
|
||||
.collect();
|
||||
matching.sort_by(|a, b| by_name((&a.name, a.id.0), (&b.name, b.id.0)));
|
||||
let in_category = |category: Option<SnippetCategoryId>| -> Vec<SnippetId> {
|
||||
matching
|
||||
.iter()
|
||||
.filter(|snippet| snippet.category == category)
|
||||
.map(|snippet| snippet.id)
|
||||
.collect()
|
||||
};
|
||||
if categories.is_empty() {
|
||||
return in_category(None)
|
||||
.into_iter()
|
||||
.map(SnippetRow::Snippet)
|
||||
.collect();
|
||||
}
|
||||
let mut sorted: Vec<&SnippetCategory> = categories.iter().collect();
|
||||
sorted.sort_by(|a, b| by_name((&a.name, a.id.0), (&b.name, b.id.0)));
|
||||
let mut rows = Vec::new();
|
||||
let mut section = |id: Option<SnippetCategoryId>, shown_empty: bool| {
|
||||
let ids = in_category(id);
|
||||
if ids.is_empty() && !shown_empty {
|
||||
return;
|
||||
}
|
||||
let folded = !searching && folded.contains(&id);
|
||||
rows.push(SnippetRow::Category {
|
||||
id,
|
||||
count: ids.len(),
|
||||
folded,
|
||||
});
|
||||
if !folded {
|
||||
rows.extend(ids.into_iter().map(SnippetRow::Snippet));
|
||||
}
|
||||
};
|
||||
for category in sorted {
|
||||
// A new category shows, empty, until something goes in it.
|
||||
section(Some(category.id), !searching);
|
||||
}
|
||||
section(None, false);
|
||||
rows
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host::SnippetDraft;
|
||||
|
||||
fn category(id: u64, name: &str) -> SnippetCategory {
|
||||
SnippetCategory {
|
||||
id: SnippetCategoryId(id),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn snippet(id: u64, name: &str, category: Option<u64>) -> Snippet {
|
||||
Snippet::new(
|
||||
SnippetId(id),
|
||||
SnippetDraft::new(
|
||||
name,
|
||||
format!("echo {name}"),
|
||||
category.map(SnippetCategoryId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn heading(id: Option<u64>, count: usize, folded: bool) -> SnippetRow {
|
||||
SnippetRow::Category {
|
||||
id: id.map(SnippetCategoryId),
|
||||
count,
|
||||
folded,
|
||||
}
|
||||
}
|
||||
|
||||
fn item(id: u64) -> SnippetRow {
|
||||
SnippetRow::Snippet(SnippetId(id))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_categories_the_snippets_are_listed_by_name() {
|
||||
let snippets = [snippet(1, "uptime", None), snippet(2, "df", None)];
|
||||
assert_eq!(
|
||||
rows(&[], &snippets, "", &HashSet::new()),
|
||||
[item(2), item(1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_by_name_then_the_rest_under_none() {
|
||||
let categories = [
|
||||
category(1, "日志"),
|
||||
category(2, "Docker"),
|
||||
category(3, "空"),
|
||||
];
|
||||
let snippets = [
|
||||
snippet(1, "tail", Some(1)),
|
||||
snippet(2, "ps", Some(2)),
|
||||
snippet(3, "images", Some(2)),
|
||||
snippet(4, "df", None),
|
||||
];
|
||||
assert_eq!(
|
||||
rows(&categories, &snippets, "", &HashSet::new()),
|
||||
[
|
||||
heading(Some(2), 2, false),
|
||||
item(3),
|
||||
item(2),
|
||||
heading(Some(1), 1, false),
|
||||
item(1),
|
||||
heading(Some(3), 0, false),
|
||||
heading(None, 1, false),
|
||||
item(4),
|
||||
]
|
||||
);
|
||||
|
||||
// Folded, a category keeps its heading and count.
|
||||
let folded = HashSet::from([Some(SnippetCategoryId(2)), None]);
|
||||
assert_eq!(
|
||||
rows(&categories, &snippets, "", &folded),
|
||||
[
|
||||
heading(Some(2), 2, true),
|
||||
heading(Some(1), 1, false),
|
||||
item(1),
|
||||
heading(Some(3), 0, false),
|
||||
heading(None, 1, true),
|
||||
]
|
||||
);
|
||||
|
||||
// A search opens what it finds and leaves out the rest.
|
||||
assert_eq!(
|
||||
rows(&categories, &snippets, "S", &folded),
|
||||
[heading(Some(2), 2, false), item(3), item(2)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! The dialogs of 命令片段: a snippet's, and a category's.
|
||||
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, IndexPath, Sizable as _, WindowExt as _,
|
||||
checkbox::Checkbox,
|
||||
form::{Field, Form},
|
||||
input::{Input, InputState, Textarea, TextareaState},
|
||||
select::{Select, SelectState},
|
||||
};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::host::{HostStore, SnippetCategoryId, SnippetDraft, SnippetId, SnippetScope, by_name};
|
||||
use crate::shared::{commit_footer, dismiss_form_error, form_error_notification};
|
||||
|
||||
/// The category list's row for a snippet of none.
|
||||
const NO_CATEGORY: &str = "未分类";
|
||||
|
||||
/// How wide the snippet dialog is: room for a command of a fair length on
|
||||
/// one line.
|
||||
const DIALOG_WIDTH: f32 = 36.;
|
||||
|
||||
/// Which snippet dialog to open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SnippetDialog {
|
||||
/// A new snippet, in this category or none.
|
||||
New(Option<SnippetCategoryId>),
|
||||
Edit(SnippetId),
|
||||
}
|
||||
|
||||
/// The body of the new and edit snippet dialog. Validates on commit; the
|
||||
/// store is only touched when that passes.
|
||||
pub struct SnippetForm {
|
||||
store: Entity<HostStore>,
|
||||
editing: Option<SnippetId>,
|
||||
/// Kept as it is: nothing sets it yet.
|
||||
scope: SnippetScope,
|
||||
name: Entity<InputState>,
|
||||
category: Entity<SelectState<Vec<SharedString>>>,
|
||||
/// Parallel to the category select's rows; `None` is 未分类.
|
||||
category_ids: Vec<Option<SnippetCategoryId>>,
|
||||
command: Entity<TextareaState>,
|
||||
run_on_click: bool,
|
||||
}
|
||||
|
||||
impl SnippetForm {
|
||||
fn new(
|
||||
dialog: SnippetDialog,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let (editing, draft, mut categories) = {
|
||||
let read = store.read(cx);
|
||||
let (editing, draft) = match dialog {
|
||||
SnippetDialog::Edit(id) => match read.snippet(id) {
|
||||
Some(snippet) => (Some(id), snippet.draft()),
|
||||
None => (None, SnippetDraft::new("", "", None)),
|
||||
},
|
||||
// A new snippet runs on a click unless told otherwise.
|
||||
SnippetDialog::New(category) => (
|
||||
None,
|
||||
SnippetDraft::new("", "", category).with_run_on_click(true),
|
||||
),
|
||||
};
|
||||
let categories: Vec<(SnippetCategoryId, SharedString)> = read
|
||||
.snippet_categories()
|
||||
.iter()
|
||||
.map(|category| (category.id, category.name.clone()))
|
||||
.collect();
|
||||
(editing, draft, categories)
|
||||
};
|
||||
categories.sort_by(|a, b| by_name((&a.1, a.0.0), (&b.1, b.0.0)));
|
||||
let mut category_ids: Vec<Option<SnippetCategoryId>> = vec![None];
|
||||
let mut labels: Vec<SharedString> = vec![NO_CATEGORY.into()];
|
||||
for (id, name) in categories {
|
||||
category_ids.push(Some(id));
|
||||
labels.push(name);
|
||||
}
|
||||
let selected = category_ids
|
||||
.iter()
|
||||
.position(|id| *id == draft.category)
|
||||
.unwrap_or(0);
|
||||
|
||||
let name = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("例如 查看容器")
|
||||
.default_value(draft.name.clone())
|
||||
});
|
||||
let category =
|
||||
cx.new(|cx| SelectState::new(labels, Some(IndexPath::new(selected)), window, cx));
|
||||
let command = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.auto_grow(4, 12)
|
||||
.placeholder("例如 docker ps -a")
|
||||
.default_value(draft.command.clone())
|
||||
});
|
||||
Self {
|
||||
store,
|
||||
editing,
|
||||
scope: draft.scope,
|
||||
name,
|
||||
category,
|
||||
category_ids,
|
||||
command,
|
||||
run_on_click: draft.run_on_click,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and write to the store. Returns whether the dialog may close.
|
||||
fn commit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
let category = self
|
||||
.category
|
||||
.read(cx)
|
||||
.selected_index(cx)
|
||||
.and_then(|index| self.category_ids.get(index.row).copied())
|
||||
.unwrap_or(None);
|
||||
let draft = match SnippetDraft::validated(
|
||||
&self.name.read(cx).value(),
|
||||
&self.command.read(cx).value(),
|
||||
category,
|
||||
) {
|
||||
Ok(draft) => SnippetDraft {
|
||||
scope: self.scope,
|
||||
..draft.with_run_on_click(self.run_on_click)
|
||||
},
|
||||
Err(error) => {
|
||||
window.push_notification(form_error_notification(error), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let editing = self.editing;
|
||||
self.store.update(cx, |store, cx| match editing {
|
||||
Some(id) => {
|
||||
store.update_snippet(id, draft, cx);
|
||||
}
|
||||
None => {
|
||||
store.insert_snippet(draft, cx);
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SnippetForm {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Form::new()
|
||||
.child(
|
||||
Field::new()
|
||||
.label("名称")
|
||||
.required(true)
|
||||
.child(Input::new(&self.name).id("snippet-name").small()),
|
||||
)
|
||||
.child(
|
||||
Field::new().label("分类").child(
|
||||
div()
|
||||
.id("snippet-category")
|
||||
.test_support()
|
||||
.w_full()
|
||||
.child(Select::new(&self.category).small()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Field::new()
|
||||
.label("命令")
|
||||
.required(true)
|
||||
.description("可以有多行。")
|
||||
.child(
|
||||
div().id("snippet-command").test_support().w_full().child(
|
||||
Textarea::new(&self.command)
|
||||
.text_sm()
|
||||
.font_family(cx.theme().mono_font_family.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Field::new().child(
|
||||
Checkbox::new("snippet-run-on-click")
|
||||
.label("点击时自动执行")
|
||||
.checked(self.run_on_click)
|
||||
.small()
|
||||
.on_click(cx.listener(|this, checked: &bool, _, cx| {
|
||||
this.run_on_click = *checked;
|
||||
cx.notify();
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("启用后将自动执行命令,否则只将内容填入终端"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the new or the edit snippet dialog.
|
||||
pub fn open_snippet_dialog(
|
||||
dialog: SnippetDialog,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let form = cx.new(|cx| SnippetForm::new(dialog, store, window, cx));
|
||||
let editing = form.read(cx).editing.is_some();
|
||||
let (title, commit): (SharedString, SharedString) = if editing {
|
||||
("编辑命令片段".into(), "保存".into())
|
||||
} else {
|
||||
("新建命令片段".into(), "创建".into())
|
||||
};
|
||||
window.open_dialog(cx, {
|
||||
let form = form.clone();
|
||||
move |dialog, window, _| {
|
||||
dialog
|
||||
.title(title.clone())
|
||||
// Dialog geometry is an API boundary that takes `Pixels`; the
|
||||
// width follows the interface zoom through the rem.
|
||||
.w(rems(DIALOG_WIDTH).to_pixels(window.rem_size()))
|
||||
// Closed by its buttons or Escape, not by a click beside it.
|
||||
.overlay_closable(false)
|
||||
.child(form.clone())
|
||||
.footer(commit_footer("commit", commit.clone()))
|
||||
.on_ok({
|
||||
let form = form.clone();
|
||||
move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx))
|
||||
})
|
||||
.on_close(|_, window, cx| dismiss_form_error(window, cx))
|
||||
}
|
||||
});
|
||||
// Straight to typing: in the same update as the opening, or the
|
||||
// dialog's own focus wins.
|
||||
let name = form.read(cx).name.clone();
|
||||
name.update(cx, |name, cx| name.focus(window, cx));
|
||||
}
|
||||
|
||||
/// Which category dialog to open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CategoryDialog {
|
||||
New,
|
||||
Rename(SnippetCategoryId),
|
||||
}
|
||||
|
||||
/// The body of the new and rename category dialog.
|
||||
pub struct CategoryForm {
|
||||
store: Entity<HostStore>,
|
||||
editing: Option<SnippetCategoryId>,
|
||||
name: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl CategoryForm {
|
||||
fn new(
|
||||
dialog: CategoryDialog,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let (editing, current) = match dialog {
|
||||
CategoryDialog::New => (None, SharedString::default()),
|
||||
CategoryDialog::Rename(id) => match store.read(cx).snippet_category(id) {
|
||||
Some(category) => (Some(id), category.name.clone()),
|
||||
None => (None, SharedString::default()),
|
||||
},
|
||||
};
|
||||
let name = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("例如 Docker")
|
||||
.default_value(current)
|
||||
});
|
||||
Self {
|
||||
store,
|
||||
editing,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
let name = self.name.read(cx).value().trim().to_string();
|
||||
let editing = self.editing;
|
||||
let taken = self
|
||||
.store
|
||||
.read(cx)
|
||||
.snippet_categories()
|
||||
.iter()
|
||||
.any(|category| category.name == name.as_str() && Some(category.id) != editing);
|
||||
let error = if name.is_empty() {
|
||||
Some("请输入分类名称")
|
||||
} else if taken {
|
||||
Some("已有同名分类")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(error) = error {
|
||||
window.push_notification(form_error_notification(error), cx);
|
||||
return false;
|
||||
}
|
||||
self.store.update(cx, |store, cx| match editing {
|
||||
Some(id) => {
|
||||
store.rename_snippet_category(id, name, cx);
|
||||
}
|
||||
None => {
|
||||
store.insert_snippet_category(name, cx);
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CategoryForm {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Form::new().child(
|
||||
Field::new()
|
||||
.label("名称")
|
||||
.required(true)
|
||||
.child(Input::new(&self.name).id("snippet-category-name").small()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the new or the rename category dialog.
|
||||
pub fn open_category_dialog(
|
||||
dialog: CategoryDialog,
|
||||
store: Entity<HostStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let form = cx.new(|cx| CategoryForm::new(dialog, store, window, cx));
|
||||
let renaming = form.read(cx).editing.is_some();
|
||||
let (title, commit): (SharedString, SharedString) = if renaming {
|
||||
("重命名分类".into(), "保存".into())
|
||||
} else {
|
||||
("新建分类".into(), "创建".into())
|
||||
};
|
||||
window.open_dialog(cx, {
|
||||
let form = form.clone();
|
||||
move |dialog, _, _| {
|
||||
dialog
|
||||
.title(title.clone())
|
||||
// Closed by its buttons or Escape, not by a click beside it.
|
||||
.overlay_closable(false)
|
||||
.child(form.clone())
|
||||
.footer(commit_footer("commit", commit.clone()))
|
||||
.on_ok({
|
||||
let form = form.clone();
|
||||
move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx))
|
||||
})
|
||||
.on_close(|_, window, cx| dismiss_form_error(window, cx))
|
||||
}
|
||||
});
|
||||
let name = form.read(cx).name.clone();
|
||||
name.update(cx, |name, cx| name.focus(window, cx));
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
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 _,
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::model::{SnippetRow, rows};
|
||||
use crate::app::{
|
||||
CatalogIcon, CopyCommand, DeleteSnippet, DeleteSnippetCategory, EditSnippet, EnterCommand,
|
||||
NewSnippet, NewSnippetCategory, NewSnippetIn, RenameSnippetCategory, ToggleSnippetCategory,
|
||||
};
|
||||
use crate::host::{HostStore, Snippet, SnippetCategoryId, SnippetId};
|
||||
use crate::shared::{command_tooltip, one_line};
|
||||
|
||||
/// What 未分类 is called, as a category's heading.
|
||||
const UNCATEGORIZED: &str = "未分类";
|
||||
|
||||
/// 命令片段: the commands kept for every host, in categories one level
|
||||
/// deep. A click puts one on the input line of the SSH terminal in front to
|
||||
/// edit; 执行 runs it there.
|
||||
///
|
||||
/// The snippets are in the host store, so they are the same whichever
|
||||
/// terminal is in front, and nothing is read from the host. Which
|
||||
/// categories are folded and the search stay as they are from one terminal
|
||||
/// to the next; the folds are not saved.
|
||||
pub struct SnippetPanel {
|
||||
store: Entity<HostStore>,
|
||||
search: Entity<InputState>,
|
||||
query: String,
|
||||
folded: HashSet<Option<SnippetCategoryId>>,
|
||||
list: ListState,
|
||||
/// The lines the list was last told about.
|
||||
rows: Vec<SnippetRow>,
|
||||
/// The search changed: back to the top.
|
||||
back_to_top: bool,
|
||||
/// What a right click landed on, for the list's menu; `None` off every
|
||||
/// line.
|
||||
menu_hit: Rc<RefCell<Option<MenuHit>>>,
|
||||
/// 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(Clone, Copy, Debug, PartialEq)]
|
||||
enum MenuHit {
|
||||
Snippet(SnippetId),
|
||||
Category(Option<SnippetCategoryId>),
|
||||
}
|
||||
|
||||
impl SnippetPanel {
|
||||
pub fn new(
|
||||
store: Entity<HostStore>,
|
||||
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.observe(&store, |_, _, cx| cx.notify()),
|
||||
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
this.query = input.read(cx).value().to_string();
|
||||
this.back_to_top = true;
|
||||
cx.notify();
|
||||
}
|
||||
}),
|
||||
];
|
||||
Self {
|
||||
store,
|
||||
search,
|
||||
query: String::new(),
|
||||
folded: HashSet::new(),
|
||||
list: ListState::new(0, ListAlignment::Top, px(400.)),
|
||||
rows: Vec::new(),
|
||||
back_to_top: false,
|
||||
menu_hit: Rc::new(RefCell::new(None)),
|
||||
dispatch,
|
||||
focus_handle: cx.focus_handle(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a category away, or unfold it; `None` is 未分类.
|
||||
pub fn toggle_category(&mut self, id: Option<SnippetCategoryId>, cx: &mut Context<Self>) {
|
||||
if !self.folded.remove(&id) {
|
||||
self.folded.insert(id);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Tell the list about `rows` when they are not the ones it has; back
|
||||
/// at the top when the search changed.
|
||||
fn sync_list(&mut self, rows: &[SnippetRow]) {
|
||||
if std::mem::take(&mut self.back_to_top) {
|
||||
self.list.reset(rows.len());
|
||||
} else if self.rows != rows {
|
||||
self.list.splice(0..self.rows.len(), rows.len());
|
||||
}
|
||||
self.rows = rows.to_vec();
|
||||
}
|
||||
|
||||
fn render_header(&self, snippets: usize, categories: usize, cx: &App) -> impl IntoElement {
|
||||
let summary = if categories == 0 {
|
||||
format!("共 {snippets} 个片段")
|
||||
} else {
|
||||
format!("共 {snippets} 个片段 · {categories} 个分类")
|
||||
};
|
||||
let (new_category, new_snippet) = (self.dispatch.clone(), self.dispatch.clone());
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.px_3()
|
||||
.pt_3()
|
||||
.pb_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("snippets-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("snippets-new")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(Icon::new(IconName::Plus))
|
||||
.tooltip("新建片段")
|
||||
.accessibility_label("新建片段")
|
||||
.on_click(move |_, window, cx| {
|
||||
new_snippet.dispatch_action(&NewSnippet, window, cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("snippets-new-category")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(Icon::new(CatalogIcon::FolderPlus))
|
||||
.tooltip("新建分类")
|
||||
.accessibility_label("新建分类")
|
||||
.on_click(move |_, window, cx| {
|
||||
new_category.dispatch_action(&NewSnippetCategory, window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Input::new(&self.search)
|
||||
.id("snippets-search")
|
||||
.small()
|
||||
.cleanable(true)
|
||||
.prefix(Icon::new(IconName::Search).small()),
|
||||
)
|
||||
}
|
||||
|
||||
/// What shows before there is any snippet: what they are for, and the
|
||||
/// way to make one.
|
||||
fn render_empty(&self, cx: &App) -> AnyElement {
|
||||
let dispatch = self.dispatch.clone();
|
||||
v_flex()
|
||||
.id("snippets-empty")
|
||||
.test_support()
|
||||
.aria_label("还没有命令片段")
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_4()
|
||||
.py_8()
|
||||
.child(div().text_sm().child("还没有命令片段"))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("把常用的命令存成片段,点一下就输入到当前终端。"),
|
||||
)
|
||||
.child(
|
||||
Button::new("snippets-empty-new")
|
||||
.small()
|
||||
.primary()
|
||||
.mt_2()
|
||||
.icon(Icon::new(IconName::Plus))
|
||||
.label("新建片段")
|
||||
.on_click(move |_, window, cx| {
|
||||
dispatch.dispatch_action(&NewSnippet, window, cx)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for SnippetPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SnippetPanel {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.store.read(cx);
|
||||
let snippets: Rc<Vec<Snippet>> = Rc::new(store.snippets().to_vec());
|
||||
let names: Rc<Vec<(SnippetCategoryId, SharedString)>> = Rc::new(
|
||||
store
|
||||
.snippet_categories()
|
||||
.iter()
|
||||
.map(|category| (category.id, category.name.clone()))
|
||||
.collect(),
|
||||
);
|
||||
let rows = rows(
|
||||
store.snippet_categories(),
|
||||
&snippets,
|
||||
&self.query,
|
||||
&self.folded,
|
||||
);
|
||||
let nothing = snippets.is_empty() && names.is_empty();
|
||||
self.sync_list(&rows);
|
||||
let header = self.render_header(snippets.len(), names.len(), cx);
|
||||
|
||||
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 lines it would scroll away with them. The menu hangs
|
||||
// off the box too, not off the lines, which are drawn after layout
|
||||
// (see the host tree).
|
||||
let list = div()
|
||||
.id("snippets-list")
|
||||
.test_support()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.map(|list| {
|
||||
if nothing {
|
||||
list.child(self.render_empty(cx))
|
||||
} else if rows.is_empty() {
|
||||
let empty = "没有符合条件的片段";
|
||||
list.child(
|
||||
div()
|
||||
.id("snippets-no-match")
|
||||
.test_support()
|
||||
.aria_label(empty)
|
||||
.py_8()
|
||||
.text_sm()
|
||||
.text_center()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(empty),
|
||||
)
|
||||
} else {
|
||||
let rows = Rc::new(rows);
|
||||
let (hit, focus, dispatch) = (
|
||||
self.menu_hit.clone(),
|
||||
self.focus_handle.clone(),
|
||||
self.dispatch.clone(),
|
||||
);
|
||||
list.child(
|
||||
gpui_kit::list(self.list.clone(), move |index, _, cx| {
|
||||
let last = index + 1 == rows.len();
|
||||
match rows[index] {
|
||||
SnippetRow::Category { id, count, folded } => {
|
||||
let name = id
|
||||
.and_then(|id| names.iter().find(|(each, _)| *each == id))
|
||||
.map_or(UNCATEGORIZED.into(), |(_, name)| name.clone());
|
||||
render_category(
|
||||
Heading {
|
||||
id,
|
||||
name,
|
||||
count,
|
||||
folded,
|
||||
last,
|
||||
},
|
||||
hit.clone(),
|
||||
focus.clone(),
|
||||
dispatch.clone(),
|
||||
cx,
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
SnippetRow::Snippet(id) => {
|
||||
let Some(snippet) =
|
||||
snippets.iter().find(|snippet| snippet.id == id)
|
||||
else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
div()
|
||||
.px_3()
|
||||
.pt_2()
|
||||
// The last one clears the bottom.
|
||||
.when(last, |row| row.pb_3())
|
||||
.child(render_snippet(
|
||||
snippet,
|
||||
hit.clone(),
|
||||
focus.clone(),
|
||||
dispatch.clone(),
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
})
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.vertical_scrollbar(&self.list)
|
||||
.capture_any_mouse_down(move |event, _, _| {
|
||||
if matches!(event.button, MouseButton::Left | MouseButton::Right) {
|
||||
clear_hit.replace(None);
|
||||
}
|
||||
})
|
||||
// A right click off every line still focuses the panel, so the
|
||||
// menu's commands have somewhere to be dispatched from.
|
||||
.on_mouse_down(MouseButton::Right, {
|
||||
let focus = self.focus_handle.clone();
|
||||
move |_, window, cx| focus.focus(window, cx)
|
||||
})
|
||||
.context_menu({
|
||||
let store = self.store.clone();
|
||||
move |menu, _, cx| build_context_menu(*menu_hit.borrow(), store.read(cx), menu)
|
||||
});
|
||||
v_flex()
|
||||
.id("snippets")
|
||||
.test_support()
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.child(header)
|
||||
.child(list)
|
||||
}
|
||||
}
|
||||
|
||||
/// The list's menu, for what a right click landed on: a snippet, a
|
||||
/// category's heading, or nothing. Built as it opens, from the store as it
|
||||
/// is then.
|
||||
fn build_context_menu(hit: Option<MenuHit>, store: &HostStore, menu: PopupMenu) -> PopupMenu {
|
||||
let new_snippet = |menu: PopupMenu, action: Box<dyn Action>| {
|
||||
menu.menu_with_icon("新建片段…", Icon::new(IconName::Plus), action)
|
||||
};
|
||||
match hit {
|
||||
None => new_snippet(menu, Box::new(NewSnippet)).menu_with_icon(
|
||||
"新建分类…",
|
||||
Icon::new(CatalogIcon::FolderPlus),
|
||||
Box::new(NewSnippetCategory),
|
||||
),
|
||||
Some(MenuHit::Category(None)) => new_snippet(menu, Box::new(NewSnippet)),
|
||||
Some(MenuHit::Category(Some(id))) => new_snippet(menu, Box::new(NewSnippetIn(id)))
|
||||
.menu_with_icon(
|
||||
"重命名分类…",
|
||||
Icon::new(CatalogIcon::Pencil),
|
||||
Box::new(RenameSnippetCategory(id)),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"删除分类…",
|
||||
Icon::new(CatalogIcon::Trash),
|
||||
Box::new(DeleteSnippetCategory(id)),
|
||||
),
|
||||
Some(MenuHit::Snippet(id)) => {
|
||||
let Some(snippet) = store.snippet(id) else {
|
||||
return menu;
|
||||
};
|
||||
let command = snippet.command.clone();
|
||||
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,
|
||||
}),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"复制命令",
|
||||
Icon::new(IconName::Copy),
|
||||
Box::new(CopyCommand(command)),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"编辑…",
|
||||
Icon::new(CatalogIcon::Pencil),
|
||||
Box::new(EditSnippet(id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"删除…",
|
||||
Icon::new(CatalogIcon::Trash),
|
||||
Box::new(DeleteSnippet(id)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a category's heading shows.
|
||||
struct Heading {
|
||||
/// `None` is 未分类.
|
||||
id: Option<SnippetCategoryId>,
|
||||
name: SharedString,
|
||||
count: usize,
|
||||
folded: bool,
|
||||
/// The list's last line, which clears the bottom.
|
||||
last: bool,
|
||||
}
|
||||
|
||||
/// A category's heading: a click folds it away or unfolds it.
|
||||
fn render_category(
|
||||
heading: Heading,
|
||||
menu_hit: Rc<RefCell<Option<MenuHit>>>,
|
||||
focus: FocusHandle,
|
||||
dispatch: FocusHandle,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let Heading {
|
||||
id,
|
||||
name,
|
||||
count,
|
||||
folded,
|
||||
last,
|
||||
} = heading;
|
||||
let key = id.map_or("none".to_string(), |id| id.0.to_string());
|
||||
h_flex()
|
||||
.id(SharedString::from(format!("snippet-category:{key}")))
|
||||
.test_support()
|
||||
.aria_label(format!("{name} {count}"))
|
||||
.aria_expanded(!folded)
|
||||
.px_3()
|
||||
.pt_3()
|
||||
.pb_1()
|
||||
.when(last, |line| line.pb_3())
|
||||
.gap_1p5()
|
||||
.cursor_pointer()
|
||||
.on_click(move |_, window, cx| {
|
||||
dispatch.dispatch_action(&ToggleSnippetCategory(id), window, cx)
|
||||
})
|
||||
.on_mouse_down(MouseButton::Right, move |_, window, cx| {
|
||||
menu_hit.replace(Some(MenuHit::Category(id)));
|
||||
focus.focus(window, cx);
|
||||
})
|
||||
.child(
|
||||
Icon::new(if folded {
|
||||
IconName::ChevronRight
|
||||
} else {
|
||||
IconName::ChevronDown
|
||||
})
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(count.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// A snippet's card: its name, and its command on one line below.
|
||||
///
|
||||
/// What a click does is not the same for every snippet, so the pointer on
|
||||
/// a card shows it at the right: ⚡ for one a click runs, ▶ 执行 for one a
|
||||
/// click only types, which runs it. Out of the way otherwise, the slot
|
||||
/// keeps its room, so nothing moves as it shows.
|
||||
fn render_snippet(
|
||||
snippet: &Snippet,
|
||||
menu_hit: Rc<RefCell<Option<MenuHit>>>,
|
||||
focus: FocusHandle,
|
||||
dispatch: FocusHandle,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let id = snippet.id;
|
||||
let line = one_line(&snippet.command);
|
||||
let runs = snippet.run_on_click;
|
||||
// A click runs it when the snippet says so, and only types it
|
||||
// otherwise.
|
||||
let click = EnterCommand {
|
||||
command: snippet.command.clone(),
|
||||
run: runs,
|
||||
};
|
||||
let run = EnterCommand {
|
||||
command: snippet.command.clone(),
|
||||
run: true,
|
||||
};
|
||||
let tooltip = command_tooltip(&snippet.command, cx);
|
||||
let card = SharedString::from(format!("snippet-card:{}", id.0));
|
||||
h_flex()
|
||||
.id(("snippet", id.0))
|
||||
.test_support()
|
||||
.group(card.clone())
|
||||
.aria_label(if runs {
|
||||
format!("{} · {line} · 点击时自动执行", snippet.name)
|
||||
} else {
|
||||
format!("{} · {line}", snippet.name)
|
||||
})
|
||||
.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 types it, or runs it; the border says it does something.
|
||||
.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(&click, window, cx)
|
||||
})
|
||||
// A right click puts the menu on this snippet. The menu's commands
|
||||
// are dispatched from what is focused, which may be nothing.
|
||||
.on_mouse_down(MouseButton::Right, move |_, window, cx| {
|
||||
menu_hit.replace(Some(MenuHit::Snippet(id)));
|
||||
focus.focus(window, cx);
|
||||
})
|
||||
// The whole of a command too long for its line.
|
||||
.when_some(tooltip, |card, tooltip| card.tooltip(tooltip))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(snippet.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(line),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.invisible()
|
||||
.group_hover(card, |style| style.visible())
|
||||
.child(
|
||||
// As tall as the card's two lines allow: an easy target.
|
||||
Button::new(("snippet-run", id.0))
|
||||
.ghost()
|
||||
.map(|button| {
|
||||
if runs {
|
||||
button
|
||||
.icon(
|
||||
Icon::new(CatalogIcon::Zap).text_color(cx.theme().warning),
|
||||
)
|
||||
.tooltip("点击时自动执行")
|
||||
} else {
|
||||
button.icon(Icon::new(CatalogIcon::Play)).tooltip("执行")
|
||||
}
|
||||
})
|
||||
.accessibility_label("执行")
|
||||
.on_click(move |_, window, cx| {
|
||||
// Not the card's click too, which would run it
|
||||
// again or only type it.
|
||||
cx.stop_propagation();
|
||||
dispatch.dispatch_action(&run, window, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
+37
-17
@@ -255,15 +255,20 @@ 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> {
|
||||
/// Put `command` on the shell's input line, as a paste, and with `run`
|
||||
/// press Enter: what a click in 历史命令 and 命令片段 does. With
|
||||
/// `replace`, in place of what is typed there. 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,
|
||||
replace: bool,
|
||||
) -> Result<(), &'static str> {
|
||||
if !self.lifecycle.accepts_input() {
|
||||
return Err("终端没有连接");
|
||||
}
|
||||
self.send_user_input(encode_command(command, run, self.mode())?);
|
||||
self.send_user_input(encode_command(command, run, replace, self.mode())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -799,21 +804,31 @@ 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.
|
||||
/// What `TerminalEngine::enter_command` types: with `replace` 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> {
|
||||
fn encode_command(
|
||||
command: &str,
|
||||
run: bool,
|
||||
replace: 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();
|
||||
let mut bytes = if replace {
|
||||
b"\x05\x15".to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
bytes.extend(encode_paste(command, mode));
|
||||
if run {
|
||||
bytes.push(b'\r');
|
||||
@@ -1207,19 +1222,24 @@ mod tests {
|
||||
#[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),
|
||||
encode_command("ls -l", false, true, TermMode::BRACKETED_PASTE),
|
||||
Ok(b"\x05\x15\x1b[200~ls -l\x1b[201~".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_command("ls -l", true, TermMode::default()),
|
||||
encode_command("ls -l", true, true, TermMode::default()),
|
||||
Ok(b"\x05\x15ls -l\r".to_vec())
|
||||
);
|
||||
// Not replacing, only the command.
|
||||
assert_eq!(
|
||||
encode_command("dir", true, false, TermMode::default()),
|
||||
Ok(b"dir\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());
|
||||
assert!(encode_command(lines, false, true, TermMode::BRACKETED_PASTE).is_ok());
|
||||
assert!(encode_command(lines, false, true, TermMode::default()).is_err());
|
||||
assert!(encode_command(lines, true, true, TermMode::default()).is_ok());
|
||||
// vim and less have the keys to themselves.
|
||||
assert!(encode_command("ls", true, TermMode::ALT_SCREEN).is_err());
|
||||
assert!(encode_command("ls", true, true, TermMode::ALT_SCREEN).is_err());
|
||||
}
|
||||
}
|
||||
@@ -352,15 +352,17 @@ 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`].
|
||||
/// Put `command` on the shell's input line, with `replace` 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,
|
||||
replace: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), &'static str> {
|
||||
self.engine.read(cx).enter_command(command, run)?;
|
||||
self.engine.read(cx).enter_command(command, run, replace)?;
|
||||
self.selecting = false;
|
||||
self.scroll_accumulator.reset();
|
||||
self.cursor_visible = true;
|
||||
|
||||
@@ -7,6 +7,7 @@ mod dock_skin;
|
||||
mod forwards;
|
||||
mod recent_hosts;
|
||||
mod sidebar;
|
||||
mod snippets;
|
||||
mod status_bar;
|
||||
mod title_bar;
|
||||
mod tool_sidebar;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! The workspace's share of 命令片段: the commands of the snippet list,
|
||||
//! which open the dialogs and ask before deleting.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
DeleteSnippet, DeleteSnippetCategory, EditSnippet, NewSnippet, NewSnippetCategory,
|
||||
NewSnippetIn, RenameSnippetCategory, ToggleSnippetCategory,
|
||||
};
|
||||
use crate::shared::confirm_delete;
|
||||
use crate::snippets::{CategoryDialog, SnippetDialog, open_category_dialog, open_snippet_dialog};
|
||||
|
||||
use super::workspace_view::Workspace;
|
||||
|
||||
impl Workspace {
|
||||
pub(super) fn on_new_snippet(
|
||||
&mut self,
|
||||
_: &NewSnippet,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_snippet_dialog(SnippetDialog::New(None), self.store.clone(), window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn on_new_snippet_in(
|
||||
&mut self,
|
||||
action: &NewSnippetIn,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_snippet_dialog(
|
||||
SnippetDialog::New(Some(action.0)),
|
||||
self.store.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn on_edit_snippet(
|
||||
&mut self,
|
||||
action: &EditSnippet,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.store.read(cx).snippet(action.0).is_some() {
|
||||
open_snippet_dialog(
|
||||
SnippetDialog::Edit(action.0),
|
||||
self.store.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_delete_snippet(
|
||||
&mut self,
|
||||
action: &DeleteSnippet,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = action.0;
|
||||
let Some(name) = self
|
||||
.store
|
||||
.read(cx)
|
||||
.snippet(id)
|
||||
.map(|snippet| snippet.name.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let store = self.store.clone();
|
||||
confirm_delete(
|
||||
&name,
|
||||
None,
|
||||
Rc::new(move |_, cx| {
|
||||
store.update(cx, |store, cx| {
|
||||
store.remove_snippet(id, cx);
|
||||
});
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn on_new_snippet_category(
|
||||
&mut self,
|
||||
_: &NewSnippetCategory,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_category_dialog(CategoryDialog::New, self.store.clone(), window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn on_rename_snippet_category(
|
||||
&mut self,
|
||||
action: &RenameSnippetCategory,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.store.read(cx).snippet_category(action.0).is_some() {
|
||||
open_category_dialog(
|
||||
CategoryDialog::Rename(action.0),
|
||||
self.store.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask, saying how many snippets go with it, then delete a category.
|
||||
pub(super) fn on_delete_snippet_category(
|
||||
&mut self,
|
||||
action: &DeleteSnippetCategory,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = action.0;
|
||||
let store = self.store.read(cx);
|
||||
let Some(name) = store
|
||||
.snippet_category(id)
|
||||
.map(|category| category.name.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let snippets = store.snippets_in(id);
|
||||
let description = (snippets > 0)
|
||||
.then(|| SharedString::from(format!("分类里的 {snippets} 个片段会一起删除。")));
|
||||
let store = self.store.clone();
|
||||
confirm_delete(
|
||||
&name,
|
||||
description,
|
||||
Rc::new(move |_, cx| {
|
||||
store.update(cx, |store, cx| {
|
||||
store.remove_snippet_category(id, cx);
|
||||
});
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// A category's heading: fold it away, or unfold it.
|
||||
pub(super) fn on_toggle_snippet_category(
|
||||
&mut self,
|
||||
action: &ToggleSnippetCategory,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = action.0;
|
||||
self.tools
|
||||
.update(cx, |tools, cx| tools.toggle_snippet_category(id, cx));
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,12 @@ 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::host::{HostId, HostOs, HostStore, SnippetCategoryId};
|
||||
use crate::monitor::{MonitorDetail, MonitorPanel};
|
||||
use crate::netstat::NetstatPanel;
|
||||
use crate::processes::{Process, ProcessDetails, ProcessPanel, ProcessSort};
|
||||
use crate::services::{Service, ServicePanel};
|
||||
use crate::snippets::SnippetPanel;
|
||||
use crate::terminal::{ExecTarget, RemoteTerminalId, TerminalView};
|
||||
|
||||
impl ToolKind {
|
||||
@@ -71,20 +72,6 @@ impl ToolKind {
|
||||
ToolKind::Monitor => "tool-monitor",
|
||||
}
|
||||
}
|
||||
|
||||
/// What a tool still to come is going to show for the terminal in
|
||||
/// front of `host`, said in its place.
|
||||
fn about(self, host: &str) -> String {
|
||||
match self {
|
||||
ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(),
|
||||
ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"),
|
||||
ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"),
|
||||
ToolKind::Services => format!("{host} 上的 systemd 服务:启动、停止、日志。"),
|
||||
ToolKind::Processes => format!("{host} 上的进程:内存、CPU,结束进程。"),
|
||||
ToolKind::Connections => format!("{host} 上的 TCP、UDP 连接和监听端口。"),
|
||||
ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The SSH terminal the right sidebar works on.
|
||||
@@ -107,7 +94,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 tool still to come is a placeholder.
|
||||
/// screen.
|
||||
pub struct ToolSidebar {
|
||||
tool: ToolKind,
|
||||
/// The terminal in front; `None` while the sidebar is hidden for
|
||||
@@ -116,14 +103,13 @@ pub struct ToolSidebar {
|
||||
/// Whether the right dock is open.
|
||||
shown: bool,
|
||||
monitor: Entity<MonitorPanel>,
|
||||
snippets: Entity<SnippetPanel>,
|
||||
history: Entity<HistoryPanel>,
|
||||
docker: Entity<DockerPanel>,
|
||||
services: Entity<ServicePanel>,
|
||||
processes: Entity<ProcessPanel>,
|
||||
netstat: Entity<NetstatPanel>,
|
||||
store: Entity<HostStore>,
|
||||
focus_handle: FocusHandle,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl ToolSidebar {
|
||||
@@ -135,21 +121,18 @@ impl ToolSidebar {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
// A host renamed is named anew.
|
||||
let subscription = cx.observe(&store, |_, _, cx| cx.notify());
|
||||
Self {
|
||||
tool: ToolKind::default(),
|
||||
terminal: None,
|
||||
shown: false,
|
||||
monitor: cx.new(|_| MonitorPanel::new(dispatch.clone())),
|
||||
snippets: cx.new(|cx| SnippetPanel::new(store, dispatch.clone(), window, cx)),
|
||||
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)),
|
||||
netstat: cx.new(|cx| NetstatPanel::new(dispatch, window, cx)),
|
||||
store,
|
||||
focus_handle: cx.focus_handle(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +166,16 @@ impl ToolSidebar {
|
||||
self.netstat.update(cx, |netstat, cx| netstat.refresh(cx));
|
||||
}
|
||||
|
||||
/// Fold a category of 命令片段 away, or unfold it.
|
||||
pub fn toggle_snippet_category(
|
||||
&mut self,
|
||||
id: Option<SnippetCategoryId>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.snippets
|
||||
.update(cx, |snippets, cx| snippets.toggle_category(id, cx));
|
||||
}
|
||||
|
||||
/// 历史命令's 刷新.
|
||||
pub fn refresh_history(&mut self, cx: &mut Context<Self>) {
|
||||
self.history.update(cx, |history, cx| history.refresh(cx));
|
||||
@@ -301,24 +294,6 @@ impl ToolSidebar {
|
||||
self.sync_tools(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_placeholder(&self, host: HostId, cx: &App) -> impl IntoElement {
|
||||
let store = self.store.read(cx);
|
||||
let host = store.host(host).map(|host| host.name.clone());
|
||||
let about = self.tool.about(host.as_deref().unwrap_or_default());
|
||||
v_flex()
|
||||
.id("tool-placeholder")
|
||||
.test_support()
|
||||
.aria_label(about.clone())
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_4()
|
||||
.py_8()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(self.tool.icon().large())
|
||||
.child(div().text_sm().text_center().child(about))
|
||||
.child(div().text_xs().child("即将推出"))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ToolSidebar {}
|
||||
@@ -372,28 +347,17 @@ impl Render for ToolSidebar {
|
||||
.size_full()
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.when_some(self.terminal.as_ref(), |sidebar, terminal| {
|
||||
match self.tool {
|
||||
ToolKind::Monitor => {
|
||||
sidebar.child(div().flex_1().min_h_0().child(self.monitor.clone()))
|
||||
}
|
||||
ToolKind::Connections => {
|
||||
sidebar.child(div().flex_1().min_h_0().child(self.netstat.clone()))
|
||||
}
|
||||
ToolKind::Processes => {
|
||||
sidebar.child(div().flex_1().min_h_0().child(self.processes.clone()))
|
||||
}
|
||||
ToolKind::Services => {
|
||||
sidebar.child(div().flex_1().min_h_0().child(self.services.clone()))
|
||||
}
|
||||
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)),
|
||||
}
|
||||
.when(self.terminal.is_some(), |sidebar| {
|
||||
let panel = match self.tool {
|
||||
ToolKind::Snippets => self.snippets.clone().into_any_element(),
|
||||
ToolKind::History => self.history.clone().into_any_element(),
|
||||
ToolKind::Docker => self.docker.clone().into_any_element(),
|
||||
ToolKind::Services => self.services.clone().into_any_element(),
|
||||
ToolKind::Processes => self.processes.clone().into_any_element(),
|
||||
ToolKind::Connections => self.netstat.clone().into_any_element(),
|
||||
ToolKind::Monitor => self.monitor.clone().into_any_element(),
|
||||
};
|
||||
sidebar.child(div().flex_1().min_h_0().child(panel))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+20
-8
@@ -26,6 +26,7 @@ use crate::app::{
|
||||
use crate::docker::{
|
||||
self, ContainerCommand, DockerObject, open_container_dialog, open_object_dialog,
|
||||
};
|
||||
use crate::host::HostOs;
|
||||
use crate::processes::{end_command, ended, open_process_dialog};
|
||||
use crate::services::{ServiceCommand, control_command, controlled, open_service_dialog};
|
||||
use crate::shared::confirm_danger;
|
||||
@@ -186,23 +187,34 @@ impl Workspace {
|
||||
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.
|
||||
/// A command of 历史命令 or 命令片段 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.
|
||||
///
|
||||
/// It takes the place of what is typed there, but on Windows, whose
|
||||
/// shells do not read Ctrl-E and Ctrl-U as line editing.
|
||||
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 {
|
||||
let Some(terminal) = self.tool_terminal(cx) else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = terminal.view.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let replace = self
|
||||
.store
|
||||
.read(cx)
|
||||
.host(terminal.host)
|
||||
.and_then(|host| host.os)
|
||||
!= Some(HostOs::Windows);
|
||||
let EnterCommand { command, run } = action;
|
||||
match view.update(cx, |view, cx| view.enter_command(command, *run, cx)) {
|
||||
match view.update(cx, |view, cx| {
|
||||
view.enter_command(command, *run, replace, cx)
|
||||
}) {
|
||||
Ok(()) => {
|
||||
let focus = view.read(cx).focus_handle();
|
||||
window.focus(&focus, cx);
|
||||
|
||||
@@ -2246,6 +2246,14 @@ impl Render for Workspace {
|
||||
.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_new_snippet))
|
||||
.on_action(cx.listener(Self::on_new_snippet_in))
|
||||
.on_action(cx.listener(Self::on_edit_snippet))
|
||||
.on_action(cx.listener(Self::on_delete_snippet))
|
||||
.on_action(cx.listener(Self::on_new_snippet_category))
|
||||
.on_action(cx.listener(Self::on_rename_snippet_category))
|
||||
.on_action(cx.listener(Self::on_delete_snippet_category))
|
||||
.on_action(cx.listener(Self::on_toggle_snippet_category))
|
||||
.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))
|
||||
|
||||
@@ -20,6 +20,7 @@ mod processes;
|
||||
mod services;
|
||||
mod settings;
|
||||
mod sftp;
|
||||
mod snippets;
|
||||
mod terminal;
|
||||
mod transfer;
|
||||
mod update;
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! 命令片段: the right sidebar's tool that keeps commands for every host,
|
||||
//! in categories, and puts them on the SSH terminal's line.
|
||||
|
||||
use crate::support::*;
|
||||
use shellrs::app::{
|
||||
DeleteSnippet, DeleteSnippetCategory, EditSnippet, NewSnippetCategory, NewSnippetIn,
|
||||
RenameSnippetCategory,
|
||||
};
|
||||
use shellrs::host::{SnippetCategoryId, SnippetDraft, SnippetId};
|
||||
|
||||
/// Two categories and a snippet of none, made in an order the list does
|
||||
/// not keep: Docker's 「容器」 and 「镜像」, 日志's 「系统日志」, and 「磁盘」.
|
||||
fn store_with_snippets() -> HostStore {
|
||||
let mut store = HostStore::seed();
|
||||
let logs = store.insert_snippet_category_unnotified("日志");
|
||||
let docker = store.insert_snippet_category_unnotified("Docker");
|
||||
store.insert_snippet_unnotified(SnippetDraft::new("镜像", "docker images", Some(docker)));
|
||||
store.insert_snippet_unnotified(
|
||||
SnippetDraft::new("系统日志", "journalctl -f", Some(logs)).with_run_on_click(true),
|
||||
);
|
||||
store.insert_snippet_unnotified(SnippetDraft::new("磁盘", "df -h", None));
|
||||
store.insert_snippet_unnotified(SnippetDraft::new("容器", "docker ps -a", Some(docker)));
|
||||
store
|
||||
}
|
||||
|
||||
const IMAGES: u64 = 1;
|
||||
const JOURNAL: u64 = 2;
|
||||
const DISK: u64 = 3;
|
||||
const CONTAINERS: u64 = 4;
|
||||
const LOGS: u64 = 1;
|
||||
const DOCKER: u64 = 2;
|
||||
|
||||
/// 命令片段 shown beside web-01's terminal, once its shell runs.
|
||||
async fn open_snippets(
|
||||
cx: &mut TestAppContext,
|
||||
store: HostStore,
|
||||
) -> (
|
||||
WindowHandle<Root>,
|
||||
Entity<Workspace>,
|
||||
Arc<FakeTerminalFactory>,
|
||||
) {
|
||||
let factory = Arc::new(FakeTerminalFactory::default());
|
||||
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
|
||||
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-snippets", cx));
|
||||
let terminal = RemoteTerminalId(INITIAL_WEB_TERMINAL);
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| {
|
||||
remote_lifecycle(&workspace, terminal, cx).accepts_input()
|
||||
})
|
||||
.await;
|
||||
(handle, workspace, factory)
|
||||
}
|
||||
|
||||
/// A closed dialog leaves nothing focused, and an action dispatched from
|
||||
/// nothing goes nowhere: a click in the panel first, as a user would.
|
||||
fn dispatch_from_panel(
|
||||
cx: &mut TestAppContext,
|
||||
handle: WindowHandle<Root>,
|
||||
action: impl gpui_kit::Action,
|
||||
) {
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("snippets-summary", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.dispatch_action(Box::new(action), cx)
|
||||
});
|
||||
}
|
||||
|
||||
/// The pointer onto a snippet's card, which shows what a click on it does.
|
||||
fn point_at(cx: &mut TestAppContext, handle: WindowHandle<Root>, snippet: u64) {
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let position = window.find(("snippet", snippet)).bounds().center();
|
||||
window.dispatch_event(
|
||||
MouseMoveEvent {
|
||||
position,
|
||||
pressed_button: None,
|
||||
modifiers: gpui_kit::Modifiers::default(),
|
||||
}
|
||||
.to_platform_input(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn top(window: &mut gpui_kit::Window, id: impl Into<ElementId>) -> gpui_kit::Pixels {
|
||||
window.find(id).bounds().top()
|
||||
}
|
||||
|
||||
fn names(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> Vec<String> {
|
||||
workspace.read_with(cx, |workspace, cx| {
|
||||
workspace
|
||||
.store()
|
||||
.read(cx)
|
||||
.snippets()
|
||||
.iter()
|
||||
.map(|snippet| snippet.name.to_string())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn snippets_are_listed_by_category_and_a_click_puts_one_on_the_line(cx: &mut TestAppContext) {
|
||||
let (handle, _, factory) = open_snippets(cx, store_with_snippets()).await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find("snippets-summary").label(),
|
||||
Some("共 4 个片段 · 2 个分类")
|
||||
);
|
||||
// Docker, then 日志, each by name; the rest last, under 未分类.
|
||||
let order = [
|
||||
top(window, "snippet-category:2"),
|
||||
top(window, ("snippet", CONTAINERS)),
|
||||
top(window, ("snippet", IMAGES)),
|
||||
top(window, "snippet-category:1"),
|
||||
top(window, ("snippet", JOURNAL)),
|
||||
top(window, "snippet-category:none"),
|
||||
top(window, ("snippet", DISK)),
|
||||
];
|
||||
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{order:?}");
|
||||
assert_eq!(window.find("snippet-category:2").label(), Some("Docker 2"));
|
||||
assert_eq!(
|
||||
window.find(("snippet", CONTAINERS)).label(),
|
||||
Some("容器 · docker ps -a")
|
||||
);
|
||||
});
|
||||
|
||||
// A click puts it on the line in place of what is typed there, and the
|
||||
// terminal takes the keyboard.
|
||||
let before = factory.written_text().len();
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(("snippet", CONTAINERS), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find(("terminal", INITIAL_WEB_TERMINAL)).focused(),
|
||||
Some(true)
|
||||
);
|
||||
});
|
||||
assert_eq!(&factory.written_text()[before..], "\x05\x15docker ps -a");
|
||||
|
||||
// 执行 shows with the pointer on the card, and runs it, and only that.
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(!window.find(("snippet-run", DISK)).visible());
|
||||
});
|
||||
point_at(cx, handle, DISK);
|
||||
let before = factory.written_text().len();
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
assert!(window.find(("snippet-run", DISK)).visible());
|
||||
assert!(!window.find(("snippet-run", JOURNAL)).visible());
|
||||
window.click(("snippet-run", DISK), cx)
|
||||
});
|
||||
assert_eq!(&factory.written_text()[before..], "\x05\x15df -h\r");
|
||||
|
||||
// One set to run on a click says so, and runs.
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find(("snippet", JOURNAL)).label(),
|
||||
Some("系统日志 · journalctl -f · 点击时自动执行")
|
||||
);
|
||||
});
|
||||
let before = factory.written_text().len();
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(("snippet", JOURNAL), cx)
|
||||
});
|
||||
assert_eq!(&factory.written_text()[before..], "\x05\x15journalctl -f\r");
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_category_folds_away_and_the_search_finds_through_it(cx: &mut TestAppContext) {
|
||||
let (handle, _, _) = open_snippets(cx, store_with_snippets()).await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("snippet-category:2", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find(("snippet", CONTAINERS)).is_none());
|
||||
assert_eq!(window.find("snippet-category:2").label(), Some("Docker 2"));
|
||||
assert!(window.try_find(("snippet", JOURNAL)).is_some());
|
||||
});
|
||||
|
||||
// The search looks in names and commands, folded or not, and leaves
|
||||
// out the categories with nothing that matches.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("snippets-search", cx);
|
||||
window.input("PS", cx);
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find(("snippet", CONTAINERS)).is_some());
|
||||
assert_eq!(window.find("snippet-category:2").label(), Some("Docker 1"));
|
||||
assert!(window.try_find("snippet-category:1").is_none());
|
||||
assert!(window.try_find(("snippet", DISK)).is_none());
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.input(" kubectl", cx));
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.find("snippets-no-match").visible());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_snippet_is_made_edited_and_deleted(cx: &mut TestAppContext) {
|
||||
let (handle, workspace, _) = open_snippets(cx, HostStore::seed()).await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
assert_eq!(
|
||||
window.find("snippets-empty").label(),
|
||||
Some("还没有命令片段")
|
||||
);
|
||||
window.click("snippets-empty-new", cx);
|
||||
});
|
||||
|
||||
// The name takes the keyboard at once; without a command it is refused.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
assert_eq!(window.find("snippet-name").focused(), Some(true));
|
||||
window.input("查看端口", cx);
|
||||
window.click("commit", cx);
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
||||
window
|
||||
.try_find("form-error")
|
||||
.is_some_and(|error| error.label() == Some("请输入命令"))
|
||||
})
|
||||
.await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("snippet-command", cx);
|
||||
window.input("ss -tlnp", cx);
|
||||
window.click("commit", cx);
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
||||
window.try_find(("snippet", 1u64)).is_some()
|
||||
})
|
||||
.await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find(("snippet", 1u64)).label(),
|
||||
Some("查看端口 · ss -tlnp · 点击时自动执行")
|
||||
);
|
||||
assert_eq!(window.find("snippets-summary").label(), Some("共 1 个片段"));
|
||||
});
|
||||
|
||||
// Edited in the same dialog, where a click is told only to type it.
|
||||
dispatch_from_panel(cx, handle, EditSnippet(SnippetId(1)));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.press("cmd-a", cx);
|
||||
window.input("监听端口", cx);
|
||||
window.click("snippet-run-on-click", cx);
|
||||
window.click("commit", cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(names(&workspace, cx), ["监听端口"]);
|
||||
let runs = workspace.read_with(cx, |workspace, cx| {
|
||||
workspace.store().read(cx).snippets()[0].run_on_click
|
||||
});
|
||||
assert!(!runs);
|
||||
|
||||
// Deleted once confirmed.
|
||||
dispatch_from_panel(cx, handle, DeleteSnippet(SnippetId(1)));
|
||||
in_frame(cx, handle, |window, cx| window.click("ok", cx));
|
||||
assert!(names(&workspace, cx).is_empty());
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.find("snippets-empty").visible());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_category_is_made_renamed_and_deleted_with_its_snippets(cx: &mut TestAppContext) {
|
||||
let (handle, workspace, _) = open_snippets(cx, store_with_snippets()).await;
|
||||
|
||||
// A name the list already has is refused.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.dispatch_action(Box::new(NewSnippetCategory), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.input("Docker", cx);
|
||||
window.click("commit", cx);
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
||||
window
|
||||
.try_find("form-error")
|
||||
.is_some_and(|error| error.label() == Some("已有同名分类"))
|
||||
})
|
||||
.await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("snippet-category-name", cx);
|
||||
window.press("cmd-a", cx);
|
||||
window.input("部署", cx);
|
||||
window.click("commit", cx);
|
||||
});
|
||||
// Empty, it shows until something goes in it.
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
||||
window
|
||||
.try_find("snippet-category:3")
|
||||
.is_some_and(|heading| heading.label() == Some("部署 0"))
|
||||
})
|
||||
.await;
|
||||
|
||||
// A snippet made from its menu goes in it.
|
||||
dispatch_from_panel(cx, handle, NewSnippetIn(SnippetCategoryId(3)));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.input("发布", cx);
|
||||
window.click("snippet-command", cx);
|
||||
window.input("./deploy.sh", cx);
|
||||
window.click("commit", cx);
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
||||
window
|
||||
.try_find("snippet-category:3")
|
||||
.is_some_and(|heading| heading.label() == Some("部署 1"))
|
||||
})
|
||||
.await;
|
||||
|
||||
dispatch_from_panel(cx, handle, RenameSnippetCategory(SnippetCategoryId(LOGS)));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.press("cmd-a", cx);
|
||||
window.input("日志查看", cx);
|
||||
window.click("commit", cx);
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
||||
window
|
||||
.try_find("snippet-category:1")
|
||||
.is_some_and(|heading| heading.label() == Some("日志查看 1"))
|
||||
})
|
||||
.await;
|
||||
|
||||
// Deleting Docker takes its two snippets.
|
||||
dispatch_from_panel(cx, handle, DeleteSnippetCategory(SnippetCategoryId(DOCKER)));
|
||||
in_frame(cx, handle, |window, cx| window.click("ok", cx));
|
||||
assert_eq!(names(&workspace, cx), ["系统日志", "磁盘", "发布"]);
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("snippet-category:2").is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn on_windows_a_snippet_is_typed_after_what_is_there(cx: &mut TestAppContext) {
|
||||
let mut store = store_with_snippets();
|
||||
store.set_host_os_unnotified(HostId(WEB_01), Some(HostOs::Windows));
|
||||
let (handle, _, factory) = open_snippets(cx, store).await;
|
||||
point_at(cx, handle, DISK);
|
||||
let before = factory.written_text().len();
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(("snippet-run", DISK), cx)
|
||||
});
|
||||
// No Ctrl-E, Ctrl-U: its shells do not edit the line with them.
|
||||
assert_eq!(&factory.written_text()[before..], "df -h\r");
|
||||
}
|
||||
+25
-6
@@ -608,6 +608,14 @@ async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// 命令片段 beside web-01's terminal, where its dialogs open from.
|
||||
fn show_snippets(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.click("tool-snippets", cx));
|
||||
}
|
||||
|
||||
/// A dialog holds what was typed into it: a click beside it does not close
|
||||
/// it, Escape (like its buttons) does.
|
||||
#[gpui_kit::test]
|
||||
@@ -624,6 +632,8 @@ async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) {
|
||||
("new-group", "group-name"),
|
||||
("new-forward", "forward-name"),
|
||||
("new-credential", "credential-name"),
|
||||
("snippets-new", "snippet-name"),
|
||||
("snippets-new-category", "snippet-category-name"),
|
||||
] {
|
||||
if open == "new-forward" {
|
||||
show_forwards(cx, handle).await;
|
||||
@@ -631,6 +641,9 @@ async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) {
|
||||
if open == "new-credential" {
|
||||
show_credentials(cx, handle).await;
|
||||
}
|
||||
if open == "snippets-new" {
|
||||
show_snippets(cx, handle);
|
||||
}
|
||||
in_frame(cx, handle, |window, cx| window.click(open, cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(field, cx);
|
||||
@@ -672,6 +685,12 @@ async fn a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it(cx: &mu
|
||||
("new-group", "group-name", "请输入分组名称"),
|
||||
("new-forward", "forward-name", "请选择端口转发经由的主机"),
|
||||
("new-credential", "credential-name", "请输入名称"),
|
||||
("snippets-new", "snippet-name", "请输入名称"),
|
||||
(
|
||||
"snippets-new-category",
|
||||
"snippet-category-name",
|
||||
"请输入分类名称",
|
||||
),
|
||||
] {
|
||||
if open == "new-forward" {
|
||||
show_forwards(cx, handle).await;
|
||||
@@ -679,6 +698,9 @@ async fn a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it(cx: &mu
|
||||
if open == "new-credential" {
|
||||
show_credentials(cx, handle).await;
|
||||
}
|
||||
if open == "snippets-new" {
|
||||
show_snippets(cx, handle);
|
||||
}
|
||||
in_frame(cx, handle, |window, cx| window.click(open, cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(field, cx);
|
||||
@@ -763,10 +785,7 @@ fn the_tool_switch_shows_a_tool_and_hides_it_again(cx: &mut TestAppContext) {
|
||||
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("tool-placeholder").label(),
|
||||
Some("常用的命令存在这里,点一下就发送到当前终端。")
|
||||
);
|
||||
assert!(window.find("snippets").visible());
|
||||
assert_eq!(window.find("tool-snippets").checked(), Some(true));
|
||||
// Between the center and the switch.
|
||||
let bounds = window.find(sidebar).bounds();
|
||||
@@ -828,13 +847,13 @@ 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!(window.find("tool-placeholder").visible());
|
||||
assert!(window.find("snippets").visible());
|
||||
});
|
||||
|
||||
// A click in the sidebar takes the focus there; hiding the sidebar
|
||||
// hands it back to the terminal, so the shortcuts keep working.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("tool-placeholder", cx)
|
||||
window.click("snippets-summary", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(window.find(sidebar).focused(), Some(true));
|
||||
|
||||
Reference in new issue
Block a user