SFTP 加内置编辑器:双击文件在编辑器标签里编辑,⌘S 原地写回

SFTP 里双击、回车、F4 或右键「编辑」一个文件(远程和本地都行),在中间区的「编辑器」标签里打开;
「新建 › 文件…」建好后也直接打开。同一个文件只开一个标签。先读后开:超过 5 MB、二进制或不是
UTF-8 的文件只弹说明,远程的可以改用「下载…」。

远程文件经打开它的 SFTP 标签的连接读写,不另起登录。保存是原地改写(截断后写原文件),所有者、
权限和链接都不变;保存前核对大小和修改时间,被别人改过就先问「覆盖」。BOM 和 CRLF 原样写回。
常见的约 15 种语言有语法高亮(gpui-kit 的 tree-sitter 特性;SQL 的语法库要求 cc ~1.2,所以
Cargo.lock 里 cc 退到 1.2.67)。

关闭有修改的标签问「保存 / 放弃修改 / 取消」;批量关闭、关 SFTP 标签、⌘Q(Windows / Linux 还有
窗口的关闭按钮)有未保存的文件时问一次。状态栏显示光标位置和编码、换行方式。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-05 21:37:26 +08:00
1 parent 09dc45e7a2
commit c679d9d8a0
41 files changed
+3955 -78

No files matched your search

+18 -7
View File
@@ -8,9 +8,9 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;标题栏右上角「临时连接…」打开的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」(见下面「临时连接不保存,功能齐全」)。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);SFTP 里的文本文件(远程和本地)可以在内置编辑器里改,⌘S 原地写回(见下面「编辑器」);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;标题栏右上角「临时连接…」打开的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」(见下面「临时连接不保存,功能齐全」)。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
已确认的产品决定: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` 为准。
@@ -41,14 +41,14 @@ 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`。
- `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`)与数据库路径;`quit.rs`(退出的守卫 `set_quit_guard` / `quit_held_back`,见下面「编辑器」);`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`(`HostForm`;`HostForm::temporary` / `open_temporary_connection_dialog` 是临时连接的对话框)、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`quick_connect.rs`(开始页的「快速连接」,用户给了参考图:gpui-kit 的 `List`(搜索框、↑↓、回车和单击确认)列出保存的主机,`quick_hosts` 排序(最近连接的在前,其余按名称),`QuickHost::matches` 几个词都要在名称、地址、用户名、备注或分组路径里,打开时不高亮任何一台(用户嫌预先选中一台别扭),搜索后由 `List` 自己高亮第一个匹配的、回车就连它。`QuickChoice` 是不依赖窗口的选择状态(高亮行、Tab / ⌘A 选中的、⇧↵ 连哪些),都有单元测试。**多选先关掉了**(用户定的,常量 `MULTIPLE` 为假):开着时搜索框自己要的 Tab(`IndentInline`)、⌘A(`SelectAll`)和 ⇧↵(`Enter { shift }`)由外层容器在捕获阶段 `capture_action` 截下再 `stop_propagation`,⌘ 单击也是选中,页脚列出这三个键;关着时这些都不挂,Tab、⌘A 照搜索框的本意。普通回车照常经输入框传给 `List` 的确认。连接是关掉对话框后 `window.defer` 在工作区的 focus handle 上逐台派发 `ConnectHost`,同临时连接)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。**算法按 OpenSSH 的默认来**(`ssh_config`:OpenSSH 默认能连上的,ShellRS 也要能连上):russh 自己的密钥交换列表不含 NIST 曲线,素数组交换(GEX)又要至少 3072 位;用户的堡垒机是老 Java 上的 Apache SSHD 2.10,没有 curve25519、GEX 最大只有 2048 位,于是 russh 选了 GEX、服务器找不到合适的素数直接断开(「Disconnected」/「early eof」),OpenSSH 却选 `ecdh-sha2-nistp256` 连上了。所以 `KEX_ORDER` 照 OpenSSH 的顺序加上 `ecdh-sha2-nistp*`,`MIN_DH_GROUP_BITS` 是 OpenSSH 的 2048;没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的老算法。`route_tests` 的 `a_server_with_only_2048_bit_groups_or_nist_curves_is_reached` 用只给 2048 位组、只有 NIST 曲线的进程内服务端守着。连接失败的说明一律经 `describe_login_error`(终端、SFTP、测试连接、外部 CLI、转发):它沿错误链找到真正的原因(网络、缺凭据、`NoCommonAlgo` 时写明服务器支持哪些算法),找不到时给出整条错误链(`{error:#}`),不再只剩「无法建立 SSH 连接,请检查地址、端口和主机密钥」这一层。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算Line truncated
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。选「密码」又没填密码时不拦着(用户定的:不强制填密码,连不上就连不上):`LoginTest::password_left_empty` 为真,测试按「无密码」登录(免认证、SSH Agent、默认私钥),服务器要密码时(`PasswordWanted`,「无密码」那句报错的类型)改报「未填写密码」,不说「请改用…」(`a_test_with_the_password_left_empty_goes_without_one`)。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。`TemporarySecretStore`(`temporary.rs`)是 `HostStore` 包在注入的存储外面的一层:`SecretRef::Temporary { host }`(不保存的连接的密码)只记在它的内存里,其余原样转给钥匙串,`remember` / `forget` 只碰内存、可以在 UI 线程上调。服务名是 `SERVICE` = `shellrs`。
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`file_edit.rs`(给编辑器读写文件:`FileLocation`、`edit_target`、`open_for_edit`(先读后开,发 `ExplorerPanelEvent::EditFile`,读不了的在 `refuse_edit` 说明)、`read_file` / `write_remote_file`(远程的挂在 `files` 里,`on_event` 收到 `FileRead` / `FileWritten` 时完成,断开时丢掉)、`reload_if_showing`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`edit.rs`(编辑器的整文件读写:`TextFile::decode` / `TextFormat::encode`(BOM、纯 CRLF、二进制和 UTF-8 的判断)、`read_text`(`stat` 后按上限拒绝,流水线读进预分配的缓冲区,第一块有 NUL 就放弃)、`write_in_place`(核对 `FileStamp`、`RemoteFs::open_replace` 截断写、`sync`、再 `stat`),本地的 `read_local_text` / `write_local_text` 经 `LocalDirectoryProvider::read_file` / `write_file`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `forward/` — 端口转发。规则的模型在 `host/forward.rs`,这里是运行和界面。
- `transport.rs`:可注入的接口 `ForwardTransport` / `ForwardTransportProvider`,命令 `ForwardCommand`(`PromptReply`、`Stop`)和事件 `ForwardEvent`(`Connecting`、`Prompt`、`Listening`、`Connections(n)`、`ConnectionFailed`、`Reconnecting`,以及两个终结事件 `Failed` / `Stopped`)。
- `worker.rs`:真实实现 `SshForwardTransportProvider`。每次启动一条规则就是一个线程(`shellrs-forward`)加一个 current-thread runtime,结构照 `sftp/worker.rs`;本地转发是 `channel_open_direct_tcpip` 加 `copy_bidirectional`,远程转发是 `tcpip_forward` 加处理器交出来的通道,动态转发先过 SOCKS 握手。`describe_*` 把错误映射成中文。
@@ -58,13 +58,14 @@ 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`。
- `editor/` — 内置编辑器。`model.rs`(纯函数:`language_for` 按文件名选高亮语言、`indentation`(Makefile 和多数行用 Tab 缩进的文件用真 Tab,`EditorState` 默认是两个空格)、状态栏的 `cursor_label` / `format_label`,`EditorId` 和查重用的 `EditorKey`)、`editor_panel.rs`(`EditorPanel`:中间区标签,`EditorState` + gpui-kit 的 `Editor`,细工具栏(主机徽章、路径、状态标签、「重新加载」「保存」),`EditorSource::{Remote, Local}` 决定读写走 SFTP 标签还是本机,`execute(EditorCommand)` 是保存、覆盖、重新加载、复制路径,`confirm_close` 是三按钮的关闭问题)。依赖 `explorer` 的公开接口(`ExplorerPanel::read_file` / `write_remote_file`、`FileLocation`)和 `sftp`,不依赖 `workspace`;开标签、查重、关闭和确认在 `workspace/editors.rs`。
- `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`)。
- `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。
- `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉;标题右边是小号的「快速连接」(`QuickConnect`,primary)和「新建主机…」两个按钮)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`editors.rs`(`impl Workspace` 的编辑器部分:`open_editor`、查重的 `editor_for_location`、关闭和三种确认、退出守卫 `ask_before_quit`、批量关闭 `close_center_tabs`)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉;标题右边是小号的「快速连接」(`QuickConnect`,primary)和「新建主机…」两个按钮)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`、WinSCP 的开关(`/sessionname=` 当标签名,`/newinstance`、`/ini=` 等列在 `WINSCP_SWITCHES` 里的忽略;只认列出的名字,所以 `/tmp/x` 这种路径不算),或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
@@ -87,6 +88,16 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **传输队列在标签这一层,引擎一次只跑一批。** `ExplorerPanel` 持有 `TransferQueue`:确认框派发的 `ExplorerCommand::Enqueue(TransferJob)` 只是入队(请求在入队时就校验,失败直接报错不入队),`start_next_if_idle` 在引擎空闲(`engine_busy` 为假,它由发出 Upload / Download / Resume / Discard 置真、由引擎的 `Idle` 清掉)且已连接时把第一个排队中的批次交出去。引擎处理完每个命令都会发 `Idle`(完成、停止、丢弃、准备失败都一样),标签收到后才推进,所以前一批的事件不会串到下一批上。进度事件只归队首(运行中或已停止的那一批),没有队首时丢弃(例如丢弃续传进度后引擎回的空 `Completed`)。**已停止的批次挡在队首**:引擎还留着它等「继续」,开始新批次会把它换掉,所以要等用户继续、丢弃续传进度或「移出队列」(续传记录留在磁盘上)之后队列才往下走。确认框和询问框分开记录(`question_shown`):批次停止或结束时只关询问框,不碰用户正在填的下一批的确认框。`TransferProgress` 为队列补了当前文件(`current_source` / `current_bytes` / `current_total`)、只在传输阶段累计的用时 `elapsed`、最近 3 秒的速度(`sftp/speed.rs` 的 `TransferClock`,等待回答和重连时归零)、跳过和失败文件的字节 `settled_bytes`(`fraction()` 因此能到 100%,`remaining()` 按剩余字节除以速度)和重连说明 `note`(`current` 始终是路径)。队列面板和双栏在同一个 `v_resizable` 里,队列为空时那一块 `visible(false)`,双栏在元素树里的位置不变,分栏比例不丢。
- **SFTP 独立于终端。** 主机状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除主机会停止传输并保留续传数据。每个 SFTP 标签的传输引擎一次跑一个批次,其余在标签的传输队列里等,上传和下载共用(`TransferBatch`,类型统一叫 `Transfer*`,方向在 `TransferProgress::direction`),来源、端点和目标在确认时固定。SFTP 标签按 `ExplorerId` 登记(工作区的 `explorers`,`explorers_of(host)` 列出一个主机的全部 SFTP 标签),`ExplorerAction`、`CloseExplorer`、`RenameExplorer`、`CenterTab::Explorer` 和 SFTP 认证问答的 `PromptOwner::Sftp` 都按标签 id 寻址;按主机做的事(连接状态汇总、断开、删除主机 / 分组、统计要停止的传输)要遍历 `explorers_of`。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **编辑器:先读后开,原地写回,读写走 SFTP 标签自己的连接。**
- **打开**:`ExplorerCommand::Edit { remote, path }`(`path` 为空时是光标所在的文件;`Open` 遇到文件就派发带路径的 `Edit`)。工作区在 `on_explorer_action` 里先查重(`edit_target` → `editor_for_location`),已开就 `show_editor`,不再读;否则 `ExplorerPanel::open_for_edit` 读文件(`opening` 防重复,面板状态栏 0.3 秒后写「正在打开 x…」),读成功才发 `ExplorerPanelEvent::EditFile`,工作区 `open_editor` 建标签;读的期间又打开同一个文件,`open_editor` 还会再查一次。拒绝(`ReadFailure::{NotFile, TooLarge, NotText}`)是 `refuse_edit` 的对话框,远程的确定按钮是「下载…」(派发 `DownloadPaths`),`Failed` 是错误通知。
- **读写**:远程的经 `SftpCommand::ReadFile` / `WriteFile`,worker 照 `List` / `Operate` 另起任务、和传输并行,回答是 `SftpEvent::FileRead` / `FileWritten`;`ExplorerPanel` 把等回答的 `oneshot` 挂在 `files` 里、在 `on_event`(UI 线程)上完成,断开时 `abandon_remote_operations` 一起丢掉(读当作失败,写当作 `Interrupted`)。没连接时 `read_file` / `write_remote_file` 返回 `None` 并已经弹了「SFTP 连接已断开 / 重新连接」,编辑器不再另报错。本地的经 `LocalDirectoryProvider::read_file` / `write_file`(测试的假实现只在内存里)。
- **原地写回**(用户定的):`RemoteFs::open_replace` 是 `CREATE | TRUNCATE | WRITE`,跟随链接、同一个 inode,所以所有者、权限、硬链接和指向它的符号链接都不变;代价是写到一半断网时服务器上是半个文件,这时 `write_in_place` 把错误包成 `SaveFailure::Interrupted`(作为 context,原因留在链上,`is_network_error` 照常认出断网),编辑器作废 `stamp`(下一次保存不再核对)并说明「可能只写了一部分」。不要改成上传那套 `.filepart` 加改名:root 改别人的文件会变成 root 所有、硬链接会断、目录不可写时存不了。
- **保存前核对**:`FileStamp`(大小加修改时间;SFTP v3 只有秒,同一秒里同样大小的改动发现不了,WinSCP 也一样)。被改过或删掉是 `SaveFailure::Changed`,什么也没写;「覆盖」派发 `EditorCommand::Overwrite { close }`,`expected` 为空,文件不在了就重新建。保存成功后编辑器发 `Saved`,工作区让正显示那个目录的 SFTP 面板重读(远程只问同一台主机的 SFTP 标签,本地问所有标签)。
- **文本**:只认 UTF-8(`TextFile::decode`:前 8 KB 有 NUL 是二进制;BOM 去掉、保存时加回;所有换行都是 CRLF 才换成 LF 给编辑器、保存时换回,混合换行原样留着,`EditorState` 本身不改 `\r\n`)。上限 `EDIT_LIMIT`(5 MB),远程的在 `stat` 之后、读之前就拒绝。是否有修改按文本比较(`Rope` 先比长度再比内容),撤回到原样就不算修改;保存时取的是那一刻的文本快照,保存期间接着打的字仍算修改。
- **关闭**:`CloseEditor` 有修改时 `confirm_close`(「取消」「放弃修改」「保存」,「保存」= `SaveAndClose`,写成功才发 `CloseRequested`);`EditorCommand::CloseConfirmed` 由工作区直接移除标签。关 SFTP 标签时它的远程编辑器有修改就问一次(有传输时并进 `confirm_close_transfer` 的说明),确认后 `ExplorerPanelEvent::Closed` 把它的编辑器一起移除;删除主机 / 分组关掉的 SFTP 标签同样带走编辑器,不另问。批量关闭(`on_close_tabs` → `close_center_tabs`)对所有会丢的修改只问一次,不逐个弹三按钮框。
- **退出守卫**:`app::set_quit_guard` 是全局量,工作区构造时装上,全局的 `Quit` 处理器 `cx.defer` 之后经守卫记下的窗口句柄问 `Workspace::ask_before_quit`(派发 `Quit` 时窗口正被借用,直接 `update` 会失败),有未保存的文件就弹「退出 ShellRS?」并拦下,确认后 `cx.quit()`。不用工作区元素上的 `on_action`:对话框关掉后焦点为空,动作只到窗口根,到不了工作区。Windows / Linux 关窗口即退出,`hide_when_closed` 在那里注册 `on_window_should_close` 调 `quit_held_back`;macOS 关窗口只是隐藏,不动。macOS 程序坞右键「退出」、注销关机走 `applicationShouldTerminate`,GPUI 不给否决的机会,拦不住;在线升级的「重启并安装」也拦不住,`RestartImpact::unsaved_files` 只在对话框里写明会丢。
- **⌘S** 绑在编辑器的 key context `FileEditor` 上(`EditorShortcut(EditorCommand::Save)`,工作区交给焦点所在的编辑器);gpui-base 的 `Input` 上下文不绑 ⌘S / ⌘W,所以在编辑器的输入区和它的查找框里都有效,升级 gpui-kit 时要再核对。`set_active(true)` 把焦点交给 `EditorState`,否则「×」、⌘W、⌘S 都会失效。
- **高亮**:`Cargo.toml` 给 gpui-kit 开的 `tree-sitter-*` 特性就是能高亮的语言(`tree-sitter` 本身带 JSON),`language_for` 只能给出这些名字(`every_language_named_is_built_in` 查 `LanguageRegistry`),加语言要同时加特性和 `[profile.dev.package]` 的优化项。`tree-sitter-sequel`(SQL)要求 `cc ~1.2`,所以 `Cargo.lock` 把 `cc` 停在 1.2.x,别随手升到 1.3 以上。
- **下载严格按顺序写盘。** 读请求并发且可能乱序、短读返回,`download_file` 用重排缓冲区按偏移顺序写入 `.filepart`,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。
- **文件操作从不跟随符号链接。** 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走 `trash` crate 的 NSFileManager 方式(不弹自动化权限、不放声音)。文件操作与传输批次可以并行;同一侧面板有操作在跑时(`FilePane::is_busy`)第二行工具栏和对应命令都不接受新操作,结果回来后面板重新读目录。
- **选择归面板,不归表格。** `DataTable` 只支持单行选择,所以表格设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`(`..` 永远不进选择集),通过 `ListingContext` 快照交给委托;同 WinSCP 关掉「整行选择」,**名称单元格才是项目**:`render_td` 给出外层 `name-cell:<名称>`(`size_full`,画悬停灰底、选中底色和光标框,接单击、双击打开和右键,`FileListing::name_events`)和里面只有内容宽的 `name:<名称>`(图标加文件名,只接文件拖动)。所以在名称单元格里任何地方单击都选中这一行,但只有拖图标和文件名才是拖文件,从名称右边的空白拖是框选。行上不画任何状态:`DataTable` 在 `render_tr` 的样式之后给整行加 hover 底色、我们改不掉,所以行的第一个子元素是一块铺满的 `tokens.table` 底色(表格自己的不透明背景)把它盖住,单元格画在它上面;右键时表格给整行画的边框也在容器的右键处理里立刻清掉(`press_empty` 每次都 `clear_menu_row`)。为了连单元格的内边距一起铺满,名称列用 `name_column` 把表格的单元格内边距设成 0,名称单元格和它的列标题各自 `px_1p5()`(等于 `.small()` 表格的 6 px,文字因此和标题对齐);`Size::table_cell_padding` 不是公开 API,拿不到表格自己的值。单击、双击打开、右键都挂在名称上(`FileListing::name_events`),行上只剩拖放目标和记几何的 canvas。选中的名称一律只有底色,光标框只画在未选中的光标项上(`..`、刚用 ⌘ 取消选择的项),否则多选的最后一项会多出一圈边框。名称单元格的 `on_click` 读 `ClickEvent` 的修饰键和点击次数,直接更新面板(这是事件时刻,不是渲染时刻)。名称单元格和列标题以外都是空白处:按下落在哪里记在 `ListGeometry::pressed`(`Pressed::Empty` / `Name` / `Title`,容器在捕获阶段复位成 `Empty`,名称和标题在冒泡时改写),容器的 `on_click` 和右键按下在 `Empty` 且没按 ⌘ / Shift 时清空选择(`FilePane::press_empty`,连光标一起清)。表格底部的横向滚动条会在按下时停止冒泡,点在它上面不算空白处。键盘上的方向键 / Home / End / 翻页绑在 `"LocalFileList > DataTable"` 这一层,盖过表格自带的单行选择键;←、→ 绑 `NoAction`,因为表格在行模式下也会把它们切成列选择。
@@ -208,4 +219,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
## 测试约定
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode` / `workbuddy`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltLine truncated
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`editor`(内置编辑器:打开、保存、覆盖、关闭和退出的确认)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode` / `workbuddy`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / Line truncated
Generated
+195 -3
View File
@@ -1060,9 +1060,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.5"
version = "1.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2929,6 +2929,22 @@ dependencies = [
"smallvec",
"smol",
"tracing",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-css",
"tree-sitter-diff",
"tree-sitter-go",
"tree-sitter-html",
"tree-sitter-javascript",
"tree-sitter-json",
"tree-sitter-lua",
"tree-sitter-make",
"tree-sitter-md",
"tree-sitter-php",
"tree-sitter-python",
"tree-sitter-sequel",
"tree-sitter-toml-ng",
"tree-sitter-yaml",
"uuid",
"windows 0.58.0",
]
@@ -8027,6 +8043,12 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6"
[[package]]
name = "streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
[[package]]
name = "strict-num"
version = "0.1.1"
@@ -8349,7 +8371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -8794,6 +8816,176 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "tree-sitter"
version = "0.26.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29"
dependencies = [
"cc",
"regex",
"regex-syntax",
"serde_json",
"streaming-iterator",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-bash"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-css"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-diff"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-go"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-html"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-javascript"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-json"
version = "0.24.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
[[package]]
name = "tree-sitter-lua"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-make"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-md"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-php"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-python"
version = "0.23.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-sequel"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-toml-ng"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-yaml"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "triomphe"
version = "0.1.16"
+37 -1
View File
@@ -21,7 +21,26 @@ name = "shellrs-cli"
path = "src/bin/shellrs-cli.rs"
[dependencies]
gpui-kit = "0.6.1"
gpui-kit = { version = "0.6.1", features = [
# The built-in editor's syntax highlighting, for what servers hold most:
# `tree-sitter` itself brings JSON. Each grammar is compiled C, so the
# list stays short.
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-css",
"tree-sitter-diff",
"tree-sitter-go",
"tree-sitter-html",
"tree-sitter-javascript",
"tree-sitter-lua",
"tree-sitter-make",
"tree-sitter-markdown",
"tree-sitter-php",
"tree-sitter-python",
"tree-sitter-sql",
"tree-sitter-toml",
"tree-sitter-yaml",
] }
alacritty_terminal = "0.26"
anyhow = "1"
async-channel = "2"
@@ -127,3 +146,20 @@ crypto-bigint = { opt-level = 3 }
crypto-primes = { opt-level = 3 }
bcrypt-pbkdf = { opt-level = 3 }
blowfish = { opt-level = 3 }
# The editor parses whole files of up to 5 MB to highlight them.
tree-sitter = { opt-level = 3 }
tree-sitter-bash = { opt-level = 3 }
tree-sitter-css = { opt-level = 3 }
tree-sitter-diff = { opt-level = 3 }
tree-sitter-go = { opt-level = 3 }
tree-sitter-html = { opt-level = 3 }
tree-sitter-javascript = { opt-level = 3 }
tree-sitter-json = { opt-level = 3 }
tree-sitter-lua = { opt-level = 3 }
tree-sitter-make = { opt-level = 3 }
tree-sitter-md = { opt-level = 3 }
tree-sitter-php = { opt-level = 3 }
tree-sitter-python = { opt-level = 3 }
tree-sitter-sequel = { opt-level = 3 }
tree-sitter-toml-ng = { opt-level = 3 }
tree-sitter-yaml = { opt-level = 3 }
+1
View File
@@ -62,6 +62,7 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
- Upload and download by dragging or by shortcut, recursively. Start new transfers while one runs; they wait in a per-tab transfer queue.
- Resumable transfers: data goes to a `.filepart` first, dropped connections are retried after 1, 3 and 10 seconds, and after a restart ShellRS offers to resume the same transfer.
- Delete (local items go to the Trash), rename, create, and change permissions (a 3×3 grid plus octal, optionally recursive).
- Built-in editor: double-click a text file to edit it, ⌘S writes it back in place, after checking nobody else changed it; syntax highlighting for common formats.
- Each SFTP tab has its own connection and asks only for the SFTP subsystem, so no shell is needed on the server.
**Opening from a bastion host**
+1
View File
@@ -62,6 +62,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
- 拖动或快捷键上传、下载,支持递归。传输进行中还能继续发起,新批次进传输队列依次执行。
- 断点续传:先写 `.filepart`,断线后按 1、3、10 秒自动重连;重启后再传同样的文件,会询问是否续传。
- 删除(本地移到废纸篓)、重命名、新建、修改权限(3×3 复选框加八进制,可递归)。
- 内置编辑器:双击文本文件直接编辑,⌘S 原地写回服务器,保存前检查文件是否被别人改过;常见格式语法高亮。
- 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。
**从堡垒机打开**
+15 -2
View File
@@ -265,7 +265,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
- 框选:在名称右边的空白、大小、类型、时间等列或列表下方按下拖动,画出选框,框住的行被选中;按住 ⌘ 或 Shift 开始拖动则加到已有选择上。拖到列表上下边缘时自动滚动。拖动文件名才是把文件拖到另一侧传输:按下未选中的文件名时只选中它(其他选择取消),拖走的就是它一个;按下已选中的文件名则拖走全部已选。拖动时只有到了另一侧列表上才显示「上传 / 下载 N 个项目」,在自己的列表或其他不能放下的地方,指针显示为禁止。
- 键盘:↑↓、Home、End、PageUp、PageDown 移动,加 Shift 扩展;空格或 Insert 切换当前行;⌘A 全选;Tab 切到另一侧。
- 右键未选中的名称格会先选中它;右键空白处取消选择并弹出空白处菜单。
- 文件名上的菜单:打开、上传… / 下载…、删除、重命名…、属性…。
- 文件名上的菜单:打开(目录)或编辑(文件)、上传… / 下载…、删除、重命名…、属性…。
- 空白处的菜单:前往(上级 / 根 / 主目录 / 后退 / 前进)、刷新、添加路径到书签、新建。
### 快捷键
@@ -277,13 +277,14 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
| 新建文件夹 | F7 |
| 删除 | F8、Delete;macOS 另有 ⌘⌫ |
| 属性 | F9;macOS 另有 ⌘I |
| 编辑文件 | F4 |
| 刷新 | ⌘R / Ctrl+R |
| 上级目录 | Backspace;macOS 另有 ⌘↑ |
| 根目录 | ⌘\ / Ctrl+\ |
| 主目录 | macOS ⌘⇧H,其他平台 Ctrl+H |
| 后退 / 前进 | ⌥← / ⌥→;macOS 另有 ⌘[ / ⌘] |
| 添加路径到书签 | ⌘D / Ctrl+D |
| 打开目录 | Enter;macOS 另有 ⌘↓ |
| 打开目录 / 编辑文件 | Enter;macOS 另有 ⌘↓ |
| 打开目录/书签 | ⌘O / Ctrl+O;macOS 另有 ⌘⇧G |
### 文件操作
@@ -302,6 +303,18 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
- 含目录时可以递归应用;递归时默认「为目录添加执行权限(X)」,避免目录无法进入。
- 符号链接不参与修改权限。
### 编辑文件
文本文件可以直接在 ShellRS 里改,远程和本地的都行(与 WinSCP 的内置编辑器相同):
- 打开:双击文件、选中后回车或按 F4、右键「编辑」。「新建 › 文件…」建好后也直接打开。文件在中间区的「编辑器」标签里打开,和终端、SFTP 一样可以拖成分栏;同一个文件只开一个标签,再打开就切到它。
- 编辑器有行号、查找替换(⌘F / Ctrl+F,替换 ⌘⇧F / Ctrl+H),按文件名为 Shell、YAML、TOML、JSON、Python、JavaScript、HTML、CSS、Markdown、SQL、PHP、Go、Lua、Makefile、Diff 着色,其他文件是纯文本。Makefile 和用 Tab 缩进的文件按 Tab 输入制表符。
- 保存:⌘S / Ctrl+S 或标签上方的「保存」。远程文件经打开它的 SFTP 标签的连接直接写回原文件(原地改写),所有者、权限和指向它的链接都不变。保存前会核对文件的大小和修改时间:打开后被别人改过或删掉了,会先问是否「覆盖」,不会悄悄覆盖。写到一半连接断开时,服务器上的文件可能只写了一部分,编辑器里的内容还在,重新连接后再保存一次即可。
- 「重新加载」从服务器或磁盘重新读一遍;有没保存的修改时先确认。
- 标签上的圆点表示有没保存的修改。关闭这样的标签会问「保存」「放弃修改」还是「取消」;一次关闭多个标签、关闭 SFTP 标签(它打开的远程文件会一起关掉)或退出 ShellRS 时,有没保存的文件会先问一次。macOS 上从程序坞退出和注销关机不会询问。
- 窗口左下角是这个文件所走的 SFTP 连接(本地文件写「本机文件」),右下角是光标位置和编码、换行方式(如「UTF-8 · LF」)。
- 限制:只打开 5 MB 以内的 UTF-8 文本文件(带不带 BOM 都可以,保存时原样保留 BOM 和 CRLF 换行)。更大的文件、二进制文件和 GBK 等其他编码的文件会说明原因、不打开,远程文件可以改用「下载…」。
### 上传与下载
- 入口:
+57 -1
View File
@@ -6,6 +6,7 @@ use gpui_kit::*;
use crate::{
cli::AgentKind,
docker::{ContainerCommand, DockerObject},
editor::EditorId,
explorer::{ExplorerId, FileSizeFormat},
host::{
CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop, SnippetCategoryId, SnippetId,
@@ -197,6 +198,9 @@ id_actions! {
/// Close one SFTP tab.
CloseExplorer(ExplorerId);
/// Close one editor tab, asking first when it has unsaved changes.
CloseEditor(EditorId);
/// Open the dialog that gives one SFTP tab its own title.
RenameExplorer(ExplorerId);
@@ -338,6 +342,7 @@ pub enum CenterTab {
LocalTerminal(LocalTerminalId),
/// There is at most one settings tab.
Settings,
Editor(EditorId),
}
/// A tool of the right sidebar, in the order of the switch beside it.
@@ -423,10 +428,17 @@ pub enum ExplorerCommand {
Forward {
remote: bool,
},
/// Enter or double-click: open the directory under the cursor.
/// Enter or double-click: open the directory under the cursor, or edit
/// the file there.
Open {
remote: bool,
},
/// F4 and 编辑: open a file in the editor, `path` or else the one under
/// the cursor (WinSCP's Edit).
Edit {
remote: bool,
path: Option<String>,
},
/// WinSCP's 打开目录/书签 dialog: type a directory or pick a bookmark.
OpenDirectory {
remote: bool,
@@ -555,6 +567,50 @@ impl ExplorerAction {
&self.command
}
}
/// Commands of an editor tab, shared by its toolbar, tab menu, dialogs and
/// key bindings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditorCommand {
/// ⌘S: write the file back, unless it changed since it was read.
Save,
/// 覆盖: write it back although it changed; `close` once that worked.
Overwrite {
close: bool,
},
/// Read the file again, asking first when there are changes.
Reload,
/// Read it again, throwing the changes away.
ReloadConfirmed,
/// 保存 in the close question: save, then close if that worked.
SaveAndClose,
/// 放弃修改 in the close question: close without saving.
CloseConfirmed,
CopyPath,
}
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct EditorAction {
editor: EditorId,
command: EditorCommand,
}
impl EditorAction {
pub fn new(editor: EditorId, command: EditorCommand) -> Self {
Self { editor, command }
}
pub fn editor(&self) -> EditorId {
self.editor
}
pub fn command(&self) -> EditorCommand {
self.command
}
}
/// An editor key binding; the workspace sends it to the editor that holds
/// focus.
#[derive(Action, Clone, PartialEq, Eq)]
#[action(namespace = shellrs, no_json)]
pub struct EditorShortcut(pub EditorCommand);
/// A file-list key binding. The binding carries the pane side, and the
/// workspace sends it to the explorer that holds focus.
#[derive(Action, Clone, PartialEq, Eq)]
+2
View File
@@ -65,6 +65,8 @@ icon_assets!(
Ban,
CircleDot,
FileText,
FilePenLine,
Save,
Zap,
ExternalLink
]
+12 -1
View File
@@ -4,6 +4,7 @@ mod actions;
mod app_icon;
mod assets;
mod paths;
mod quit;
mod window_hiding;
pub use actions::*;
@@ -12,6 +13,7 @@ pub use assets::{AppAssets, CatalogIcon, DOCKER_ICON};
pub use paths::{
cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path, updates_dir,
};
pub use quit::{quit_held_back, set_quit_guard};
pub use window_hiding::{bring_forward, hide_when_closed};
use gpui_kit::component::{Theme, dock::ToggleZoom};
@@ -27,6 +29,8 @@ pub const FORWARD_PANEL_CONTEXT: &str = "ForwardPanel";
/// Key context of the credential list.
pub const CREDENTIAL_PANEL_CONTEXT: &str = "CredentialPanel";
/// Key contexts of the two SFTP file lists.
/// Key context of an editor tab.
pub const EDITOR_CONTEXT: &str = "FileEditor";
pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList";
pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList";
@@ -36,7 +40,7 @@ pub fn init(cx: &mut App) {
deepen_list_hover(cx);
gpui_kit::component::set_locale("zh-CN");
cx.bind_keys(key_bindings());
cx.on_action(|_: &Quit, cx: &mut App| cx.quit());
cx.on_action(|_: &Quit, cx: &mut App| quit::quit(cx));
}
/// Use a stronger version of the theme's neutral list hover color throughout
@@ -80,6 +84,11 @@ fn key_bindings() -> Vec<KeyBinding> {
KeyBinding::new(&primary("-"), ZoomOut, None),
KeyBinding::new(&primary("0"), ZoomReset, None),
KeyBinding::new(&primary("q"), Quit, None),
KeyBinding::new(
&primary("s"),
EditorShortcut(EditorCommand::Save),
Some(EDITOR_CONTEXT),
),
KeyBinding::new("shift-escape", ToggleZoom, None),
KeyBinding::new("enter", ConnectSelected, Some(HOST_PANEL_CONTEXT)),
KeyBinding::new("enter", ConnectSelected, Some(RECENT_HOSTS_CONTEXT)),
@@ -207,6 +216,8 @@ fn file_list_key_bindings() -> Vec<KeyBinding> {
bind("delete", ExplorerCommand::Delete { remote }, context),
bind("f8", ExplorerCommand::Delete { remote }, context),
bind("f9", ExplorerCommand::Properties { remote }, context),
// WinSCP's 编辑.
bind("f4", ExplorerCommand::Edit { remote, path: None }, context),
bind("f5", ExplorerCommand::Transfer { remote }, context),
bind(
"space",
+63
View File
@@ -0,0 +1,63 @@
//! ⌘Q asks first when quitting would lose something: an editor's changes
//! that are not saved yet.
//!
//! The guard is a global rather than a listener on the workspace's element:
//! after a dialog closes nothing is focused, and then an action reaches only
//! the root of the window, never the workspace, while a global listener
//! hears it wherever focus is.
use std::rc::Rc;
use gpui_kit::*;
type Ask = Rc<dyn Fn(&mut Window, &mut App) -> bool>;
/// Asks the window whether a quit may go ahead. `ask` returns true when it
/// held the quit back, having asked the user (and quitting itself if they
/// agree).
struct QuitGuard {
window: AnyWindowHandle,
ask: Ask,
}
impl Global for QuitGuard {}
/// Put a quit through `ask`, run in `window`, from now on.
pub fn set_quit_guard(
window: AnyWindowHandle,
ask: impl Fn(&mut Window, &mut App) -> bool + 'static,
cx: &mut App,
) {
cx.set_global(QuitGuard {
window,
ask: Rc::new(ask),
});
}
/// The `Quit` action: quit, unless the guard holds it back.
pub(super) fn quit(cx: &mut App) {
let Some(guard) = cx.try_global::<QuitGuard>() else {
cx.quit();
return;
};
let (window, ask) = (guard.window, guard.ask.clone());
// The action is dispatched while the window is being updated, so it can
// only be asked once that is over.
cx.defer(move |cx| {
let held = window
.update(cx, |_, window, cx| ask(window, cx))
.unwrap_or(false);
if !held {
cx.quit();
}
});
}
/// Whether the guard holds a quit back, asked from inside the window: when
/// its close button would quit (Windows and Linux).
pub fn quit_held_back(window: &mut Window, cx: &mut App) -> bool {
match cx.try_global::<QuitGuard>().map(|guard| guard.ask.clone()) {
Some(ask) => ask(window, cx),
None => false,
}
}
+7 -3
View File
@@ -6,11 +6,13 @@
//! and the Dock icon could not bring one back. So, like Mac terminals,
//! closing hides the app: the Dock icon, ⌘Tab or opening ShellRS again
//! shows the window as it was, and ⌘Q quits. Windows and Linux quit when
//! the window closes, as GPUI does there by default.
//! the window closes, as GPUI does there by default, but like ⌘Q only once
//! the quit guard lets them (unsaved changes in an editor are asked about).
use gpui_kit::{App, Window};
/// Make the window's close button hide ShellRS. Nothing outside macOS.
/// Make the window's close button hide ShellRS on macOS. Elsewhere closing
/// quits, after asking the quit guard.
#[cfg(target_os = "macos")]
pub fn hide_when_closed(window: &Window, cx: &App) {
window.on_window_should_close(cx, |_, cx| {
@@ -20,7 +22,9 @@ pub fn hide_when_closed(window: &Window, cx: &App) {
}
#[cfg(not(target_os = "macos"))]
pub fn hide_when_closed(_: &Window, _: &App) {}
pub fn hide_when_closed(window: &Window, cx: &App) {
window.on_window_should_close(cx, |window, cx| !super::quit::quit_held_back(window, cx));
}
/// Show the window again, in front of other apps: when the Dock icon is
/// clicked, or ShellRS is opened again while it runs.
+724
View File
@@ -0,0 +1,724 @@
use std::path::PathBuf;
use gpui_kit::component::{
ActiveTheme as _, Disableable as _, Icon, Sizable as _, WindowExt as _,
button::{Button, ButtonVariants as _},
dialog::{DialogAction, DialogFooter},
dock::{BasePanel, Panel, PanelEvent, TabGroup},
h_flex,
input::{Editor, EditorState, InputEvent, Position, Rope, RopeExt as _},
menu::PopupMenu,
notification::Notification,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::{EditorId, EditorKey, cursor_label, format_label, indentation, language_for};
use crate::app::{
CatalogIcon, CenterTab, CloseEditor, EDITOR_CONTEXT, EditorAction, EditorCommand,
EditorShortcut,
};
use crate::explorer::{ExplorerId, ExplorerPanel, FileLocation};
use crate::host::{HostId, HostStore};
use crate::sftp::{
FileStamp, ReadFailure, SaveFailure, SharedLocalDirectoryProvider, TextFile, TextFormat,
read_local_text, write_local_text,
};
use crate::shared::{ClosableTabTitle, HostMark, close_tab_items, confirm_danger, soft_tag};
/// Where an editor reads and writes its file. A remote file goes through
/// the SFTP tab that opened it, over that tab's connection; a local one
/// straight to the disk.
pub enum EditorSource {
Remote {
explorer: WeakEntity<ExplorerPanel>,
explorer_id: ExplorerId,
host: HostId,
},
Local {
provider: SharedLocalDirectoryProvider,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EditorPanelEvent {
Activated(EditorId),
Closed(EditorId),
/// What the window's status line shows for this tab changed: the
/// cursor, or whether there are changes.
Changed(EditorId),
/// The file was written; panes showing its folder read it again.
Saved(EditorId),
/// A save asked to close the tab after it is done, and it is.
CloseRequested(EditorId),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
Idle,
Saving,
Reloading,
}
/// A center tab that edits one text file.
pub struct EditorPanel {
id: EditorId,
source: EditorSource,
location: FileLocation,
store: Entity<HostStore>,
state: Entity<EditorState>,
/// The text as last read or saved, to tell whether there are changes.
saved: Rope,
format: TextFormat,
/// What the file looked like when read or saved; `None` once a save
/// broke off halfway, after which the file is no longer what was read.
stamp: Option<FileStamp>,
dirty: bool,
phase: Phase,
/// Close the tab once the save under way succeeds (保存 when closing).
close_after_save: bool,
cursor: Position,
/// The workspace's focus handle, which commands from dialogs and
/// buttons are dispatched on.
dispatch: FocusHandle,
tab_group: Option<WeakEntity<TabGroup>>,
_task: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
}
impl EditorPanel {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: EditorId,
source: EditorSource,
location: FileLocation,
file: TextFile,
store: Entity<HostStore>,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let language = language_for(&location.name());
let (text, format, stamp) = file.into_parts();
let tab = indentation(language, &text);
let state = cx.new(|cx| {
EditorState::new(window, cx)
.language(language)
.tab_size(tab)
.default_value(text)
});
let saved = state.read(cx).text().clone();
let subscriptions = vec![
cx.subscribe(&state, |this, _, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
this.update_dirty(cx);
}
}),
// The cursor moves without a change of text.
cx.observe(&state, |this, state, cx| {
let cursor = state.read(cx).cursor_position();
if cursor != this.cursor {
this.cursor = cursor;
cx.emit(EditorPanelEvent::Changed(this.id));
}
}),
cx.observe(&store, |_, _, cx| cx.notify()),
];
Self {
id,
source,
location,
store,
state,
saved,
format,
stamp: Some(stamp),
dirty: false,
phase: Phase::Idle,
close_after_save: false,
cursor: Position::default(),
dispatch,
tab_group: None,
_task: None,
_subscriptions: subscriptions,
}
}
pub fn id(&self) -> EditorId {
self.id
}
/// The file this tab edits, to find it when the file is opened again.
pub fn key(&self) -> EditorKey {
match (&self.source, &self.location) {
(EditorSource::Remote { host, .. }, FileLocation::Remote(path)) => {
EditorKey::Remote(*host, path.clone())
}
_ => EditorKey::Local(PathBuf::from(self.location.path())),
}
}
pub fn location(&self) -> &FileLocation {
&self.location
}
pub fn name(&self) -> String {
self.location.name()
}
/// The SFTP tab a remote file is read and written through.
pub fn explorer_id(&self) -> Option<ExplorerId> {
match &self.source {
EditorSource::Remote { explorer_id, .. } => Some(*explorer_id),
EditorSource::Local { .. } => None,
}
}
pub fn host_id(&self) -> Option<HostId> {
match &self.source {
EditorSource::Remote { host, .. } => Some(*host),
EditorSource::Local { .. } => None,
}
}
/// There are changes not saved yet.
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn state(&self) -> &Entity<EditorState> {
&self.state
}
pub fn tab_group(&self) -> Option<WeakEntity<TabGroup>> {
self.tab_group.clone()
}
pub fn contains_focus(&self, window: &Window, cx: &App) -> bool {
self.state
.read(cx)
.focus_handle(cx)
.contains_focused(window, cx)
}
/// 「行 12,列 5」 for the window's status line.
pub fn cursor_label(&self) -> String {
cursor_label(self.cursor.line, self.cursor.character)
}
/// 「UTF-8 · LF」 for the window's status line.
pub fn format_label(&self) -> String {
format_label(self.format)
}
pub fn execute(&mut self, command: EditorCommand, window: &mut Window, cx: &mut Context<Self>) {
match command {
EditorCommand::Save => self.save(false, window, cx),
EditorCommand::Overwrite { close } => {
self.close_after_save = close;
self.save(true, window, cx);
}
EditorCommand::SaveAndClose => {
if self.dirty {
self.close_after_save = true;
self.save(false, window, cx);
} else {
cx.emit(EditorPanelEvent::CloseRequested(self.id));
}
}
EditorCommand::Reload => {
if self.dirty {
let action = EditorAction::new(self.id, EditorCommand::ReloadConfirmed);
let dispatch = self.dispatch.clone();
confirm_danger(
format!("放弃对“{}”的修改并重新加载?", self.name()).into(),
Some("编辑器里没有保存的修改会丢失。".into()),
"重新加载",
std::rc::Rc::new(move |window, cx| send(&dispatch, &action, window, cx)),
window,
cx,
);
} else {
self.reload(window, cx);
}
}
EditorCommand::ReloadConfirmed => self.reload(window, cx),
// The workspace closes the tab.
EditorCommand::CloseConfirmed => {}
EditorCommand::CopyPath => {
cx.write_to_clipboard(ClipboardItem::new_string(self.location.path()))
}
}
}
fn update_dirty(&mut self, cx: &mut Context<Self>) {
let dirty = {
let text = self.state.read(cx).text();
text.len() != self.saved.len() || *text != self.saved
};
if dirty != self.dirty {
self.dirty = dirty;
cx.emit(EditorPanelEvent::Changed(self.id));
cx.notify();
}
}
/// Write the text back; `overwrite` skips the check that nobody changed
/// the file since.
fn save(&mut self, overwrite: bool, window: &mut Window, cx: &mut Context<Self>) {
if self.phase != Phase::Idle || !self.dirty && !overwrite {
if self.close_after_save && !self.dirty {
self.close_after_save = false;
cx.emit(EditorPanelEvent::CloseRequested(self.id));
}
return;
}
// Typing goes on while the file is written; what is saved is this.
let snapshot = self.state.read(cx).text().clone();
let bytes = self.format.encode(&snapshot.to_string());
let expected = if overwrite { None } else { self.stamp };
let write = match &self.source {
EditorSource::Remote { explorer, .. } => {
let FileLocation::Remote(path) = &self.location else {
return;
};
let Some(explorer) = explorer.upgrade() else {
self.close_after_save = false;
return;
};
let path = path.clone();
// `None`: not connected, and the SFTP tab said so.
match explorer.update(cx, |explorer, cx| {
explorer.write_remote_file(path, bytes, expected, window, cx)
}) {
Some(write) => write,
None => {
self.close_after_save = false;
return;
}
}
}
EditorSource::Local { provider } => {
let (provider, path) = (provider.clone(), PathBuf::from(self.location.path()));
cx.background_spawn(async move {
write_local_text(provider.as_ref(), &path, &bytes, expected)
})
}
};
self.phase = Phase::Saving;
cx.notify();
self._task = Some(cx.spawn_in(window, async move |this, cx| {
let result = write.await;
let _ = this.update_in(cx, |this, window, cx| {
this.finish_save(snapshot, result, window, cx)
});
}));
}
fn finish_save(
&mut self,
snapshot: Rope,
result: Result<FileStamp, SaveFailure>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.phase = Phase::Idle;
let close = std::mem::take(&mut self.close_after_save);
let name = self.name();
match result {
Ok(stamp) => {
self.stamp = Some(stamp);
self.saved = snapshot;
self.update_dirty(cx);
cx.emit(EditorPanelEvent::Saved(self.id));
if close && !self.dirty {
cx.emit(EditorPanelEvent::CloseRequested(self.id));
}
}
Err(SaveFailure::Changed) => {
let place = if self.location.is_remote() {
"服务器上的"
} else {
"本机上的"
};
let action = EditorAction::new(self.id, EditorCommand::Overwrite { close });
let dispatch = self.dispatch.clone();
confirm_danger(
format!("“{name}”在打开后已被修改").into(),
Some(
format!(
"{place}这个文件在你打开或上次保存之后被改过或删除了。覆盖会用编辑器里的内容替换它。"
)
.into(),
),
"覆盖",
std::rc::Rc::new(move |window, cx| send(&dispatch, &action, window, cx)),
window,
cx,
);
}
Err(SaveFailure::Interrupted(message)) => {
// What is on disk is neither what was read nor what was meant.
self.stamp = None;
window.push_notification(
Notification::error(format!(
"{message}。文件可能只写了一部分,请重新连接后再保存一次。"
))
.title(format!("没有保存完“{name}”")),
cx,
);
}
Err(SaveFailure::Failed(message)) => window.push_notification(
Notification::error(message).title(format!("无法保存“{name}”")),
cx,
),
}
cx.emit(EditorPanelEvent::Changed(self.id));
cx.notify();
}
/// Read the file again, changes or not.
fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.phase != Phase::Idle {
return;
}
let read = match &self.source {
EditorSource::Remote { explorer, .. } => {
let Some(explorer) = explorer.upgrade() else {
return;
};
let location = self.location.clone();
match explorer.update(cx, |explorer, cx| explorer.read_file(&location, window, cx))
{
Some(read) => read,
None => return,
}
}
EditorSource::Local { provider } => {
let (provider, path) = (provider.clone(), PathBuf::from(self.location.path()));
cx.background_spawn(async move { read_local_text(provider.as_ref(), &path) })
}
};
self.phase = Phase::Reloading;
cx.notify();
self._task = Some(cx.spawn_in(window, async move |this, cx| {
let result = read.await;
let _ = this.update_in(cx, |this, window, cx| {
this.finish_reload(result, window, cx)
});
}));
}
fn finish_reload(
&mut self,
result: Result<TextFile, ReadFailure>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.phase = Phase::Idle;
match result {
Ok(file) => {
let (text, format, stamp) = file.into_parts();
let line = self.cursor.line;
self.state.update(cx, |state, cx| {
state.set_value(text, window, cx);
// Back to the line the cursor was on, as near as the
// new text allows.
let last = state.text().lines_len().saturating_sub(1) as u32;
state.set_cursor_position(Position::new(line.min(last), 0), window, cx);
});
self.saved = self.state.read(cx).text().clone();
self.format = format;
self.stamp = Some(stamp);
self.update_dirty(cx);
}
Err(failure) => window.push_notification(
Notification::error(failure.to_string())
.title(format!("无法重新加载“{}”", self.name())),
cx,
),
}
cx.emit(EditorPanelEvent::Changed(self.id));
cx.notify();
}
/// Ask what to do with the changes before the tab closes: save them,
/// throw them away, or keep the tab.
pub fn confirm_close(&self, window: &mut Window, cx: &mut Context<Self>) {
let name = self.name();
let dispatch = self.dispatch.clone();
let save = EditorAction::new(self.id, EditorCommand::SaveAndClose);
let discard = EditorAction::new(self.id, EditorCommand::CloseConfirmed);
let focus = self.state.read(cx).focus_handle(cx);
window.open_dialog(cx, move |dialog, _, _| {
dialog
.title(format!("保存对“{name}”的修改?"))
.overlay_closable(false)
.child(div().text_sm().child("不保存的话,这些修改会丢失。"))
.footer(
DialogFooter::new()
.child(
Button::new("editor-close-cancel")
.label("取消")
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("editor-close-discard")
.danger()
.label("放弃修改")
.on_click({
let (dispatch, discard) = (dispatch.clone(), discard.clone());
move |_, window, cx| {
window.close_dialog(cx);
send(&dispatch, &discard, window, cx);
}
}),
)
.child(
DialogAction::new()
.child(Button::new("editor-close-save").primary().label("保存")),
),
)
.on_ok({
let (dispatch, save) = (dispatch.clone(), save.clone());
move |_, window, cx| {
send(&dispatch, &save, window, cx);
true
}
})
.on_close({
let focus = focus.clone();
move |_, window, cx| window.focus(&focus, cx)
})
});
}
fn display_path(&self, cx: &App) -> String {
match &self.source {
EditorSource::Remote { host, .. } => {
let host = self
.store
.read(cx)
.host(*host)
.map(|host| host.name.to_string())
.unwrap_or_default();
format!("{host}:{}", self.location.path())
}
EditorSource::Local { .. } => self.location.path(),
}
}
fn render_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let id = self.id;
let path: SharedString = self.display_path(cx).into();
let leading = match &self.source {
EditorSource::Remote { host, .. } => {
let host = self.store.read(cx).host(*host);
HostMark::new(
("editor-host", id.0),
host.map(|host| host.name.clone()).unwrap_or_default(),
host.and_then(|host| host.os),
)
.small()
.into_any_element()
}
EditorSource::Local { .. } => Icon::new(CatalogIcon::Laptop)
.small()
.text_color(cx.theme().muted_foreground)
.into_any_element(),
};
let state = match self.phase {
Phase::Saving => Some(("正在保存…", cx.theme().muted_foreground)),
Phase::Reloading => Some(("正在重新加载…", cx.theme().muted_foreground)),
Phase::Idle if self.dirty => Some(("已修改", cx.theme().warning)),
Phase::Idle => None,
};
let command = |command: EditorCommand| {
let (dispatch, action) = (self.dispatch.clone(), EditorAction::new(id, command));
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
send(&dispatch, &action, window, cx)
}
};
h_flex()
.id(("editor-toolbar", id.0))
.flex_shrink_0()
.gap_2()
.px_2()
.py_1()
.border_b_1()
.border_color(cx.theme().border)
.child(leading)
.child(
div()
.id(("editor-path", id.0))
.test_support()
.aria_label(path.clone())
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis_middle()
.text_sm()
.font_family(cx.theme().mono_font_family.clone())
.text_color(cx.theme().muted_foreground)
.tooltip({
let path = path.clone();
move |window, cx| {
gpui_kit::component::tooltip::Tooltip::new(path.clone())
.build(window, cx)
}
})
.child(path),
)
.when_some(state, |row, (text, color)| {
row.child(
div()
.id(("editor-state", id.0))
.test_support()
.aria_label(text)
.flex_shrink_0()
.child(soft_tag(text, color)),
)
})
.child(
Button::new(("editor-reload", id.0))
.ghost()
.small()
.icon(Icon::new(CatalogIcon::RefreshCw))
.label("重新加载")
.disabled(self.phase != Phase::Idle)
.on_click(command(EditorCommand::Reload)),
)
.child(
Button::new(("editor-save", id.0))
.small()
.icon(Icon::new(CatalogIcon::Save))
.label("保存")
.disabled(!self.dirty || self.phase != Phase::Idle)
.tooltip_with_action(
"保存",
&EditorShortcut(EditorCommand::Save),
Some(EDITOR_CONTEXT),
)
.on_click(command(EditorCommand::Save)),
)
}
}
/// Dispatch an editor command on the workspace, after the current update:
/// from a dialog or a button, where nothing in the editor may hold focus.
fn send(dispatch: &FocusHandle, action: &EditorAction, window: &mut Window, cx: &mut App) {
let (focus, action) = (dispatch.clone(), action.clone());
window.defer(cx, move |window, cx| {
focus.dispatch_action(&action, window, cx)
});
}
impl EventEmitter<PanelEvent> for EditorPanel {}
impl EventEmitter<EditorPanelEvent> for EditorPanel {}
impl Focusable for EditorPanel {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.state.read(cx).focus_handle(cx)
}
}
impl BasePanel for EditorPanel {
fn panel_name(&self) -> &'static str {
"EditorPanel"
}
/// Closing goes through `CloseEditor`, which asks about changes; see
/// `ClosableTabTitle`.
fn closable(&self, _: &App) -> bool {
false
}
fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
if active {
let focus = self.state.read(cx).focus_handle(cx);
window.focus(&focus, cx);
cx.emit(EditorPanelEvent::Activated(self.id));
}
}
fn on_added_to(&mut self, group: WeakEntity<TabGroup>, _: &mut Window, _: &mut Context<Self>) {
self.tab_group = Some(group);
}
fn on_removed(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.tab_group = None;
self._task = None;
cx.emit(EditorPanelEvent::Closed(self.id));
}
}
impl Panel for EditorPanel {
fn title(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (id, group, panel) = (self.id, self.tab_group.clone(), cx.entity_id());
ClosableTabTitle::new(
("editor-tab", id.0),
Icon::new(CatalogIcon::FileText).small(),
self.name(),
)
.modified(self.dirty)
.closable(("close-editor", id.0), Box::new(CloseEditor(id)))
.context_menu(move |menu, _, cx| tab_menu(menu, id, group.clone(), panel, cx))
}
fn dropdown_menu(
&mut self,
menu: PopupMenu,
_: &mut Window,
cx: &mut Context<Self>,
) -> PopupMenu {
tab_menu(menu, self.id, self.tab_group.clone(), cx.entity_id(), cx)
}
fn inner_padding(&self, _: &App) -> bool {
false
}
}
/// The commands of an editor tab, shared by its context menu and the tab
/// bar's 「…」 menu.
fn tab_menu(
menu: PopupMenu,
id: EditorId,
group: Option<WeakEntity<TabGroup>>,
panel: EntityId,
cx: &App,
) -> PopupMenu {
let action = |command| Box::new(EditorAction::new(id, command));
let menu = menu
.menu_with_icon(
"保存",
Icon::new(CatalogIcon::Save),
action(EditorCommand::Save),
)
.menu_with_icon(
"重新加载",
Icon::new(CatalogIcon::RefreshCw),
action(EditorCommand::Reload),
)
.menu("复制路径", action(EditorCommand::CopyPath))
.separator();
close_tab_items(menu, CenterTab::Editor(id), group, panel, cx)
}
impl Render for EditorPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let id = self.id;
v_flex()
.id(("editor", id.0))
.test_support()
.key_context(EDITOR_CONTEXT)
.size_full()
.child(self.render_toolbar(cx))
.child(
div()
.id(("editor-text", id.0))
.test_support()
.flex_1()
.min_h_0()
.child(
Editor::new(&self.state)
.bordered(false)
.rounded_none()
.h_full()
.readonly(self.phase == Phase::Reloading)
.aria_label(format!("编辑 {}", self.name())),
),
)
}
}
+8
View File
@@ -0,0 +1,8 @@
//! The built-in editor: a center tab that edits one text file, a remote one
//! over the SFTP tab that opened it, or a local one.
mod editor_panel;
mod model;
pub use editor_panel::{EditorPanel, EditorPanelEvent, EditorSource};
pub use model::*;
+165
View File
@@ -0,0 +1,165 @@
//! What the editor decides without a window: which highlighter a file gets,
//! how it indents, and what the status line says.
use crate::host::HostId;
use crate::sftp::{RemotePath, TextFormat};
use gpui_kit::component::input::TabSize;
use std::path::PathBuf;
/// Identity of an editor tab; one per file it edits.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct EditorId(pub u64);
/// The file an editor edits. Opening it again shows that editor instead of
/// a second one: a remote file by host and path (whichever SFTP tab of the
/// host opened it), a local one by path.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum EditorKey {
Remote(HostId, RemotePath),
Local(PathBuf),
}
/// The highlighter for a file, by its name: the languages ShellRS is built
/// with, `text` for anything else.
pub fn language_for(file_name: &str) -> &'static str {
let name = file_name.to_ascii_lowercase();
match name.as_str() {
"makefile" | "gnumakefile" => return "make",
".bashrc" | ".bash_profile" | ".bash_aliases" | ".bash_logout" | ".profile" | ".zshrc"
| ".zprofile" | ".envrc" => return "bash",
"pipfile" | "cargo.lock" => return "toml",
_ => {}
}
let Some((_, extension)) = name.rsplit_once('.') else {
return "text";
};
match extension {
"sh" | "bash" | "zsh" | "ksh" | "env" => "bash",
"yml" | "yaml" => "yaml",
"toml" => "toml",
"json" | "jsonc" => "json",
"py" | "pyi" | "pyw" => "python",
"js" | "mjs" | "cjs" | "jsx" => "javascript",
"html" | "htm" => "html",
"css" | "scss" => "css",
"md" | "markdown" => "markdown",
"sql" => "sql",
"php" | "phtml" => "php",
"go" => "go",
"lua" => "lua",
"mk" | "mak" => "make",
"diff" | "patch" => "diff",
_ => "text",
}
}
/// How Tab indents. A Makefile needs real tabs, and so does a file that
/// mostly indents with them; others get spaces, as wide as is usual for
/// their language.
pub fn indentation(language: &str, text: &str) -> TabSize {
let (mut tabs, mut spaces) = (0, 0);
for line in text.lines().take(2000) {
if line.starts_with('\t') {
tabs += 1;
} else if line.starts_with(" ") {
spaces += 1;
}
}
let hard_tabs = language == "make" || tabs > spaces;
let tab_size = match language {
"yaml" | "json" | "html" | "css" | "javascript" | "lua" => 2,
_ => 4,
};
TabSize {
tab_size,
hard_tabs,
}
}
/// 「行 12,列 5」, from the editor's zero-based position.
pub fn cursor_label(line: u32, character: u32) -> String {
format!("行 {},列 {}", line + 1, character + 1)
}
/// 「UTF-8 · LF」, 「UTF-8 BOM · CRLF」.
pub fn format_label(format: TextFormat) -> String {
let encoding = if format.bom() { "UTF-8 BOM" } else { "UTF-8" };
let lines = if format.crlf() { "CRLF" } else { "LF" };
format!("{encoding} · {lines}")
}
#[cfg(test)]
mod tests {
use super::*;
use gpui_kit::component::highlighter::LanguageRegistry;
#[test]
fn a_file_gets_the_highlighter_its_name_says() {
for (name, language) in [
("nginx.conf", "text"),
("docker-compose.yml", "yaml"),
("deploy.SH", "bash"),
(".bashrc", "bash"),
("Makefile", "make"),
("rules.mk", "make"),
("package.json", "json"),
("app.py", "python"),
("README.md", "markdown"),
("init.sql", "sql"),
("fix.patch", "diff"),
("hosts", "text"),
("archive.tar.gz", "text"),
] {
assert_eq!(language_for(name), language, "{name}");
}
}
#[test]
fn every_language_named_is_built_in() {
let registry = LanguageRegistry::singleton();
for name in [
"bash",
"yaml",
"toml",
"json",
"python",
"javascript",
"html",
"css",
"markdown",
"sql",
"php",
"go",
"lua",
"make",
"diff",
] {
assert!(
registry
.language(name)
.is_some_and(|config| config.has_grammar()),
"{name} is not compiled in"
);
}
}
#[test]
fn makefiles_and_tab_indented_files_indent_with_tabs() {
assert!(indentation("make", "all:\n echo").hard_tabs);
assert!(indentation("text", "a {\n\tb;\n\tc;\n}").hard_tabs);
let yaml = indentation("yaml", "a:\n b: 1\n");
assert!(!yaml.hard_tabs);
assert_eq!(yaml.tab_size, 2);
assert_eq!(indentation("python", "").tab_size, 4);
}
#[test]
fn the_status_line_counts_from_one() {
assert_eq!(cursor_label(11, 4), "行 12,列 5");
assert_eq!(format_label(TextFormat::default()), "UTF-8 · LF");
assert_eq!(
format_label(TextFormat::new(true, true)),
"UTF-8 BOM · CRLF"
);
}
}
+43 -8
View File
@@ -1,6 +1,6 @@
use super::{
ExplorerId, FilePane, FilePaneEvent, LoadIntent, PaneSide, QueueId, QueueState, Removal,
TransferJob, TransferQueue, pane_operations::PendingOperation,
ExplorerId, FileLocation, FilePane, FilePaneEvent, LoadIntent, PaneSide, QueueId, QueueState,
Removal, TransferJob, TransferQueue, file_edit::PendingFile, pane_operations::PendingOperation,
};
use crate::app::ExplorerDispatch as _;
use crate::{
@@ -12,7 +12,7 @@ use crate::{
host::{BookmarkSide, ConnectionState, HostId, HostStore},
sftp::{
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider,
SharedSftpTransportProvider, TransferDirection, TransferPhase, TransferQuestion,
SharedSftpTransportProvider, TextFile, TransferDirection, TransferPhase, TransferQuestion,
UploadRequest,
},
shared::{ClosableTabTitle, HostMark, LatencyLabel, RenamableTab, close_tab_items},
@@ -29,7 +29,8 @@ use gpui_kit::component::{
};
use gpui_kit::*;
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
rc::Rc,
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
@@ -43,6 +44,8 @@ pub enum ExplorerPanelEvent {
/// connection changing: a directory could not be read, or can again.
StatusChanged(ExplorerId),
PromptRequested(ExplorerId, HostId, u64, ConnectionPrompt),
/// A file was read for the editor and can be edited.
EditFile(ExplorerId, FileLocation, Rc<TextFile>),
}
pub struct ExplorerPanel {
id: ExplorerId,
@@ -56,6 +59,10 @@ pub struct ExplorerPanel {
/// File operations waiting for their result, by request id.
pub(super) operations: HashMap<u64, PendingOperation>,
pub(super) next_operation: u64,
/// Reads and writes for the editor waiting for the worker, by request
/// id; the files being read for a new editor tab.
pub(super) files: HashMap<u64, PendingFile>,
pub(super) opening: HashSet<FileLocation>,
commands: async_channel::Sender<SftpCommand>,
state: ConnectionState,
/// The connection's latest round trip; none until the first one after
@@ -212,6 +219,8 @@ impl ExplorerPanel {
local_provider,
operations: HashMap::new(),
next_operation: 0,
files: HashMap::new(),
opening: HashSet::new(),
commands,
state: ConnectionState::Connecting,
latency: None,
@@ -280,7 +289,7 @@ impl ExplorerPanel {
/// Tell the user the remote side is disconnected, and why, with 重新连接
/// right there. When the queue owns reconnecting (a stopped batch waits
/// for 继续, or a transfer is reconnecting by itself), say so instead.
fn offer_reconnect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub(super) fn offer_reconnect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if window.has_active_dialog(cx) {
return;
}
@@ -479,6 +488,10 @@ impl ExplorerPanel {
SftpEvent::Operated { request_id, result } => {
self.finish_operation(request_id, result, window, cx)
}
SftpEvent::FileRead { request_id, result } => self.finish_file_read(request_id, result),
SftpEvent::FileWritten { request_id, result } => {
self.finish_file_write(request_id, result)
}
SftpEvent::Progress(progress) => {
let complete = progress.phase() == TransferPhase::Completed;
let direction = progress.direction();
@@ -636,15 +649,36 @@ impl ExplorerPanel {
}
ExplorerCommand::Open { remote } => {
let pane = self.pane(*remote).clone();
if let Some(entry) = pane.read(cx).cursor_entry(cx)
&& entry.is_dir()
{
let Some(entry) = pane.read(cx).cursor_entry(cx) else {
return;
};
if entry.is_dir() {
let path = if entry.is_parent() {
pane.read(cx).parent_path()
} else {
pane.read(cx).child_path_of(&entry.name)
};
self.navigate(*remote, path, LoadIntent::Visit, window, cx);
} else {
// A file opens in the editor, through the workspace, which
// shows the tab when the file is open already.
let path = pane.read(cx).child_path_of(&entry.name);
self.dispatch.dispatch_explorer_action(
&ExplorerAction::new(
self.id,
ExplorerCommand::Edit {
remote: *remote,
path: Some(path),
},
),
window,
cx,
);
}
}
ExplorerCommand::Edit { remote, path } => {
if let Some(location) = self.edit_target(*remote, path.as_deref(), cx) {
self.open_for_edit(location, window, cx);
}
}
ExplorerCommand::OpenDirectory { remote } => {
@@ -925,6 +959,7 @@ fn needs_connection(command: &ExplorerCommand) -> bool {
| C::Back { remote }
| C::Forward { remote }
| C::Open { remote }
| C::Edit { remote, .. }
| C::OpenDirectory { remote }
| C::Delete { remote }
| C::Rename { remote }
+330
View File
@@ -0,0 +1,330 @@
//! Files for the editor: an SFTP tab reads them for it and writes the remote
//! ones back over its own connection, so editing never logs in again.
//!
//! A file is read before any editor tab exists: one that cannot be edited
//! (too large, binary, not UTF-8) is explained here and never flashes a tab.
use super::{ExplorerPanel, ExplorerPanelEvent, format_size};
use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _};
use crate::host::ConnectionState;
use crate::sftp::{
EDIT_LIMIT, FileStamp, ReadFailure, RemotePath, SaveFailure, SftpCommand, TextFile,
read_local_text,
};
use futures::channel::oneshot;
use gpui_kit::component::{WindowExt as _, dialog::DialogButtonProps, notification::Notification};
use gpui_kit::*;
use std::path::PathBuf;
use std::rc::Rc;
/// Where a file to edit lives: on the server of an SFTP tab, or here.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum FileLocation {
Remote(RemotePath),
Local(PathBuf),
}
impl FileLocation {
pub fn is_remote(&self) -> bool {
matches!(self, FileLocation::Remote(_))
}
/// The file's own name.
pub fn name(&self) -> String {
match self {
FileLocation::Remote(path) => path.file_name().to_string(),
FileLocation::Local(path) => path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
}
}
/// The full path, as a pane shows paths.
pub fn path(&self) -> String {
match self {
FileLocation::Remote(path) => path.to_string(),
FileLocation::Local(path) => path.to_string_lossy().into_owned(),
}
}
/// The directory the file is in, as a pane shows paths.
pub fn directory(&self) -> String {
match self {
FileLocation::Remote(path) => path.parent().to_string(),
FileLocation::Local(path) => path
.parent()
.map(|parent| parent.to_string_lossy().into_owned())
.unwrap_or_default(),
}
}
}
/// A read or a write waiting for the worker, answered from `on_event`.
pub(super) enum PendingFile {
Read(oneshot::Sender<Result<TextFile, ReadFailure>>),
Write(oneshot::Sender<Result<FileStamp, SaveFailure>>),
}
impl ExplorerPanel {
/// The file `path` names on one side, or the one under that side's
/// cursor; `None` for a directory or nothing.
pub fn edit_target(&self, remote: bool, path: Option<&str>, cx: &App) -> Option<FileLocation> {
let pane = self.pane(remote).read(cx);
let path = match path {
Some(path) => path.to_string(),
None => {
let entry = pane.cursor_entry(cx)?;
if entry.is_dir() || entry.is_parent() {
return None;
}
pane.child_path_of(&entry.name)
}
};
Some(if remote {
FileLocation::Remote(RemotePath::new(path).ok()?)
} else {
FileLocation::Local(PathBuf::from(path))
})
}
/// Read a file for a new editor tab; `EditFile` brings it to the
/// workspace, a refusal is explained here.
pub(super) fn open_for_edit(
&mut self,
location: FileLocation,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.opening.insert(location.clone()) {
return;
}
let Some(read) = self.read_file(&location, window, cx) else {
self.opening.remove(&location);
return;
};
let remote = location.is_remote();
let name = location.name();
self.pane(remote)
.clone()
.update(cx, |pane, cx| pane.begin_opening(name, cx));
cx.spawn_in(window, async move |this, cx| {
let result = read.await;
let _ = this.update_in(cx, |this, window, cx| {
this.opening.remove(&location);
this.pane(remote)
.clone()
.update(cx, |pane, cx| pane.finish_opening(cx));
match result {
Ok(file) => cx.emit(ExplorerPanelEvent::EditFile(
this.id(),
location,
Rc::new(file),
)),
Err(failure) => this.refuse_edit(&location, failure, window, cx),
}
});
})
.detach();
}
/// Read a whole text file. `None` when the remote side is not connected,
/// which this tab has already told the user about.
pub fn read_file(
&mut self,
location: &FileLocation,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<TextFile, ReadFailure>>> {
match location {
FileLocation::Local(path) => {
let (provider, path) = (self.local_provider.clone(), path.clone());
Some(cx.background_spawn(async move { read_local_text(provider.as_ref(), &path) }))
}
FileLocation::Remote(path) => {
if !self.reachable(window, cx) {
return None;
}
let (sender, receiver) = oneshot::channel();
let request_id = self.next_file_request(PendingFile::Read(sender));
self.send(SftpCommand::ReadFile {
request_id,
path: path.clone(),
});
Some(cx.spawn(async move |_, _| {
receiver
.await
.unwrap_or_else(|_| Err(ReadFailure::Failed("SFTP 连接已断开".into())))
}))
}
}
}
/// Write bytes over a remote file in place. `None` when not connected,
/// which this tab has already told the user about.
pub fn write_remote_file(
&mut self,
path: RemotePath,
bytes: Vec<u8>,
expected: Option<FileStamp>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<FileStamp, SaveFailure>>> {
if !self.reachable(window, cx) {
return None;
}
let (sender, receiver) = oneshot::channel();
let request_id = self.next_file_request(PendingFile::Write(sender));
self.send(SftpCommand::WriteFile {
request_id,
path,
bytes,
expected,
});
Some(cx.spawn(async move |_, _| {
// The connection dropped before the answer: the write may have
// stopped halfway.
receiver
.await
.unwrap_or_else(|_| Err(SaveFailure::Interrupted("SFTP 连接已断开".into())))
}))
}
/// Re-read a pane that shows the directory `location` is in, so its
/// size and time follow a save.
pub fn reload_if_showing(
&mut self,
location: &FileLocation,
window: &mut Window,
cx: &mut Context<Self>,
) {
let remote = location.is_remote();
if remote && self.connection_state() != ConnectionState::Connected {
return;
}
if self.pane(remote).read(cx).path() == location.directory() {
self.reload(remote, window, cx);
}
}
pub(super) fn finish_file_read(&mut self, id: u64, result: Result<TextFile, ReadFailure>) {
if let Some(PendingFile::Read(sender)) = self.files.remove(&id) {
let _ = sender.send(result);
}
}
pub(super) fn finish_file_write(&mut self, id: u64, result: Result<FileStamp, SaveFailure>) {
if let Some(PendingFile::Write(sender)) = self.files.remove(&id) {
let _ = sender.send(result);
}
}
/// The remote side is connected; otherwise say why and offer to
/// reconnect.
fn reachable(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
match self.connection_state() {
ConnectionState::Connected => true,
ConnectionState::Disconnected => {
self.offer_reconnect(window, cx);
false
}
ConnectionState::Connecting => {
window.push_notification(Notification::warning("正在连接 SFTP,请稍候再试。"), cx);
false
}
}
}
fn next_file_request(&mut self, pending: PendingFile) -> u64 {
self.next_operation += 1;
self.files.insert(self.next_operation, pending);
self.next_operation
}
/// Why a file was not opened. Too large, binary or not UTF-8 is a
/// decision to make (a remote file can still be downloaded); anything
/// else is an error.
fn refuse_edit(
&mut self,
location: &FileLocation,
failure: ReadFailure,
window: &mut Window,
cx: &mut Context<Self>,
) {
let name = location.name();
let reason = match &failure {
ReadFailure::TooLarge(size) => format!(
"文件有 {},编辑器只打开 {} 以内的文件。",
format_size(*size),
format_size(EDIT_LIMIT)
),
ReadFailure::NotText => {
"它不是 UTF-8 编码的文本文件:可能是二进制文件,或用了 GBK 等其他编码。".into()
}
ReadFailure::NotFile => "它不是普通文件。".into(),
ReadFailure::Failed(message) => {
window.push_notification(
Notification::error(message.clone()).title(format!("无法打开“{name}”")),
cx,
);
return;
}
};
let download = match location {
FileLocation::Remote(path) => Some(ExplorerCommand::DownloadPaths {
paths: vec![path.to_string()],
target: self.local().read(cx).path(),
}),
FileLocation::Local(_) => None,
};
let description = if download.is_some() {
format!("{reason}可以下载到本机,用别的程序打开。")
} else {
reason
};
let (dispatch, id) = (self.dispatch.clone(), self.id());
window.open_alert_dialog(cx, move |alert, _, _| {
let alert = alert
.title(format!("无法在编辑器中打开“{name}”"))
.description(description.clone());
match download.clone() {
Some(download) => alert
.button_props(
DialogButtonProps::default()
.ok_text("下载…")
.cancel_text("取消"),
)
.show_cancel(true)
.on_ok({
let dispatch = dispatch.clone();
move |_, window, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(id, download.clone()),
window,
cx,
);
true
}
}),
None => alert.button_props(DialogButtonProps::default().ok_text("知道了")),
}
});
}
}
#[cfg(test)]
mod tests {
use super::FileLocation;
use crate::sftp::RemotePath;
use std::path::PathBuf;
#[test]
fn a_location_names_its_file_and_directory_as_a_pane_does() {
let remote = FileLocation::Remote(RemotePath::new("/etc/nginx/nginx.conf").unwrap());
assert_eq!(remote.name(), "nginx.conf");
assert_eq!(remote.directory(), "/etc/nginx");
let top = FileLocation::Remote(RemotePath::new("/hosts").unwrap());
assert_eq!(top.directory(), "/");
let local = FileLocation::Local(PathBuf::from("/Users/me/notes.md"));
assert_eq!(local.name(), "notes.md");
assert_eq!(local.directory(), "/Users/me");
assert_eq!(local.path(), "/Users/me/notes.md");
}
}
+32
View File
@@ -195,6 +195,10 @@ pub struct FilePane {
/// The load in flight has taken long enough to say so.
slow_load: bool,
slow_load_timer: Option<Task<()>>,
/// A file being read for the editor, once that has taken long enough to
/// say so; the timer that decides.
opening: Option<String>,
opening_timer: Option<Task<()>>,
/// What went wrong reading this pane's directory, or why the remote
/// side dropped: the window's status line shows it, see `problem`.
error: Option<String>,
@@ -278,6 +282,8 @@ impl FilePane {
loading: false,
slow_load: false,
slow_load_timer: None,
opening: None,
opening_timer: None,
error: None,
listed: false,
connection: if side == PaneSide::Local {
@@ -652,6 +658,8 @@ impl FilePane {
fn render_status(&self, file_count: usize, selected: usize, cx: &App) -> impl IntoElement {
let status = if self.connection == ConnectionState::Connecting {
"正在连接 SFTP…".to_string()
} else if let Some(name) = &self.opening {
format!("正在打开 {name}…")
} else if self.slow_load {
"正在读取目录…".to_string()
} else if selected == 0 {
@@ -781,6 +789,24 @@ impl FilePane {
cx.notify();
self.request_id
}
/// A file of this pane is being read for the editor: the status line
/// says so if it takes a while, like a slow directory.
pub fn begin_opening(&mut self, name: String, cx: &mut Context<Self>) {
self.opening = None;
self.opening_timer = Some(cx.spawn(async move |this, cx| {
cx.background_executor().timer(SLOW_LOAD).await;
let _ = this.update(cx, |this, cx| {
this.opening = Some(name);
cx.notify();
});
}));
}
pub fn finish_opening(&mut self, cx: &mut Context<Self>) {
self.opening_timer = None;
if self.opening.take().is_some() {
cx.notify();
}
}
fn finish_loading(&mut self) {
self.loading = false;
self.slow_load = false;
@@ -905,6 +931,12 @@ impl FilePane {
remote: self.is_remote(),
explorer: self.explorer,
opens_directory: entries.len() == 1 && entries[0].is_dir(),
edits_file: match entries.as_slice() {
[entry] if !entry.is_dir() && !entry.is_parent() => {
Some(self.child_path_of(&entry.name))
}
_ => None,
},
targets,
can_go_up: self.parent_path() != self.path,
can_go_home: !self.home.is_empty() && self.home != self.path,
+2
View File
@@ -2,6 +2,7 @@
mod explorer_panel;
mod file_dialogs;
mod file_edit;
mod file_listing;
mod file_pane;
mod history;
@@ -16,6 +17,7 @@ mod selection;
mod transfer_queue;
pub use explorer_panel::{ExplorerPanel, ExplorerPanelEvent, ExplorerStatus};
pub use file_edit::FileLocation;
pub use file_listing::FileListing;
pub use file_pane::{FilePane, FilePaneEvent, PaneSide};
pub use history::{LoadIntent, NavigationHistory};
+13
View File
@@ -16,6 +16,9 @@ pub(super) struct PaneMenuState {
pub targets: Vec<String>,
/// The single selected row is a directory (or a link to one).
pub opens_directory: bool,
/// The single selected row is a file the editor can try to open: its
/// full path.
pub edits_file: Option<String>,
pub can_go_up: bool,
pub can_go_home: bool,
pub can_go_back: bool,
@@ -45,6 +48,16 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
state.action(ExplorerCommand::Open { remote }),
)
.separator()
} else if let Some(path) = state.edits_file.clone() {
menu.menu_with_icon(
"编辑",
Icon::new(CatalogIcon::FilePenLine),
state.action(ExplorerCommand::Edit {
remote,
path: Some(path),
}),
)
.separator()
} else {
menu
};
+33 -3
View File
@@ -3,6 +3,7 @@
//! stays busy until the result arrives, then re-reads its directory.
use super::{ExplorerPanel, NewEntryKind};
use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _};
use crate::host::ConnectionState;
use crate::sftp::{
LocalDirectoryProvider, PermissionEdit, RemoteOperation, RemotePath, SftpCommand,
@@ -38,6 +39,8 @@ pub(super) struct PendingOperation {
remote: bool,
failure: &'static str,
select: Option<String>,
/// A new file, to open in the editor once it exists (WinSCP does).
edit: Option<String>,
}
impl PaneOperation {
@@ -148,12 +151,20 @@ impl ExplorerPanel {
let directory = pane.read(cx).path();
self.next_operation += 1;
let id = self.next_operation;
let edit = match &operation {
PaneOperation::Create {
kind: NewEntryKind::File,
name,
} => Some(pane.read(cx).child_path_of(name)),
_ => None,
};
self.operations.insert(
id,
PendingOperation {
remote,
failure: operation.failure_title(),
select: operation.result_name(),
edit,
},
);
pane.update(cx, |pane, cx| pane.set_busy(true, cx));
@@ -200,16 +211,35 @@ impl ExplorerPanel {
pane.select_after_load(name);
}
});
if let Err(message) = result {
window.push_notification(Notification::error(message).title(pending.failure), cx);
match result {
Err(message) => {
window.push_notification(Notification::error(message).title(pending.failure), cx)
}
Ok(()) => {
if let Some(path) = pending.edit {
self.dispatch.dispatch_explorer_action(
&ExplorerAction::new(
self.id(),
ExplorerCommand::Edit {
remote: pending.remote,
path: Some(path),
},
),
window,
cx,
);
}
}
}
// Re-read even after a failure: a delete may have removed part of a tree.
self.reload(pending.remote, window, cx);
}
/// The connection dropped: remote operations will not answer.
/// The connection dropped: remote operations will not answer, nor will
/// the editor's reads and writes (all of them are remote).
pub(super) fn abandon_remote_operations(&mut self, cx: &mut Context<Self>) {
self.operations.retain(|_, pending| !pending.remote);
self.files.clear();
self.remote.update(cx, |pane, cx| pane.set_busy(false, cx));
}
}
+8 -3
View File
@@ -549,22 +549,27 @@ impl Render for ConflictForm {
}
/// Ask before closing an SFTP tab with transfers under way. Confirming goes
/// through the workspace like the tab's other commands.
/// `unsaved` says which files of the tab have changes that closing it
/// would lose, so there is one question, not two.
pub fn confirm_close_transfer(
explorer: ExplorerId,
generation: u64,
direction: TransferDirection,
unsaved: Option<String>,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut App,
) {
let focus = window.focused(cx);
let verb = direction.verb();
let description = format!(
"{verb}进度会保留,下次选择相同来源和目标目录时可以继续{verb}。{}",
unsaved.unwrap_or_default()
);
window.open_alert_dialog(cx, move |dialog, _, _| {
dialog
.title(format!("停止{verb}并关闭?"))
.description(format!(
"{verb}进度会保留,下次选择相同来源和目标目录时可以继续{verb}。"
))
.description(description.clone())
.button_props(
gpui_kit::component::dialog::DialogButtonProps::default()
.ok_text(format!("停止{verb}并关闭"))
+2 -1
View File
@@ -4,7 +4,7 @@
//! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
//! (凭据), `docker` (Docker), `monitor` (系统监控), `services` (系统服务), `processes` (进程管理),
//! `netstat` (网络连接), `history` (历史命令), `snippets` (命令片段),
//! `netstat` (网络连接), `history` (历史命令), `snippets` (命令片段), `editor` (内置编辑器),
//! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与
//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源).
@@ -13,6 +13,7 @@ pub mod cli;
pub mod connection;
pub mod credential;
pub mod docker;
pub mod editor;
pub mod explorer;
pub mod forward;
pub mod history;
+14
View File
@@ -26,6 +26,9 @@ pub(crate) trait RemoteFs {
/// The entries of a directory without `.` and `..`, kinds as `lstat` reports them.
async fn read_dir(&self, path: &RemotePath) -> Result<Vec<DirectoryEntry>>;
async fn open(&self, path: &RemotePath, create: bool) -> Result<String>;
/// Open a file to write it over in place: truncated, or created when it
/// is missing. Follows links, so the file a link points at is written.
async fn open_replace(&self, path: &RemotePath) -> Result<String>;
/// Open an existing file for reading.
async fn open_read(&self, path: &RemotePath) -> Result<String>;
/// Up to `len` bytes at `offset`; may return fewer. `None` at end of file.
@@ -252,6 +255,17 @@ impl RemoteFs for SftpClient {
.await?
.handle)
}
async fn open_replace(&self, path: &RemotePath) -> Result<String> {
Ok(self
.raw
.open(
path.as_str(),
OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE,
FileAttributes::empty(),
)
.await?
.handle)
}
async fn open_read(&self, path: &RemotePath) -> Result<String> {
Ok(self
.raw
+435
View File
@@ -0,0 +1,435 @@
//! Whole text files for the editor: read into memory, written back in place.
//!
//! Unlike transfers there is no `.filepart` and no rename: a save truncates
//! the file and writes it again, as vim, nano and VS Code Remote do, so the
//! owner, the permissions, links pointing at it and hard links all stay.
//! What keeps a save from clobbering someone else's change is the stamp: the
//! size and modification time the file had when it was read or last saved.
use super::{EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath, client::RemoteFs};
use anyhow::{Result, anyhow, bail};
use futures::{StreamExt as _, stream::FuturesUnordered};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// The largest file the editor opens. The code editor is meant for up to
/// about fifty thousand lines.
pub const EDIT_LIMIT: u64 = 5 * 1024 * 1024;
const CHUNK: u32 = 32 * 1024;
const MAX_IN_FLIGHT: usize = 16;
/// A NUL byte this early means a binary file, as `grep` and git decide.
const BINARY_PROBE: usize = 8 * 1024;
const BOM: &[u8] = b"\xEF\xBB\xBF";
/// What a file looked like when it was read or saved: a save checks it is
/// still so, otherwise someone else changed the file in the meantime.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FileStamp {
size: u64,
modified: Option<SystemTime>,
}
impl FileStamp {
pub fn new(size: u64, modified: Option<SystemTime>) -> Self {
Self { size, modified }
}
/// A remote file's stamp; SFTP gives whole seconds.
pub fn of(metadata: &FileMetadata) -> Self {
Self::new(
metadata.size(),
metadata
.modified()
.map(|seconds| UNIX_EPOCH + Duration::from_secs(u64::from(seconds))),
)
}
/// A local file's stamp, to the precision the file system keeps.
pub fn local(metadata: &std::fs::Metadata) -> Self {
Self::new(metadata.len(), metadata.modified().ok())
}
pub fn size(&self) -> u64 {
self.size
}
}
/// How a text file is laid out on disk, kept so that a save writes it back
/// the same way.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TextFormat {
bom: bool,
crlf: bool,
}
impl TextFormat {
pub fn new(bom: bool, crlf: bool) -> Self {
Self { bom, crlf }
}
/// The file starts with a UTF-8 byte order mark.
pub fn bom(&self) -> bool {
self.bom
}
/// Every line ends in CRLF; the editor sees LF.
pub fn crlf(&self) -> bool {
self.crlf
}
/// The bytes to write for `text` as the editor holds it.
pub fn encode(&self, text: &str) -> Vec<u8> {
let mut bytes = Vec::with_capacity(text.len() + 3);
if self.bom {
bytes.extend_from_slice(BOM);
}
if self.crlf {
// A CRLF pasted in stays one line break.
bytes.extend_from_slice(text.replace("\r\n", "\n").replace('\n', "\r\n").as_bytes());
} else {
bytes.extend_from_slice(text.as_bytes());
}
bytes
}
}
/// A file read for editing.
#[derive(Clone, PartialEq, Eq)]
pub struct TextFile {
text: String,
format: TextFormat,
stamp: FileStamp,
}
impl TextFile {
/// UTF-8 text, with or without a byte order mark. A file whose lines all
/// end in CRLF is shown with LF and written back with CRLF; one with
/// mixed line ends is kept exactly as it is.
pub fn decode(mut bytes: Vec<u8>, stamp: FileStamp) -> Result<Self, ReadFailure> {
if bytes[..bytes.len().min(BINARY_PROBE)].contains(&0) {
return Err(ReadFailure::NotText);
}
let bom = bytes.starts_with(BOM);
if bom {
bytes.drain(..BOM.len());
}
let text = String::from_utf8(bytes).map_err(|_| ReadFailure::NotText)?;
let crlf_count = text.matches("\r\n").count();
let crlf = crlf_count > 0 && crlf_count == text.matches('\n').count();
let text = if crlf {
text.replace("\r\n", "\n")
} else {
text
};
Ok(Self {
text,
format: TextFormat { bom, crlf },
stamp,
})
}
pub fn text(&self) -> &str {
&self.text
}
pub fn format(&self) -> TextFormat {
self.format
}
pub fn stamp(&self) -> FileStamp {
self.stamp
}
pub fn into_parts(self) -> (String, TextFormat, FileStamp) {
(self.text, self.format, self.stamp)
}
}
impl std::fmt::Debug for TextFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextFile")
.field("bytes", &self.text.len())
.field("format", &self.format)
.field("stamp", &self.stamp)
.finish()
}
}
/// Why a file was not opened for editing.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReadFailure {
/// A directory, a device or the like.
NotFile,
/// Larger than `EDIT_LIMIT`; the size in bytes.
TooLarge(u64),
/// Binary, or text in an encoding other than UTF-8.
NotText,
Failed(String),
}
impl std::fmt::Display for ReadFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReadFailure::NotFile => f.write_str("不是普通文件"),
ReadFailure::TooLarge(size) => write!(f, "文件太大({size} 字节)"),
ReadFailure::NotText => f.write_str("不是 UTF-8 文本文件"),
ReadFailure::Failed(message) => f.write_str(message),
}
}
}
impl std::error::Error for ReadFailure {}
impl ReadFailure {
/// The refusal inside `error`, or the error itself as a failure.
pub(crate) fn from_error(error: anyhow::Error) -> Self {
match error.downcast::<ReadFailure>() {
Ok(refusal) => refusal,
Err(error) => ReadFailure::Failed(format!("{error:#}")),
}
}
}
/// Why a save did not happen, or did not finish.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SaveFailure {
/// The file is no longer what was read or last saved: changed, or gone.
/// Nothing was written.
Changed,
/// The file was truncated and then the write failed: what is on disk
/// now is incomplete, and no longer what was read.
Interrupted(String),
/// Nothing was written.
Failed(String),
}
impl std::fmt::Display for SaveFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SaveFailure::Changed => f.write_str("文件在打开后已被修改"),
SaveFailure::Interrupted(message) | SaveFailure::Failed(message) => {
f.write_str(message)
}
}
}
}
impl std::error::Error for SaveFailure {}
impl SaveFailure {
pub(crate) fn from_error(error: anyhow::Error) -> Self {
match error.downcast::<SaveFailure>() {
Ok(refusal) => refusal,
Err(error) => SaveFailure::Failed(format!("{error:#}")),
}
}
}
/// Read a remote text file, following links. Refusals come back as a
/// `ReadFailure` inside the error, so that the worker can still tell a
/// broken connection from the rest.
pub(crate) async fn read_text<F: RemoteFs>(fs: &F, path: &RemotePath) -> Result<TextFile> {
let Some(metadata) = fs.stat(path).await? else {
bail!("文件不存在");
};
if metadata.kind() != EntryKind::File {
return Err(ReadFailure::NotFile.into());
}
if metadata.size() > EDIT_LIMIT {
return Err(ReadFailure::TooLarge(metadata.size()).into());
}
let handle = fs.open_read(path).await?;
let bytes = read_all(fs, &handle, metadata.size()).await;
let close = fs.close(&handle).await;
let bytes = bytes?;
close?;
Ok(TextFile::decode(bytes, FileStamp::of(&metadata))?)
}
/// Read a local text file through `provider`. Blocking, like the provider.
pub fn read_local_text(
provider: &dyn LocalDirectoryProvider,
path: &std::path::Path,
) -> Result<TextFile, ReadFailure> {
let (bytes, stamp) = provider
.read_file(path, EDIT_LIMIT)
.map_err(ReadFailure::from_error)?;
TextFile::decode(bytes, stamp)
}
/// Write the editor's bytes over a local file through `provider`. Blocking.
pub fn write_local_text(
provider: &dyn LocalDirectoryProvider,
path: &std::path::Path,
bytes: &[u8],
expected: Option<FileStamp>,
) -> Result<FileStamp, SaveFailure> {
provider
.write_file(path, bytes, expected)
.map_err(SaveFailure::from_error)
}
/// `size` bytes from the start of an open file, reading ahead in parallel.
/// Reads may come back out of order or short; each lands at its offset.
async fn read_all<F: RemoteFs>(fs: &F, handle: &str, size: u64) -> Result<Vec<u8>> {
let mut buffer = vec![0; usize::try_from(size)?];
let mut next = 0;
let mut remainders: Vec<(u64, u32)> = Vec::new();
let mut reads = FuturesUnordered::new();
loop {
while reads.len() < MAX_IN_FLIGHT && (!remainders.is_empty() || next < size) {
let (at, len) = remainders.pop().unwrap_or_else(|| {
let len = (size - next).min(u64::from(CHUNK)) as u32;
let request = (next, len);
next += u64::from(len);
request
});
reads.push(async move { (at, len, fs.read(handle, at, len).await) });
}
let Some((at, len, bytes)) = reads.next().await else {
return Ok(buffer);
};
let bytes = match bytes? {
Some(bytes) if !bytes.is_empty() => bytes,
_ => bail!("文件在读取时变短了,请重新打开"),
};
let got = bytes.len().min(len as usize);
let start = at as usize;
buffer[start..start + got].copy_from_slice(&bytes[..got]);
// Give up on a binary file before reading all of it.
if at == 0 && bytes[..got.min(BINARY_PROBE)].contains(&0) {
return Err(ReadFailure::NotText.into());
}
if got < len as usize {
remainders.push((at + got as u64, len - got as u32));
}
}
}
/// Write `bytes` over a remote file in place, following links and creating
/// it when it is gone. With `expected`, the file must still match it, or
/// the save fails with `SaveFailure::Changed` and nothing is written.
pub(crate) async fn write_in_place<F: RemoteFs>(
fs: &F,
path: &RemotePath,
bytes: Vec<u8>,
expected: Option<FileStamp>,
) -> Result<FileStamp> {
if let Some(expected) = expected {
let now = fs.stat(path).await?;
if now.as_ref().map(FileStamp::of) != Some(expected) {
return Err(SaveFailure::Changed.into());
}
}
let handle = fs.open_replace(path).await?;
// From here on the file is truncated: a failure leaves it incomplete.
// The context keeps the cause, so a dropped connection is still told
// apart.
let interrupted = |error: anyhow::Error| {
let message = format!("{error:#}");
error.context(SaveFailure::Interrupted(message))
};
let written = match write_all(fs, &handle, bytes).await {
Ok(()) => fs.sync(&handle).await,
Err(error) => Err(error),
};
let close = fs.close(&handle).await;
written.map_err(interrupted)?;
close.map_err(interrupted)?;
let metadata = fs
.stat(path)
.await?
.ok_or_else(|| anyhow!("保存后找不到文件"))?;
Ok(FileStamp::of(&metadata))
}
async fn write_all<F: RemoteFs>(fs: &F, handle: &str, bytes: Vec<u8>) -> Result<()> {
let mut chunks = bytes
.chunks(CHUNK as usize)
.enumerate()
.map(|(index, chunk)| (index as u64 * u64::from(CHUNK), chunk.to_vec()));
let mut writes = FuturesUnordered::new();
loop {
while writes.len() < MAX_IN_FLIGHT {
let Some((offset, chunk)) = chunks.next() else {
break;
};
writes.push(fs.write(handle, offset, chunk));
}
match writes.next().await {
Some(result) => result?,
None => return Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stamp() -> FileStamp {
FileStamp::new(0, None)
}
#[test]
fn plain_utf8_is_kept_as_it_is() {
let file = TextFile::decode("a\nb\n中文".into(), stamp()).unwrap();
assert_eq!(file.text(), "a\nb\n中文");
assert_eq!(file.format(), TextFormat::default());
assert_eq!(file.format().encode(file.text()), "a\nb\n中文".as_bytes());
}
#[test]
fn crlf_lines_are_edited_as_lf_and_written_back_as_crlf() {
let file = TextFile::decode(b"a\r\nb\r\n".to_vec(), stamp()).unwrap();
assert_eq!(file.text(), "a\nb\n");
assert!(file.format().crlf());
assert_eq!(file.format().encode("a\nb\nc\n"), b"a\r\nb\r\nc\r\n");
// A CRLF pasted in does not become CR CR LF.
assert_eq!(file.format().encode("a\r\nb"), b"a\r\nb");
}
#[test]
fn mixed_line_ends_are_left_alone() {
let file = TextFile::decode(b"a\r\nb\nc".to_vec(), stamp()).unwrap();
assert_eq!(file.text(), "a\r\nb\nc");
assert!(!file.format().crlf());
assert_eq!(file.format().encode(file.text()), b"a\r\nb\nc");
}
#[test]
fn a_byte_order_mark_is_hidden_and_written_back() {
let file = TextFile::decode(b"\xEF\xBB\xBFkey=1\r\n".to_vec(), stamp()).unwrap();
assert_eq!(file.text(), "key=1\n");
assert_eq!(file.format(), TextFormat::new(true, true));
assert_eq!(file.format().encode("key=2\n"), b"\xEF\xBB\xBFkey=2\r\n");
}
#[test]
fn binary_and_other_encodings_are_not_text() {
assert_eq!(
TextFile::decode(b"PK\x03\x04\x00\x00".to_vec(), stamp()),
Err(ReadFailure::NotText)
);
// 「中文」 in GBK.
assert_eq!(
TextFile::decode(b"\xD6\xD0\xCE\xC4".to_vec(), stamp()),
Err(ReadFailure::NotText)
);
assert_eq!(TextFile::decode(Vec::new(), stamp()).unwrap().text(), "");
}
#[test]
fn refusals_survive_the_trip_through_anyhow() {
let error: anyhow::Error = ReadFailure::TooLarge(9).into();
assert_eq!(ReadFailure::from_error(error), ReadFailure::TooLarge(9));
assert_eq!(
ReadFailure::from_error(anyhow!("连接已断开")),
ReadFailure::Failed("连接已断开".into())
);
let error: anyhow::Error = SaveFailure::Changed.into();
assert_eq!(SaveFailure::from_error(error), SaveFailure::Changed);
// Interrupted rides as context over the cause, which stays findable.
let cause = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
let error = anyhow::Error::from(cause).context(SaveFailure::Interrupted("reset".into()));
assert!(
error
.chain()
.any(|c| c.downcast_ref::<std::io::Error>().is_some())
);
assert_eq!(
SaveFailure::from_error(error),
SaveFailure::Interrupted("reset".into())
);
}
}
+2
View File
@@ -1,6 +1,8 @@
//! Real file browsing and resumable SFTP uploads. No GPUI or workspace dependencies.
mod edit;
mod model;
mod transport;
pub use edit::*;
pub use model::*;
pub use transport::*;
mod client;
+51
View File
@@ -1,3 +1,4 @@
use super::{FileStamp, ReadFailure, SaveFailure};
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use std::{
@@ -674,6 +675,19 @@ pub trait LocalDirectoryProvider: Send + Sync + 'static {
recursive: bool,
add_x_to_dirs: bool,
) -> Result<()>;
/// The bytes of a file to edit, following links, and its stamp. A
/// directory or a file larger than `limit` fails with a `ReadFailure`
/// before anything is read.
fn read_file(&self, path: &Path, limit: u64) -> Result<(Vec<u8>, FileStamp)>;
/// Write `bytes` over a file in place, following links and keeping its
/// permissions; created when missing. With `expected`, the file must
/// still match it, or this fails with `SaveFailure::Changed`.
fn write_file(
&self,
path: &Path,
bytes: &[u8],
expected: Option<FileStamp>,
) -> Result<FileStamp>;
}
pub type SharedLocalDirectoryProvider = std::sync::Arc<dyn LocalDirectoryProvider>;
#[derive(Default)]
@@ -792,6 +806,43 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
fn set_permissions(&self, _: &[PathBuf], _: PermissionEdit, _: bool, _: bool) -> Result<()> {
bail!("此系统不支持修改权限")
}
fn read_file(&self, path: &Path, limit: u64) -> Result<(Vec<u8>, FileStamp)> {
let metadata = std::fs::metadata(path).map_err(|error| describe_io(error, path))?;
if !metadata.is_file() {
return Err(ReadFailure::NotFile.into());
}
if metadata.len() > limit {
return Err(ReadFailure::TooLarge(metadata.len()).into());
}
let bytes = std::fs::read(path).map_err(|error| describe_io(error, path))?;
Ok((bytes, FileStamp::local(&metadata)))
}
fn write_file(
&self,
path: &Path,
bytes: &[u8],
expected: Option<FileStamp>,
) -> Result<FileStamp> {
use std::io::Write as _;
if let Some(expected) = expected {
let now = std::fs::metadata(path).ok().map(|m| FileStamp::local(&m));
if now != Some(expected) {
return Err(SaveFailure::Changed.into());
}
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
.map_err(|error| describe_io(error, path))?;
file.write_all(bytes)
.and_then(|()| file.sync_all())
.map_err(|error| describe_io(error, path))?;
drop(file);
let metadata = std::fs::metadata(path).map_err(|error| describe_io(error, path))?;
Ok(FileStamp::local(&metadata))
}
}
fn display_name(path: &Path) -> String {
+313 -2
View File
@@ -2,6 +2,7 @@ use super::{
client::RemoteFs,
control::TransferControl,
download::DownloadBatch,
edit::{read_text, write_in_place},
journal::{DownloadJournal, Journal, SourceMetadata, partial_path},
upload::UploadBatch,
*,
@@ -150,9 +151,31 @@ impl RemoteFs for Remote {
}
Ok(path.to_string())
}
async fn open_replace(&self, path: &RemotePath) -> Result<String> {
let target = self.resolve(path.as_str())?;
let mut nodes = self.nodes.borrow_mut();
match nodes.get_mut(&target) {
Some(node) if node.metadata.kind() == EntryKind::File => {
node.data.clear();
node.metadata = FileMetadata::new(
EntryKind::File,
0,
node.metadata.modified(),
node.metadata.permissions(),
);
}
Some(_) => bail!("not a file"),
None => {
drop(nodes);
self.file(&target, &[]);
}
}
Ok(target)
}
async fn open_read(&self, path: &RemotePath) -> Result<String> {
match self.nodes.borrow().get(path.as_str()) {
Some(node) if node.metadata.kind() == EntryKind::File => Ok(path.to_string()),
let target = self.resolve(path.as_str())?;
match self.nodes.borrow().get(&target) {
Some(node) if node.metadata.kind() == EntryKind::File => Ok(target.clone()),
_ => bail!("no such file"),
}
}
@@ -313,6 +336,27 @@ impl RemoteFs for Remote {
}
}
impl Remote {
/// The path a link chain ends at, as a server's `open` follows it.
fn resolve(&self, path: &str) -> Result<String> {
let nodes = self.nodes.borrow();
let mut current = path.to_string();
for _ in 0..8 {
match nodes.get(&current) {
Some(node) if node.metadata.kind() == EntryKind::Symlink => {
current = if node.link.starts_with('/') {
node.link.clone()
} else {
RemotePath::new(current.as_str())?
.parent()
.join(&node.link)?
.to_string()
};
}
_ => return Ok(current),
}
}
bail!("too many levels of symbolic links")
}
fn dir(&self, path: &str, mode: u32) {
self.nodes.borrow_mut().insert(
path.into(),
@@ -1808,3 +1852,270 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
assert_eq!(mode & 0o111, 0);
});
}
#[test]
fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
runtime().block_on(async {
let remote = Remote::new(true);
let text: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
remote.file("/etc/app.conf", text.as_bytes());
remote.short_reads.set(true);
let file = read_text(&remote, &remote_path("/etc/app.conf"))
.await
.unwrap();
assert_eq!(file.text(), text);
assert_eq!(
file.stamp(),
FileStamp::of(&remote.nodes.borrow()["/etc/app.conf"].metadata)
);
assert!(remote.reads.get() > text.len() / 1000);
});
}
#[test]
fn a_link_is_followed_to_the_file_it_points_at() {
runtime().block_on(async {
let remote = Remote::new(true);
remote.file("/srv/real.conf", b"old\n");
remote.link("/etc/app.conf", "/srv/real.conf");
let path = remote_path("/etc/app.conf");
let file = read_text(&remote, &path).await.unwrap();
assert_eq!(file.text(), "old\n");
write_in_place(&remote, &path, b"new\n".to_vec(), Some(file.stamp()))
.await
.unwrap();
// The link is still a link; the file behind it changed.
assert_eq!(
remote.nodes.borrow()["/etc/app.conf"].metadata.kind(),
EntryKind::Symlink
);
assert_eq!(remote.bytes("/srv/real.conf"), b"new\n");
});
}
#[test]
fn directories_large_files_and_binary_files_are_refused() {
runtime().block_on(async {
let remote = Remote::new(true);
remote.dir("/etc", 0o755);
remote.file("/var/big.log", &vec![b'x'; EDIT_LIMIT as usize + 1]);
let mut binary = vec![b'a'; 1_000_000];
binary[10] = 0;
remote.file("/bin/tool", &binary);
let refusal = |path: &'static str| {
let remote = &remote;
async move {
ReadFailure::from_error(read_text(remote, &remote_path(path)).await.unwrap_err())
}
};
assert_eq!(refusal("/etc").await, ReadFailure::NotFile);
assert_eq!(
refusal("/var/big.log").await,
ReadFailure::TooLarge(EDIT_LIMIT + 1)
);
let reads = remote.reads.get();
assert_eq!(refusal("/bin/tool").await, ReadFailure::NotText);
// Given up after the first chunk, not after reading it all.
assert!(remote.reads.get() - reads < 31);
assert!(matches!(refusal("/missing").await, ReadFailure::Failed(_)));
});
}
#[test]
fn a_save_writes_in_place_and_keeps_the_permissions() {
runtime().block_on(async {
let remote = Remote::new(true);
let long: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8 + 1).collect();
remote.file("/etc/app.conf", &long);
let path = remote_path("/etc/app.conf");
let file_stamp = FileStamp::of(&remote.nodes.borrow()["/etc/app.conf"].metadata);
let shorter = b"short".to_vec();
let stamp = write_in_place(&remote, &path, shorter.clone(), Some(file_stamp))
.await
.unwrap();
// Truncated, not overwritten at the start.
assert_eq!(remote.bytes("/etc/app.conf"), shorter);
assert_eq!(stamp.size(), 5);
let metadata = remote.nodes.borrow()["/etc/app.conf"].metadata.clone();
assert_eq!(metadata.permissions(), Some(0o640));
assert_eq!(remote.renames.get(), 0);
// A long write goes in parallel chunks.
write_in_place(&remote, &path, long.clone(), None)
.await
.unwrap();
assert_eq!(remote.bytes("/etc/app.conf"), long);
assert!(remote.max_in_flight.get() > 1);
});
}
#[test]
fn a_save_over_a_file_changed_or_removed_since_is_refused() {
runtime().block_on(async {
let remote = Remote::new(true);
remote.file("/etc/app.conf", b"mine\n");
let path = remote_path("/etc/app.conf");
let file = read_text(&remote, &path).await.unwrap();
remote.file("/etc/app.conf", b"someone else's\n");
let error = write_in_place(&remote, &path, b"mine 2\n".to_vec(), Some(file.stamp()))
.await
.unwrap_err();
assert_eq!(SaveFailure::from_error(error), SaveFailure::Changed);
assert_eq!(remote.bytes("/etc/app.conf"), b"someone else's\n");
remote.nodes.borrow_mut().remove("/etc/app.conf");
let error = write_in_place(&remote, &path, b"mine 2\n".to_vec(), Some(file.stamp()))
.await
.unwrap_err();
assert_eq!(SaveFailure::from_error(error), SaveFailure::Changed);
// Saving anyway creates it again.
write_in_place(&remote, &path, b"mine 2\n".to_vec(), None)
.await
.unwrap();
assert_eq!(remote.bytes("/etc/app.conf"), b"mine 2\n");
});
}
#[test]
fn a_save_that_fails_after_truncating_is_interrupted() {
runtime().block_on(async {
let remote = Remote::new(true);
remote.file("/etc/app.conf", &vec![b'a'; 200_000]);
remote.fault.set(Some(Fault::Write(2)));
let error = write_in_place(
&remote,
&remote_path("/etc/app.conf"),
vec![b'b'; 200_000],
None,
)
.await
.unwrap_err();
assert!(super::client::is_network_error(&error));
assert!(matches!(
SaveFailure::from_error(error),
SaveFailure::Interrupted(_)
));
});
}
#[cfg(unix)]
#[test]
fn openssh_protocol_edits_in_place_through_links_keeping_mode_and_hard_links() {
runtime().block_on(async {
use std::os::unix::fs::PermissionsExt as _;
let temp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let served = temp.path().join("remote");
std::fs::create_dir(&served).unwrap();
let real = served.join("真的.conf");
std::fs::write(&real, "line one\r\nline two\r\n").unwrap();
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o640)).unwrap();
std::os::unix::fs::symlink("真的.conf", served.join("链接.conf")).unwrap();
std::fs::hard_link(&real, served.join("硬链接.conf")).unwrap();
let (client, mut process) = super::client::SftpClient::local_test_server(&served)
.await
.unwrap();
let base = client
.canonicalize(&RemotePath::new(".").unwrap())
.await
.unwrap();
let link = base.join("链接.conf").unwrap();
let file = read_text(&client, &link).await.unwrap();
assert_eq!(file.text(), "line one\nline two\n");
assert!(file.format().crlf());
let bytes = file.format().encode("short\n");
let stamp = write_in_place(&client, &link, bytes, Some(file.stamp()))
.await
.unwrap();
assert!(
std::fs::symlink_metadata(served.join("链接.conf"))
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(std::fs::read(&real).unwrap(), b"short\r\n");
assert_eq!(
std::fs::read(served.join("硬链接.conf")).unwrap(),
b"short\r\n"
);
assert_eq!(
std::fs::metadata(&real).unwrap().permissions().mode() & 0o777,
0o640
);
assert_eq!(stamp.size(), 7);
// Someone else writes: the next save with the old stamp is refused.
std::fs::write(&real, "theirs, longer\n").unwrap();
let error = write_in_place(&client, &link, b"mine\n".to_vec(), Some(stamp))
.await
.unwrap_err();
assert_eq!(SaveFailure::from_error(error), SaveFailure::Changed);
assert_eq!(std::fs::read(&real).unwrap(), b"theirs, longer\n");
// Too large, without reading it.
let big = std::fs::File::create(served.join("big.log")).unwrap();
big.set_len(EDIT_LIMIT + 1).unwrap();
let error = read_text(&client, &base.join("big.log").unwrap())
.await
.unwrap_err();
assert_eq!(
ReadFailure::from_error(error),
ReadFailure::TooLarge(EDIT_LIMIT + 1)
);
drop(client);
let _ = process.kill().await;
});
}
#[cfg(unix)]
#[test]
fn local_files_are_edited_in_place_through_links() {
use std::os::unix::fs::PermissionsExt as _;
let temp = tempfile::tempdir().unwrap();
let provider = SystemLocalDirectoryProvider;
let real = temp.path().join("real.conf");
std::fs::write(&real, b"\xEF\xBB\xBFold\n").unwrap();
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap();
let link = temp.path().join("link.conf");
std::os::unix::fs::symlink(&real, &link).unwrap();
let file = read_local_text(&provider, &link).unwrap();
assert_eq!(file.text(), "old\n");
assert!(file.format().bom());
let stamp = write_local_text(
&provider,
&link,
&file.format().encode("new\n"),
Some(file.stamp()),
)
.unwrap();
assert!(
std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(std::fs::read(&real).unwrap(), b"\xEF\xBB\xBFnew\n");
assert_eq!(
std::fs::metadata(&real).unwrap().permissions().mode() & 0o777,
0o600
);
std::fs::write(&real, b"theirs, longer\n").unwrap();
assert_eq!(
write_local_text(&provider, &link, b"mine\n", Some(stamp)),
Err(SaveFailure::Changed)
);
assert_eq!(
read_local_text(&provider, temp.path()),
Err(ReadFailure::NotFile)
);
let big = std::fs::File::create(temp.path().join("big.log")).unwrap();
big.set_len(EDIT_LIMIT + 1).unwrap();
assert_eq!(
read_local_text(&provider, &temp.path().join("big.log")),
Err(ReadFailure::TooLarge(EDIT_LIMIT + 1))
);
}
+24 -2
View File
@@ -1,6 +1,6 @@
use super::{
DirectoryListing, DownloadRequest, PermissionEdit, RemotePath, TransferAnswer,
TransferProgress, TransferQuestion, UploadRequest,
DirectoryListing, DownloadRequest, FileStamp, PermissionEdit, ReadFailure, RemotePath,
SaveFailure, TextFile, TransferAnswer, TransferProgress, TransferQuestion, UploadRequest,
};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptReply, Latency},
@@ -49,6 +49,20 @@ pub enum SftpCommand {
request_id: u64,
operation: RemoteOperation,
},
/// Read a whole text file for the editor; answered by `FileRead`. Runs
/// alongside listings and transfers, like an operation.
ReadFile {
request_id: u64,
path: RemotePath,
},
/// Write the editor's text over a file in place; answered by
/// `FileWritten`. With `expected`, only if the file still matches it.
WriteFile {
request_id: u64,
path: RemotePath,
bytes: Vec<u8>,
expected: Option<FileStamp>,
},
Upload(UploadRequest),
/// A download batch; like an upload, one batch runs at a time.
Download(DownloadRequest),
@@ -82,6 +96,14 @@ pub enum SftpEvent {
request_id: u64,
result: Result<(), String>,
},
FileRead {
request_id: u64,
result: Result<TextFile, ReadFailure>,
},
FileWritten {
request_id: u64,
result: Result<FileStamp, SaveFailure>,
},
Progress(TransferProgress),
Question(TransferQuestion),
Notice(String),
+63 -4
View File
@@ -1,10 +1,11 @@
use super::{
EntryKind, RemotePath, SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider,
TransferDetail, TransferDirection, TransferPhase, TransferProgress,
EntryKind, ReadFailure, RemotePath, SaveFailure, SftpCommand, SftpEvent, SftpTransport,
SftpTransportProvider, TransferDetail, TransferDirection, TransferPhase, TransferProgress,
client::RemoteFs as _,
client::{SftpClient, is_network_error},
control::{Cancelled, TransferControl},
download::DownloadBatch,
edit,
journal::{DownloadJournal, Journal},
meter::TransferMeter,
model::{scp_local_target, scp_remote_target},
@@ -27,6 +28,9 @@ use std::{
};
use tokio::sync::{RwLock, watch};
/// The answer to a request that needs the connection while there is none.
const NOT_CONNECTED: &str = "SFTP 未连接,请重新连接";
/// How often the latency task looks for a connection to measure, and for
/// the one it is measuring to be dropped.
const LATENCY_POLL: Duration = Duration::from_millis(250);
@@ -196,7 +200,7 @@ impl SshSftpTransport {
Err(format!("无法读取目录:{error:#}"))
}
},
None => Err("SFTP 未连接,请重新连接".into()),
None => Err(NOT_CONNECTED.into()),
};
let _ = events.send(SftpEvent::Listed { request_id, result }).await;
});
@@ -221,13 +225,68 @@ impl SshSftpTransport {
}
}
}
None => Err("SFTP 未连接,请重新连接".into()),
None => Err(NOT_CONNECTED.into()),
};
let _ = events
.send(SftpEvent::Operated { request_id, result })
.await;
});
}
SftpCommand::ReadFile { request_id, path } => {
let shared = client.clone();
let connected = client.read().await.clone();
let events = events.clone();
tokio::spawn(async move {
let result = match connected {
Some(connected) => {
match edit::read_text(connected.as_ref(), &path).await {
Ok(file) => Ok(file),
Err(error) => {
drop_broken(&shared, &connected, &events, &error)
.await;
Err(ReadFailure::from_error(error))
}
}
}
None => Err(ReadFailure::Failed(NOT_CONNECTED.into())),
};
let _ = events
.send(SftpEvent::FileRead { request_id, result })
.await;
});
}
SftpCommand::WriteFile {
request_id,
path,
bytes,
expected,
} => {
let shared = client.clone();
let connected = client.read().await.clone();
let events = events.clone();
tokio::spawn(async move {
let result = match connected {
Some(connected) => match edit::write_in_place(
connected.as_ref(),
&path,
bytes,
expected,
)
.await
{
Ok(stamp) => Ok(stamp),
Err(error) => {
drop_broken(&shared, &connected, &events, &error).await;
Err(SaveFailure::from_error(error))
}
},
None => Err(SaveFailure::Failed(NOT_CONNECTED.into())),
};
let _ = events
.send(SftpEvent::FileWritten { request_id, result })
.await;
});
}
command => {
if busy.swap(true, Ordering::AcqRel) {
let _ = events
+22 -2
View File
@@ -1,7 +1,7 @@
use std::rc::Rc;
use gpui_kit::component::{
IconName, Sizable as _,
ActiveTheme as _, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex,
menu::{ContextMenuExt as _, PopupMenu},
@@ -35,6 +35,7 @@ pub struct ClosableTabTitle {
label: SharedString,
close: Option<(ElementId, Box<dyn Action>)>,
menu: Option<MenuBuilder>,
modified: bool,
}
impl ClosableTabTitle {
@@ -51,9 +52,17 @@ impl ClosableTabTitle {
label: label.into(),
close: None,
menu: None,
modified: false,
}
}
/// Mark the tab as holding changes not saved yet: a dot after the label,
/// as editors do.
pub fn modified(mut self, modified: bool) -> Self {
self.modified = modified;
self
}
/// Show a close button that dispatches `action` on the focused path.
pub fn closable(mut self, id: impl Into<ElementId>, action: Box<dyn Action>) -> Self {
self.close = Some((id.into(), action));
@@ -72,12 +81,23 @@ impl ClosableTabTitle {
}
impl RenderOnce for ClosableTabTitle {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let row = h_flex()
.id("title")
.gap_1()
.child(self.leading)
.child(self.label.clone())
.when(self.modified, |row| {
row.child(
div()
.id("modified")
.test_support()
.aria_label("未保存")
.size_1p5()
.rounded_full()
.bg(cx.theme().muted_foreground),
)
})
.when_some(self.close, |row, (id, action)| {
row.child(
Button::new(id)
+3 -2
View File
@@ -5,8 +5,8 @@ use gpui_kit::component::{
use gpui_kit::*;
use crate::app::{
CenterTab, CloseExplorer, CloseLocalTerminal, CloseScope, CloseSettings, CloseTabs,
CloseTerminal,
CenterTab, CloseEditor, CloseExplorer, CloseLocalTerminal, CloseScope, CloseSettings,
CloseTabs, CloseTerminal,
};
/// The close commands every center tab's context menu ends with: this tab,
@@ -58,5 +58,6 @@ fn close_action(tab: CenterTab) -> Box<dyn Action> {
CenterTab::Explorer(id) => Box::new(CloseExplorer(id)),
CenterTab::LocalTerminal(id) => Box::new(CloseLocalTerminal(id)),
CenterTab::Settings => Box::new(CloseSettings),
CenterTab::Editor(id) => Box::new(CloseEditor(id)),
}
}
+26 -5
View File
@@ -178,6 +178,8 @@ pub struct RestartImpact {
pub local_terminals: usize,
pub transfers: usize,
pub forwards: usize,
/// Editor tabs with changes not saved yet; a restart loses them.
pub unsaved_files: usize,
}
/// 「重启会关闭 2 个远程终端和 1 个 SFTP 标签,停止 1 个传输(保留续传进度)。」,
@@ -193,12 +195,21 @@ pub fn restart_note(impact: RestartImpact) -> Option<String> {
(impact.forwards, "条端口转发"),
]);
let note = match (closes, stops) {
(None, None) => return None,
(Some(closes), None) => format!("重启会关闭 {closes}。"),
(None, Some(stops)) => format!("重启会停止 {stops}。"),
(Some(closes), Some(stops)) => format!("重启会关闭 {closes},停止 {stops}。"),
(None, None) => None,
(Some(closes), None) => Some(format!("重启会关闭 {closes}。")),
(None, Some(stops)) => Some(format!("重启会停止 {stops}。")),
(Some(closes), Some(stops)) => Some(format!("重启会关闭 {closes},停止 {stops}。")),
};
Some(note)
let unsaved = (impact.unsaved_files > 0).then(|| {
format!(
"{} 个文件有未保存的修改,重启后会丢失。",
impact.unsaved_files
)
});
match (note, unsaved) {
(Some(note), Some(unsaved)) => Some(format!("{note}{unsaved}")),
(note, unsaved) => note.or(unsaved),
}
}
/// `2 个远程终端、1 个 SFTP 标签和 1 个本地终端`, leaving out what is none.
@@ -258,6 +269,7 @@ mod tests {
local_terminals: 1,
transfers: 1,
forwards: 3,
unsaved_files: 0,
})
.as_deref(),
Some(
@@ -273,6 +285,15 @@ mod tests {
.as_deref(),
Some("重启会停止 1 条端口转发。")
);
assert_eq!(
restart_note(RestartImpact {
sftp_tabs: 1,
unsaved_files: 2,
..RestartImpact::default()
})
.as_deref(),
Some("重启会关闭 1 个 SFTP 标签。2 个文件有未保存的修改,重启后会丢失。")
);
}
#[test]
+337
View File
@@ -0,0 +1,337 @@
//! The workspace's part in editing files: opening an editor tab for a file
//! an SFTP tab read, showing the one already open instead of a second,
//! closing them, and what has to be asked first when changes would be lost.
use std::rc::Rc;
use gpui_kit::component::dock::{DockPlacement, panel_handle};
use gpui_kit::*;
use super::Workspace;
use crate::app::{CenterTab, CloseEditor, EditorAction, EditorCommand, EditorShortcut};
use crate::editor::{EditorId, EditorKey, EditorPanel, EditorPanelEvent, EditorSource};
use crate::explorer::{ExplorerId, FileLocation};
use crate::sftp::TextFile;
use crate::shared::confirm_danger;
impl Workspace {
/// The editor of one tab, for tests and the status line.
pub fn editor(&self, id: EditorId) -> Option<&Entity<EditorPanel>> {
self.editors.get(&id)
}
/// The editor already open for a file of an SFTP tab's host.
pub(super) fn editor_for_location(
&self,
explorer: ExplorerId,
location: &FileLocation,
cx: &App,
) -> Option<Entity<EditorPanel>> {
let key = match location {
FileLocation::Remote(path) => EditorKey::Remote(
self.explorers.get(&explorer)?.read(cx).host_id(),
path.clone(),
),
FileLocation::Local(path) => EditorKey::Local(path.clone()),
};
self.editors
.values()
.find(|editor| editor.read(cx).key() == key)
.cloned()
}
/// Bring an editor's tab to the front, with focus in its text.
pub(super) fn show_editor(
&self,
editor: &Entity<EditorPanel>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let group = editor.read(cx).tab_group();
self.activate_tab(group, editor.entity_id(), window, cx);
let focus = editor.read(cx).state().read(cx).focus_handle(cx);
window.focus(&focus, cx);
}
/// A new editor tab for a file an SFTP tab has read, or the tab already
/// open for it (it was opened again while being read).
pub(super) fn open_editor(
&mut self,
explorer: ExplorerId,
location: FileLocation,
file: Rc<TextFile>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(editor) = self.editor_for_location(explorer, &location, cx) {
self.show_editor(&editor, window, cx);
return;
}
let Some(panel) = self.explorers.get(&explorer) else {
return;
};
let source = match &location {
FileLocation::Remote(_) => EditorSource::Remote {
explorer: panel.downgrade(),
explorer_id: explorer,
host: panel.read(cx).host_id(),
},
FileLocation::Local(_) => EditorSource::Local {
provider: self.local_directory_provider.clone(),
},
};
let file = Rc::try_unwrap(file).unwrap_or_else(|file| (*file).clone());
let id = EditorId(self.next_editor_id);
self.next_editor_id += 1;
let (store, dispatch) = (self.store.clone(), self.focus_handle.clone());
let editor =
cx.new(|cx| EditorPanel::new(id, source, location, file, store, dispatch, window, cx));
let subscription = cx.subscribe_in(&editor, window, Self::on_editor_event);
self._subscriptions.push(subscription);
self.editors.insert(id, editor.clone());
self.dock_area.update(cx, |area, cx| {
area.add_panel_view(
panel_handle(editor),
DockPlacement::Center,
None,
window,
cx,
);
});
}
fn on_editor_event(
&mut self,
editor: &Entity<EditorPanel>,
event: &EditorPanelEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
EditorPanelEvent::Activated(id) => {
let host = editor.read(cx).host_id();
self.store
.update(cx, |store, cx| store.set_active(host, cx));
self.set_active_tab(Some(CenterTab::Editor(*id)), window, cx);
cx.notify();
}
EditorPanelEvent::Closed(id) => {
self.editors.remove(id);
if self.active_tab == Some(CenterTab::Editor(*id)) {
self.set_active_tab(None, window, cx);
}
cx.notify();
}
EditorPanelEvent::Changed(id) => {
if self.active_tab == Some(CenterTab::Editor(*id)) {
cx.notify();
}
}
// The panes that show the file's folder show its new size and
// time: for a remote file the SFTP tabs of its host, for a
// local one every SFTP tab.
EditorPanelEvent::Saved(_) => {
let (location, host) = {
let editor = editor.read(cx);
(editor.location().clone(), editor.host_id())
};
let explorers: Vec<_> = self
.explorers
.values()
.filter(|panel| host.is_none_or(|host| panel.read(cx).host_id() == host))
.cloned()
.collect();
for panel in explorers {
panel.update(cx, |panel, cx| {
panel.reload_if_showing(&location, window, cx)
});
}
}
EditorPanelEvent::CloseRequested(id) => self.remove_editor(*id, window, cx),
}
}
pub(super) fn on_close_editor(
&mut self,
action: &CloseEditor,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(editor) = self.editors.get(&action.0).cloned() else {
return;
};
if editor.read(cx).is_dirty() {
self.show_editor(&editor, window, cx);
editor.update(cx, |editor, cx| editor.confirm_close(window, cx));
} else {
self.remove_editor(action.0, window, cx);
}
}
pub(super) fn on_editor_action(
&mut self,
action: &EditorAction,
window: &mut Window,
cx: &mut Context<Self>,
) {
if action.command() == EditorCommand::CloseConfirmed {
self.remove_editor(action.editor(), window, cx);
} else if let Some(editor) = self.editors.get(&action.editor()).cloned() {
editor.update(cx, |editor, cx| {
editor.execute(action.command(), window, cx)
});
}
}
pub(super) fn on_editor_shortcut(
&mut self,
action: &EditorShortcut,
window: &mut Window,
cx: &mut Context<Self>,
) {
let focused = self
.editors
.values()
.find(|editor| editor.read(cx).contains_focus(window, cx))
.cloned();
if let Some(editor) = focused {
editor.update(cx, |editor, cx| editor.execute(action.0, window, cx));
}
}
/// Close an editor tab without asking.
pub(super) fn remove_editor(
&mut self,
id: EditorId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(editor) = self.editors.get(&id).cloned() {
self.dock_area
.update(cx, |area, cx| area.remove_panel(editor, window, cx));
}
}
/// The editors of remote files read and written through an SFTP tab.
pub(super) fn editors_of(&self, explorer: ExplorerId, cx: &App) -> Vec<EditorId> {
self.editors
.iter()
.filter(|(_, editor)| editor.read(cx).explorer_id() == Some(explorer))
.map(|(id, _)| *id)
.collect()
}
/// The names of the files with changes not saved yet, among `editors`.
pub(super) fn unsaved_files(&self, editors: &[EditorId], cx: &App) -> Vec<String> {
let mut names: Vec<String> = editors
.iter()
.filter_map(|id| self.editors.get(id))
.filter(|editor| editor.read(cx).is_dirty())
.map(|editor| editor.read(cx).name())
.collect();
names.sort();
names
}
/// Every editor with changes not saved yet.
pub(super) fn all_unsaved_files(&self, cx: &App) -> Vec<String> {
let ids: Vec<EditorId> = self.editors.keys().copied().collect();
self.unsaved_files(&ids, cx)
}
/// The quit guard: with changes not saved, ask before quitting and hold
/// the quit back (true) until the user agrees.
pub(super) fn ask_before_quit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let unsaved = self.all_unsaved_files(cx);
if unsaved.is_empty() {
return false;
}
// A hidden window (closed on macOS) would ask where nobody sees it.
crate::app::bring_forward(window, cx);
confirm_danger(
"退出 ShellRS?".into(),
Some(format!("{}有未保存的修改,退出后会丢失。", describe_files(&unsaved)).into()),
"放弃修改并退出",
Rc::new(|_, cx| cx.quit()),
window,
cx,
);
true
}
/// Close several center tabs of one batch (关闭其他, 关闭全部…). Editors
/// with changes, theirs or those of SFTP tabs in the batch, are asked
/// about once for all, not one dialog each.
pub(super) fn close_center_tabs(
&mut self,
tabs: Vec<CenterTab>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut editors = Vec::new();
for tab in &tabs {
match tab {
CenterTab::Editor(id) => editors.push(*id),
CenterTab::Explorer(id) => editors.extend(self.editors_of(*id, cx)),
_ => {}
}
}
let unsaved = self.unsaved_files(&editors, cx);
if unsaved.is_empty() {
for tab in tabs {
self.close_center_tab(tab, window, cx);
}
return;
}
let this = cx.weak_entity();
confirm_danger(
format!("关闭 {} 个标签?", tabs.len()).into(),
Some(format!("{}有未保存的修改,关闭后会丢失。", describe_files(&unsaved)).into()),
"放弃修改并关闭",
Rc::new(move |window, cx| {
let tabs = tabs.clone();
let _ = this.update(cx, |this, cx| {
for tab in tabs {
match tab {
CenterTab::Editor(id) => this.remove_editor(id, window, cx),
CenterTab::Explorer(id) => this.close_explorer(id, false, window, cx),
tab => this.close_center_tab(tab, window, cx),
}
}
});
}),
window,
cx,
);
}
}
/// 「“a.conf”」, 「“a.conf”和“b.conf”」, 「“a.conf”等 4 个文件」: the files
/// whose changes a close or a quit would lose.
pub(super) fn describe_files(names: &[String]) -> String {
match names {
[] => String::new(),
[one] => format!("“{one}”"),
[one, two] => format!("“{one}”和“{two}”"),
[one, ..] => format!("“{one}”等 {} 个文件", names.len()),
}
}
#[cfg(test)]
mod tests {
use super::describe_files;
#[test]
fn unsaved_files_are_named_while_they_are_few() {
let names = |list: &[&str]| list.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(describe_files(&names(&["a.conf"])), "“a.conf”");
assert_eq!(
describe_files(&names(&["a.conf", "b.yml"])),
"“a.conf”和“b.yml”"
);
assert_eq!(
describe_files(&names(&["a.conf", "b.yml", "c.sh"])),
"“a.conf”等 3 个文件"
);
}
}
+1
View File
@@ -4,6 +4,7 @@
mod credentials;
mod dock_skin;
mod editors;
mod forwards;
mod links;
mod recent_hosts;
+60 -3
View File
@@ -21,6 +21,14 @@ pub enum WorkspaceStatus {
Local(TerminalStatus),
/// An SFTP tab: its own connection and what went wrong in it.
Explorer(Option<Host>, ExplorerStatus),
/// An editor: the connection of the SFTP tab a remote file goes through
/// (none for a local file), then where the cursor is and how the file
/// is encoded.
Editor {
remote: Option<(Option<Host>, ConnectionState)>,
cursor: String,
format: String,
},
}
/// The icon for a connection state; a dropped one, like a problem, in red.
@@ -44,12 +52,42 @@ impl RenderOnce for WorkspaceStatus {
// Red when something is wrong: a dropped connection, or an SFTP
// tab's directory that could not be read.
let mut alarming = false;
// The right side: an editor tells its cursor and encoding instead
// of the terminal's.
let mut editor = None;
let (text, icon, address, terminal): (
SharedString,
Icon,
Option<String>,
Option<TerminalStatus>,
) = match self {
WorkspaceStatus::Editor {
remote,
cursor,
format,
} => {
editor = Some((cursor, format));
match remote {
Some((host, state)) => {
alarming = state == ConnectionState::Disconnected;
let name = host
.as_ref()
.map_or_else(|| "SFTP".into(), |host| host.name.clone());
(
format!("{} {name}", state.label()).into(),
state_icon(state, cx),
host.map(|host| host.endpoint()),
None,
)
}
None => (
"本机文件".into(),
Icon::new(CatalogIcon::Laptop).text_color(muted),
None,
None,
),
}
}
WorkspaceStatus::Host(active, terminal) => match active {
Some(host) => {
alarming = host.state == ConnectionState::Disconnected;
@@ -134,9 +172,28 @@ impl RenderOnce for WorkspaceStatus {
.when_some(address, |bar, address| {
bar.left(Separator::vertical()).left(address)
})
.right("编码 UTF-8")
.right(Separator::vertical())
.right("终端 xterm-256color")
.map(|bar| match editor {
Some((cursor, format)) => bar
.right(
div()
.id("status-editor-cursor")
.test_support()
.aria_label(cursor.clone())
.child(cursor),
)
.right(Separator::vertical())
.right(
div()
.id("status-editor-format")
.test_support()
.aria_label(format.clone())
.child(format),
),
None => bar
.right("编码 UTF-8")
.right(Separator::vertical())
.right("终端 xterm-256color"),
})
.when_some(terminal, |bar, terminal| {
let (columns, rows) = (terminal.columns(), terminal.rows());
let size = format!("{columns}×{rows}");
+1
View File
@@ -157,6 +157,7 @@ impl Workspace {
.filter(|panel| panel.read(cx).is_transferring())
.count(),
forwards: self.forwards.read(cx).active_count(),
unsaved_files: self.all_unsaved_files(cx).len(),
}
}
}
+148 -23
View File
@@ -19,18 +19,20 @@ use gpui_kit::component::{
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::editors::describe_files;
use crate::app::ExplorerDispatch as _;
use crate::app::{
CenterTab, ClearTerminal, CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseSettings,
CloseTabs, CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectHost, CopyAgentSkill,
CopyHostAddress, CopyHostId, CopyTerminal, DeleteGroup, DeleteHost, DisconnectHost,
DisconnectTerminal, DismissTerminalFind, DuplicateHost, EditHost, ExpandAllGroups,
ExplorerAction, ExplorerCommand, ExplorerShortcut, FindInTerminal, FindNextInTerminal,
FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand, MoveHostNode,
NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal, NewTemporaryConnection,
OpenExplorer, OpenSettings, PasteTerminal, QuickConnect, ReconnectTerminal,
RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, RenameExplorer, RenameGroup,
RenameTerminal, RestartLocalTerminal, SetFileSizeFormat, ToggleHostPanel, ToggleTheme, ZoomIn,
ZoomOut, ZoomReset,
CenterTab, ClearTerminal, CloseActiveTab, CloseEditor, CloseExplorer, CloseLocalTerminal,
CloseSettings, CloseTabs, CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectHost,
CopyAgentSkill, CopyHostAddress, CopyHostId, CopyTerminal, DeleteGroup, DeleteHost,
DisconnectHost, DisconnectTerminal, DismissTerminalFind, DuplicateHost, EditHost,
ExpandAllGroups, ExplorerAction, ExplorerCommand, ExplorerShortcut, FindInTerminal,
FindNextInTerminal, FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand,
MoveHostNode, NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal,
NewTemporaryConnection, OpenExplorer, OpenSettings, PasteTerminal, QuickConnect,
ReconnectTerminal, RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, RenameExplorer,
RenameGroup, RenameTerminal, RestartLocalTerminal, SetFileSizeFormat, ToggleHostPanel,
ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
};
use crate::cli::{CliIntegration, CliServer, CliTarget, IntegrationPaths, SshCliBackend};
use crate::connection::{
@@ -38,6 +40,7 @@ use crate::connection::{
ConnectionSecret, SharedConnectionTester,
};
use crate::credential::CredentialPanel;
use crate::editor::{EditorId, EditorPanel};
use crate::explorer::{ExplorerId, ExplorerPanel, ExplorerPanelEvent, confirm_close_transfer};
use crate::forward::{
ForwardManager, ForwardManagerEvent, ForwardPanel, SharedForwardTransportProvider,
@@ -55,7 +58,7 @@ use crate::sftp::{
SharedLocalDirectoryProvider, SharedSftpTransportProvider, SshSftpTransportProvider,
SystemLocalDirectoryProvider,
};
use crate::shared::{commit_footer, open_rename_tab_dialog};
use crate::shared::{commit_footer, confirm_danger, open_rename_tab_dialog};
use crate::terminal::{
LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, LocalTerminalPanelEvent,
RemoteTerminalId, SearchDirection, SharedRemoteTerminalTransportProvider,
@@ -165,7 +168,7 @@ pub struct Workspace {
local_terminal_factory: SharedTerminalTransportFactory,
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
sftp_provider: SharedSftpTransportProvider,
local_directory_provider: SharedLocalDirectoryProvider,
pub(super) local_directory_provider: SharedLocalDirectoryProvider,
/// Backs the host dialog's 「测试连接」.
connection_tester: SharedConnectionTester,
next_remote_terminal_id: u64,
@@ -183,7 +186,10 @@ pub struct Workspace {
/// Dispatch target for the title bar and start page: actions sent to it
/// reach the workspace handlers whatever is focused.
pub(super) focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
/// Editor tabs, one per file.
pub(super) editors: HashMap<EditorId, Entity<EditorPanel>>,
pub(super) next_editor_id: u64,
pub(super) _subscriptions: Vec<Subscription>,
}
impl Workspace {
@@ -433,6 +439,17 @@ impl Workspace {
.filter(|host| host.state.is_connected())
.map(|host| host.id)
.collect();
// ⌘Q (and on Windows and Linux the window's close button) asks first
// when an editor has changes not saved yet.
let this = cx.weak_entity();
crate::app::set_quit_guard(
window.window_handle(),
move |window, cx| {
this.update(cx, |this, cx| this.ask_before_quit(window, cx))
.unwrap_or(false)
},
cx,
);
let mut terminals = HashMap::new();
let mut center = DockLayout::tabs();
let mut next_remote_terminal_id = 1;
@@ -515,6 +532,8 @@ impl Workspace {
prompt_queue: VecDeque::new(),
active_prompt: None,
focus_handle,
editors: HashMap::new(),
next_editor_id: 1,
_subscriptions: subscriptions,
}
}
@@ -627,6 +646,10 @@ impl Workspace {
.settings_tab
.as_ref()
.map(|panel| panel.read(cx).focus_handle(cx)),
Some(CenterTab::Editor(id)) => self
.editors
.get(&id)
.map(|panel| panel.read(cx).focus_handle(cx)),
None => None,
};
let handle = tab.or_else(|| {
@@ -1249,7 +1272,7 @@ impl Workspace {
}
/// Display one tab of a tab group, found by its panel's entity.
fn activate_tab(
pub(super) fn activate_tab(
&self,
group: Option<WeakEntity<TabGroup>>,
panel: EntityId,
@@ -1292,20 +1315,63 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(panel) = self.explorers.get(&action.0).cloned() {
self.close_explorer(action.0, true, window, cx);
}
/// Close an SFTP tab, asking first while it transfers, and while files
/// it opened have changes not saved yet unless that was asked already.
pub(super) fn close_explorer(
&mut self,
id: ExplorerId,
ask_about_unsaved: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(panel) = self.explorers.get(&id).cloned() {
let generation = panel.read(cx).generation();
// Its remote files close with it: changes not saved yet would be
// lost, so the question names them.
let unsaved = if ask_about_unsaved {
self.unsaved_files(&self.editors_of(id, cx), cx)
} else {
Vec::new()
};
let unsaved = (!unsaved.is_empty()).then(|| {
format!(
"{}有未保存的修改,关闭 SFTP 标签会一起关掉它们,修改会丢失。",
describe_files(&unsaved)
)
});
if panel.read(cx).is_transferring() {
let direction = panel.read(cx).transfer_direction();
confirm_close_transfer(
action.0,
id,
generation,
direction,
unsaved,
self.focus_handle.clone(),
window,
cx,
);
} else if let Some(unsaved) = unsaved {
let dispatch = self.focus_handle.clone();
confirm_danger(
"关闭 SFTP 标签?".into(),
Some(unsaved.into()),
"放弃修改并关闭",
Rc::new(move |window, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(id, ExplorerCommand::CloseConfirmed)
.with_generation(generation),
window,
cx,
)
}),
window,
cx,
);
} else {
self.remove_explorer(action.0, window, cx);
self.remove_explorer(id, window, cx);
}
}
}
@@ -1343,6 +1409,15 @@ impl Workspace {
cx,
);
}
// A file open in an editor already shows that editor: no second read,
// no second tab.
if let ExplorerCommand::Edit { remote, path } = action.command()
&& let Some(location) = panel.read(cx).edit_target(*remote, path.as_deref(), cx)
&& let Some(editor) = self.editor_for_location(action.explorer(), &location, cx)
{
self.show_editor(&editor, window, cx);
return;
}
if matches!(action.command(), ExplorerCommand::CloseConfirmed) {
self.remove_explorer(action.explorer(), window, cx);
} else {
@@ -1392,7 +1467,12 @@ impl Workspace {
/// Close one center tab through its own close path, so an explorer that
/// is uploading still asks first.
fn close_center_tab(&mut self, tab: CenterTab, window: &mut Window, cx: &mut Context<Self>) {
pub(super) fn close_center_tab(
&mut self,
tab: CenterTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
match tab {
CenterTab::Terminal(id) => self.on_close_terminal(&CloseTerminal(id), window, cx),
CenterTab::Explorer(id) => self.on_close_explorer(&CloseExplorer(id), window, cx),
@@ -1400,6 +1480,7 @@ impl Workspace {
self.on_close_local_terminal(&CloseLocalTerminal(id), window, cx)
}
CenterTab::Settings => self.on_close_settings(&CloseSettings, window, cx),
CenterTab::Editor(id) => self.on_close_editor(&CloseEditor(id), window, cx),
}
}
@@ -1422,6 +1503,10 @@ impl Workspace {
let panel = self.settings_tab.as_ref()?;
(panel.read(cx).tab_group(), panel.entity_id())
}
CenterTab::Editor(id) => {
let panel = self.editors.get(&id)?;
(panel.read(cx).tab_group(), panel.entity_id())
}
};
Some((group?.upgrade()?, PanelId::from(entity)))
}
@@ -1451,6 +1536,12 @@ impl Workspace {
.filter(|panel| is(panel.entity_id()))
.map(|_| CenterTab::Settings)
})
.or_else(|| {
self.editors
.iter()
.find(|(_, entity)| is(entity.entity_id()))
.map(|(id, _)| CenterTab::Editor(*id))
})
}
fn on_close_tabs(&mut self, action: &CloseTabs, window: &mut Window, cx: &mut Context<Self>) {
@@ -1472,9 +1563,7 @@ impl Workspace {
.into_iter()
.filter_map(|target| self.center_tab_for_panel(panels[target]))
.collect();
for tab in tabs {
self.close_center_tab(tab, window, cx);
}
self.close_center_tabs(tabs, window, cx);
}
fn on_rename_terminal(
@@ -1553,7 +1642,7 @@ impl Workspace {
CenterTab::LocalTerminal(id) => {
Some(self.local_terminals.get(&id)?.read(cx).terminal().clone())
}
CenterTab::Explorer(_) | CenterTab::Settings => None,
CenterTab::Explorer(_) | CenterTab::Settings | CenterTab::Editor(_) => None,
}
}
@@ -2134,6 +2223,11 @@ fn new_explorer_panel(
this.set_active_tab(Some(CenterTab::Explorer(*id)), window, cx)
}
ExplorerPanelEvent::Closed(id, host_id) => {
// Its remote files can no longer be written: their editors
// go with it (anything unsaved was asked about first).
for editor in this.editors_of(*id, cx) {
this.remove_editor(editor, window, cx);
}
this.explorers.remove(id);
if this.active_tab == Some(CenterTab::Explorer(*id)) {
this.set_active_tab(None, window, cx);
@@ -2163,6 +2257,9 @@ fn new_explorer_panel(
window,
cx,
),
ExplorerPanelEvent::EditFile(id, location, file) => {
this.open_editor(*id, location.clone(), file.clone(), window, cx)
}
},
);
(panel, subscription)
@@ -2224,6 +2321,31 @@ impl Render for Workspace {
}
None => WorkspaceStatus::Host(active, None),
},
// An editor of a remote file tells the connection of the SFTP
// tab its saves go through.
Some(CenterTab::Editor(id)) => match self.editors.get(&id) {
Some(editor) => {
let editor = editor.read(cx);
let remote = editor.explorer_id().map(|explorer| {
let host = editor
.host_id()
.and_then(|host| self.store.read(cx).host(host).cloned());
let state = self
.explorers
.get(&explorer)
.map_or(ConnectionState::Disconnected, |panel| {
panel.read(cx).connection_state()
});
(host, state)
});
WorkspaceStatus::Editor {
remote,
cursor: editor.cursor_label(),
format: editor.format_label(),
}
}
None => WorkspaceStatus::Host(active, None),
},
_ => WorkspaceStatus::Host(active, None),
};
let sidebar = self.sidebar_showing(cx);
@@ -2260,6 +2382,9 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_explorer_shortcut))
.on_action(cx.listener(Self::on_close_terminal))
.on_action(cx.listener(Self::on_close_explorer))
.on_action(cx.listener(Self::on_close_editor))
.on_action(cx.listener(Self::on_editor_action))
.on_action(cx.listener(Self::on_editor_shortcut))
.on_action(cx.listener(Self::on_close_local_terminal))
.on_action(cx.listener(Self::on_open_settings))
.on_action(cx.listener(Self::on_install_cli_command))
+550
View File
@@ -0,0 +1,550 @@
//! The built-in editor: opening a file from an SFTP tab, saving it back in
//! place, and what is asked before changes would be lost.
use crate::support::*;
use shellrs::app::{
CloseActiveTab, CloseEditor, CloseExplorer, ExplorerAction, ExplorerCommand, Quit,
};
use shellrs::editor::EditorId;
use shellrs::explorer::{NewEntryKind, PaneOperation};
const REMOTE_FILE: &str = "/home/tester/文件 甲.txt";
const OTHER_REMOTE_FILE: &str = "/home/tester/文件 乙.txt";
const LOCAL_FILE: &str = "/local/tester/文件 甲.txt";
const FIRST: EditorId = EditorId(1);
/// An SFTP tab on a server with these files, the remote side listed.
async fn open_server(
cx: &mut TestAppContext,
files: &[(&str, &[u8])],
) -> (WindowHandle<Root>, Entity<Workspace>, Arc<FakeSftpProvider>) {
let provider = Arc::new(FakeSftpProvider::with_files(files));
let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone());
open_test_explorer(cx, handle).await;
(handle, workspace, provider)
}
fn open_file(cx: &mut TestAppContext, handle: WindowHandle<Root>, pane: &'static str, name: &str) {
let name = ElementId::Name(format!("name:{name}").into());
in_frame(cx, handle, |window, cx| {
window.within((pane, SFTP_TAB)).double_click(name, cx)
});
}
async fn wait_for_editor(cx: &mut TestAppContext, handle: WindowHandle<Root>, id: EditorId) {
cx.wait_for(handle.into(), Duration::from_secs(2), move |window, cx| {
window.render_frame(cx);
window.try_find(("editor", id.0)).is_some()
})
.await;
}
async fn wait_for(cx: &mut TestAppContext, handle: WindowHandle<Root>, id: &'static str) {
cx.wait_for(handle.into(), Duration::from_secs(2), move |window, cx| {
window.render_frame(cx);
window.try_find(id).is_some()
})
.await;
}
fn text_of(workspace: &Entity<Workspace>, id: EditorId, cx: &mut TestAppContext) -> String {
cx.update(|cx| {
workspace
.read(cx)
.editor(id)
.expect("editor open")
.read(cx)
.state()
.read(cx)
.value()
.to_string()
})
}
fn is_open(workspace: &Entity<Workspace>, id: EditorId, cx: &mut TestAppContext) -> bool {
cx.update(|cx| workspace.read(cx).editor(id).is_some())
}
/// Type at the start of the editor, which holds focus once it opens.
fn type_text(cx: &mut TestAppContext, handle: WindowHandle<Root>, text: &str) {
in_frame(cx, handle, |window, cx| window.input(text, cx));
}
/// Until the fake server has seen `count` writes, and the tab its answer:
/// the SFTP tab polls its worker every 16 ms.
async fn wait_for_writes(cx: &mut TestAppContext, provider: &FakeSftpProvider, count: usize) {
let writes = provider.writes.clone();
for _ in 0..200 {
if writes.lock().unwrap().len() >= count {
for _ in 0..5 {
cx.executor().advance_clock(Duration::from_millis(20));
cx.run_until_parked();
}
return;
}
cx.run_until_parked();
cx.executor().advance_clock(Duration::from_millis(20));
}
panic!("expected {count} writes");
}
#[gpui_kit::test]
async fn double_clicking_a_remote_file_opens_it_once(cx: &mut TestAppContext) {
let (handle, workspace, provider) = open_server(cx, &[(REMOTE_FILE, b"hello world\n")]).await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
assert_eq!(text_of(&workspace, FIRST, cx), "hello world\n");
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find(("editor-tab", FIRST.0)).label(),
Some("文件 甲.txt")
);
assert_eq!(
window.find(("editor-path", FIRST.0)).label(),
Some("db-01:/home/tester/文件 甲.txt")
);
assert_eq!(
window.find("status-editor-cursor").label(),
Some("行 1,列 1")
);
assert_eq!(
window.find("status-editor-format").label(),
Some("UTF-8 · LF")
);
assert_eq!(
window.find("status-connection").label(),
Some("已连接 db-01")
);
// Nothing changed yet: no mark, nothing to save.
assert!(
window
.within(("editor-tab", FIRST.0))
.try_find("modified")
.is_none()
);
assert!(window.try_find(("editor-state", FIRST.0)).is_none());
});
// Opening it again shows the same tab: no second read, no second tab.
in_frame(cx, handle, |window, cx| {
window.click(("explorer-tab", SFTP_TAB), cx)
});
open_file(cx, handle, "remote-pane", "文件 甲.txt");
in_frame(cx, handle, |window, _| {
assert!(window.try_find(("editor", FIRST.0)).is_some());
});
in_frame(cx, handle, |window, cx| {
window.click(("explorer-tab", SFTP_TAB), cx)
});
in_frame(cx, handle, |window, cx| {
window
.within(("remote-pane", SFTP_TAB))
.click("name:文件 甲.txt", cx);
window.press("f4", cx);
});
cx.run_until_parked();
assert_eq!(provider.reads.lock().unwrap().len(), 1);
assert!(!is_open(&workspace, EditorId(2), cx));
// A directory still opens as a directory.
in_frame(cx, handle, |window, cx| {
window.click(("explorer-tab", SFTP_TAB), cx)
});
open_file(cx, handle, "remote-pane", "目录");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("remote-path").value() == Some("/home/tester/目录")
})
.await;
}
#[gpui_kit::test]
async fn f4_and_enter_open_a_file_too(cx: &mut TestAppContext) {
let (handle, workspace, _) = open_server(
cx,
&[(REMOTE_FILE, b"one\n"), (OTHER_REMOTE_FILE, b"two\n")],
)
.await;
press_on_row(cx, handle, "remote-pane", "文件 甲.txt", "f4");
wait_for_editor(cx, handle, FIRST).await;
assert_eq!(text_of(&workspace, FIRST, cx), "one\n");
// Back to the list, and Enter on the other file.
in_frame(cx, handle, |window, cx| {
window.click(("explorer-tab", SFTP_TAB), cx)
});
press_on_row(cx, handle, "remote-pane", "文件 乙.txt", "enter");
wait_for_editor(cx, handle, EditorId(2)).await;
assert_eq!(text_of(&workspace, EditorId(2), cx), "two\n");
}
#[gpui_kit::test]
async fn saving_writes_the_file_back_and_clears_the_mark(cx: &mut TestAppContext) {
let (handle, workspace, provider) = open_server(cx, &[(REMOTE_FILE, b"hello world\n")]).await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
type_text(cx, handle, "# ");
in_frame(cx, handle, |window, _| {
assert!(
window
.within(("editor-tab", FIRST.0))
.try_find("modified")
.is_some()
);
assert_eq!(
window.find(("editor-state", FIRST.0)).label(),
Some("已修改")
);
assert_eq!(
window.find("status-editor-cursor").label(),
Some("行 1,列 3")
);
});
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
wait_for_writes(cx, &provider, 1).await;
cx.run_until_parked();
let (path, bytes, expected) = provider.writes.lock().unwrap()[0].clone();
assert_eq!(path, REMOTE_FILE);
assert_eq!(bytes, b"# hello world\n");
// Only over the file as it was read.
assert_eq!(expected, Some(fake_stamp(b"hello world\n", 100)));
assert_eq!(provider.file(REMOTE_FILE), b"# hello world\n");
in_frame(cx, handle, |window, _| {
assert!(
window
.within(("editor-tab", FIRST.0))
.try_find("modified")
.is_none()
);
assert!(window.try_find(("editor-state", FIRST.0)).is_none());
});
// Undoing back to the saved text is no change either; the next save
// expects the file as saved.
type_text(cx, handle, "x");
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
wait_for_writes(cx, &provider, 2).await;
let expected = provider.writes.lock().unwrap()[1].2;
assert_eq!(expected, Some(fake_stamp(b"# hello world\n", 101)));
assert_eq!(text_of(&workspace, FIRST, cx), "# xhello world\n");
}
#[gpui_kit::test]
async fn crlf_and_a_byte_order_mark_are_written_back_as_they_were(cx: &mut TestAppContext) {
let (handle, workspace, provider) =
open_server(cx, &[(REMOTE_FILE, b"\xEF\xBB\xBFa=1\r\nb=2\r\n")]).await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
assert_eq!(text_of(&workspace, FIRST, cx), "a=1\nb=2\n");
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find("status-editor-format").label(),
Some("UTF-8 BOM · CRLF")
);
});
type_text(cx, handle, "c=3\n");
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
wait_for_writes(cx, &provider, 1).await;
assert_eq!(
provider.writes.lock().unwrap()[0].1,
b"\xEF\xBB\xBFc=3\r\na=1\r\nb=2\r\n"
);
}
#[gpui_kit::test]
async fn a_file_changed_on_the_server_is_overwritten_only_when_asked(cx: &mut TestAppContext) {
let (handle, workspace, provider) = open_server(cx, &[(REMOTE_FILE, b"mine\n")]).await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
type_text(cx, handle, "1");
provider.change_file(REMOTE_FILE, b"theirs\n");
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
wait_for(cx, handle, "ok").await;
// Not written: the file is theirs, and the editor keeps its changes.
assert_eq!(provider.file(REMOTE_FILE), b"theirs\n");
in_frame(cx, handle, |window, cx| window.click("cancel", cx));
in_frame(cx, handle, |window, _| {
assert!(
window
.within(("editor-tab", FIRST.0))
.try_find("modified")
.is_some()
);
});
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
wait_for(cx, handle, "ok").await;
in_frame(cx, handle, |window, cx| window.click("ok", cx));
wait_for_writes(cx, &provider, 3).await;
cx.run_until_parked();
let writes = provider.writes.lock().unwrap().clone();
assert_eq!(writes[2].2, None, "覆盖 writes without the check");
assert_eq!(provider.file(REMOTE_FILE), b"1mine\n");
assert_eq!(text_of(&workspace, FIRST, cx), "1mine\n");
}
#[gpui_kit::test]
async fn closing_an_edited_file_asks_whether_to_save_it(cx: &mut TestAppContext) {
let (handle, workspace, provider) = open_server(cx, &[(REMOTE_FILE, b"text\n")]).await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
type_text(cx, handle, "1");
// 取消 keeps the tab and the changes.
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(CloseEditor(FIRST)), cx)
});
wait_for(cx, handle, "editor-close-save").await;
in_frame(cx, handle, |window, cx| {
window.click("editor-close-cancel", cx)
});
assert!(is_open(&workspace, FIRST, cx));
// ⌘W asks the same; 放弃修改 closes without writing.
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(CloseActiveTab), cx)
});
wait_for(cx, handle, "editor-close-discard").await;
in_frame(cx, handle, |window, cx| {
window.click("editor-close-discard", cx)
});
assert!(!is_open(&workspace, FIRST, cx));
assert!(provider.writes.lock().unwrap().is_empty());
// 保存 writes, then closes. (Closing the editor showed the SFTP tab.)
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, EditorId(2)).await;
type_text(cx, handle, "2");
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(CloseEditor(EditorId(2))), cx)
});
wait_for(cx, handle, "editor-close-save").await;
in_frame(cx, handle, |window, cx| {
window.click("editor-close-save", cx)
});
wait_for_writes(cx, &provider, 1).await;
cx.run_until_parked();
assert_eq!(provider.file(REMOTE_FILE), b"2text\n");
assert!(!is_open(&workspace, EditorId(2), cx));
// Without changes nothing is asked.
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, EditorId(3)).await;
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(CloseEditor(EditorId(3))), cx)
});
assert!(!is_open(&workspace, EditorId(3), cx));
}
#[gpui_kit::test]
async fn a_binary_or_too_large_file_is_explained_not_opened(cx: &mut TestAppContext) {
let large = vec![b'a'; EDIT_LIMIT as usize + 1];
let (handle, workspace, _) = open_server(
cx,
&[
(REMOTE_FILE, b"PK\x03\x04\x00\x00"),
(OTHER_REMOTE_FILE, &large),
],
)
.await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for(cx, handle, "ok").await;
assert!(!is_open(&workspace, FIRST, cx));
// 下载… is the way out for a remote file.
in_frame(cx, handle, |window, cx| window.click("ok", cx));
wait_for(cx, handle, "download-confirm").await;
in_frame(cx, handle, |window, cx| window.press("escape", cx));
open_file(cx, handle, "remote-pane", "文件 乙.txt");
wait_for(cx, handle, "ok").await;
assert!(!is_open(&workspace, FIRST, cx));
}
#[gpui_kit::test]
async fn a_local_file_is_edited_on_this_machine(cx: &mut TestAppContext) {
let local = FakeLocalDirectory::default();
local
.files
.lock()
.unwrap()
.insert(LOCAL_FILE.into(), b"local\n".to_vec());
let provider = Arc::new(FakeSftpProvider::default());
let (handle, workspace) = open_workspace_with_services(cx, provider.clone(), local.clone());
open_test_explorer(cx, handle).await;
open_file(cx, handle, "local-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
assert_eq!(text_of(&workspace, FIRST, cx), "local\n");
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find(("editor-path", FIRST.0)).label(),
Some(LOCAL_FILE)
);
assert_eq!(window.find("status-connection").label(), Some("本机文件"));
});
type_text(cx, handle, "edited ");
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find(("editor-state", FIRST.0)).is_none()
})
.await;
assert_eq!(
local.files.lock().unwrap()[std::path::Path::new(LOCAL_FILE)],
b"edited local\n"
);
assert!(
local
.calls
.lock()
.unwrap()
.contains(&format!("write {LOCAL_FILE}"))
);
// Nothing went to the server.
assert!(provider.writes.lock().unwrap().is_empty());
}
#[gpui_kit::test]
async fn closing_the_sftp_tab_asks_once_and_takes_its_files_along(cx: &mut TestAppContext) {
let (handle, workspace, provider) = open_server(cx, &[(REMOTE_FILE, b"text\n")]).await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
type_text(cx, handle, "1");
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), cx)
});
wait_for(cx, handle, "ok").await;
in_frame(cx, handle, |window, cx| window.click("ok", cx));
cx.run_until_parked();
cx.update(|cx| assert!(workspace.read(cx).explorer(ExplorerId(SFTP_TAB)).is_none()));
assert!(!is_open(&workspace, FIRST, cx));
assert!(provider.writes.lock().unwrap().is_empty());
}
#[gpui_kit::test]
async fn saving_while_disconnected_offers_to_reconnect(cx: &mut TestAppContext) {
let (handle, workspace, provider) = open_server(cx, &[(REMOTE_FILE, b"text\n")]).await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
type_text(cx, handle, "1");
let events = provider.events.lock().unwrap()[0].clone();
events
.send_blocking(SftpEvent::Disconnected("连接中断".into()))
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window
.find("status-connection")
.label()
.is_some_and(|text| text.starts_with("未连接"))
})
.await;
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
wait_for(cx, handle, "ok").await;
assert!(provider.writes.lock().unwrap().is_empty());
assert!(cx.update(|cx| {
workspace
.read(cx)
.editor(FIRST)
.unwrap()
.read(cx)
.is_dirty()
}));
}
#[gpui_kit::test]
async fn a_new_file_opens_in_the_editor(cx: &mut TestAppContext) {
let (handle, workspace, _) = open_server(cx, &[]).await;
in_frame(cx, handle, |window, cx| {
window.dispatch_action(
Box::new(ExplorerAction::new(
ExplorerId(SFTP_TAB),
ExplorerCommand::Operate {
remote: true,
operation: PaneOperation::Create {
kind: NewEntryKind::File,
name: "新.conf".into(),
},
},
)),
cx,
)
});
wait_for_editor(cx, handle, FIRST).await;
assert_eq!(text_of(&workspace, FIRST, cx), "");
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find(("editor-tab", FIRST.0)).label(),
Some("新.conf")
);
});
}
#[gpui_kit::test]
async fn quitting_with_unsaved_changes_asks_first(cx: &mut TestAppContext) {
let (handle, workspace, _) = open_server(cx, &[(REMOTE_FILE, b"text\n")]).await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
// Nothing unsaved: nothing asked.
cx.update(|cx| cx.dispatch_action(&Quit));
cx.run_until_parked();
in_frame(cx, handle, |window, _| {
assert!(window.try_find("ok").is_none())
});
type_text(cx, handle, "1");
// From the app, with no window in front, as ⌘Q may arrive after a
// dialog left nothing focused.
cx.update(|cx| cx.dispatch_action(&Quit));
wait_for(cx, handle, "ok").await;
in_frame(cx, handle, |window, cx| window.click("cancel", cx));
assert!(is_open(&workspace, FIRST, cx));
}
#[gpui_kit::test]
async fn closing_all_tabs_asks_once_for_every_unsaved_file(cx: &mut TestAppContext) {
let (handle, workspace, provider) = open_server(
cx,
&[(REMOTE_FILE, b"one\n"), (OTHER_REMOTE_FILE, b"two\n")],
)
.await;
open_file(cx, handle, "remote-pane", "文件 甲.txt");
wait_for_editor(cx, handle, FIRST).await;
type_text(cx, handle, "1");
in_frame(cx, handle, |window, cx| {
window.click(("explorer-tab", SFTP_TAB), cx)
});
open_file(cx, handle, "remote-pane", "文件 乙.txt");
wait_for_editor(cx, handle, EditorId(2)).await;
type_text(cx, handle, "2");
in_frame(cx, handle, |window, cx| {
window.dispatch_action(
Box::new(CloseTabs {
tab: CenterTab::Editor(EditorId(2)),
scope: CloseScope::All,
}),
cx,
)
});
wait_for(cx, handle, "ok").await;
// One question, not one per file.
in_frame(cx, handle, |window, _| {
assert!(window.try_find("editor-close-save").is_none())
});
in_frame(cx, handle, |window, cx| window.click("ok", cx));
cx.run_until_parked();
assert!(!is_open(&workspace, FIRST, cx));
assert!(!is_open(&workspace, EditorId(2), cx));
assert!(provider.writes.lock().unwrap().is_empty());
}
+1
View File
@@ -10,6 +10,7 @@ mod support;
mod connection;
mod credential;
mod docker;
mod editor;
mod forward;
mod history;
mod host_dialog;
+3 -2
View File
@@ -37,8 +37,9 @@ pub use shellrs::host::{
pub use shellrs::secrets::{InMemorySecretStore, SecretRef, SecretStore as _};
pub use shellrs::settings::{Appearance, InterfaceLanguage, SettingsStore};
pub use shellrs::sftp::{
DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath,
SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadRequest,
DirectoryEntry, DirectoryListing, EDIT_LIMIT, EntryKind, FileMetadata, FileStamp,
LocalDirectoryProvider, ReadFailure, RemotePath, SaveFailure, SftpCommand, SftpEvent,
SftpTransport, SftpTransportProvider, TextFile, UploadRequest,
};
pub use shellrs::terminal::{
FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId,
+133
View File
@@ -2,6 +2,11 @@
use super::*;
/// A fake server's files for the editor: bytes and modification time.
pub type FakeFiles = Arc<Mutex<std::collections::BTreeMap<String, (Vec<u8>, u32)>>>;
/// The editor's writes: path, bytes and the stamp the file had to have.
pub type FakeWrites = Arc<Mutex<Vec<(String, Vec<u8>, Option<FileStamp>)>>>;
#[derive(Default)]
pub struct FakeSftpProvider {
pub requests: Arc<Mutex<Vec<UploadRequest>>>,
@@ -15,6 +20,43 @@ pub struct FakeSftpProvider {
/// The remote home is `/slow`, which never answers: a network too slow
/// for the first directory to arrive.
pub slow_home: bool,
/// The remote files the editor reads and writes: bytes and modification
/// time. Reads of a path under `/slow` never answer.
pub files: FakeFiles,
/// The editor's reads, by path.
pub reads: Arc<Mutex<Vec<String>>>,
/// The editor's writes: path, bytes and the stamp the file had to have.
pub writes: FakeWrites,
}
impl FakeSftpProvider {
/// A server with these files on it.
pub fn with_files(files: &[(&str, &[u8])]) -> Self {
let provider = Self::default();
provider.files.lock().unwrap().extend(
files
.iter()
.map(|(path, bytes)| (path.to_string(), (bytes.to_vec(), 100))),
);
provider
}
/// Someone else writes a file on the server.
pub fn change_file(&self, path: &str, bytes: &[u8]) {
let mut files = self.files.lock().unwrap();
let modified = files.get(path).map_or(100, |(_, modified)| modified + 7);
files.insert(path.into(), (bytes.to_vec(), modified));
}
pub fn file(&self, path: &str) -> Vec<u8> {
self.files.lock().unwrap()[path].0.clone()
}
}
/// What a fake remote file's stamp is.
pub fn fake_stamp(bytes: &[u8], modified: u32) -> FileStamp {
FileStamp::new(
bytes.len() as u64,
Some(std::time::UNIX_EPOCH + Duration::from_secs(u64::from(modified))),
)
}
impl SftpTransportProvider for FakeSftpProvider {
@@ -26,6 +68,9 @@ impl SftpTransportProvider for FakeSftpProvider {
events: self.events.clone(),
hold: self.hold_connection.lock().unwrap().take(),
reconnects: self.reconnects.clone(),
files: self.files.clone(),
reads: self.reads.clone(),
writes: self.writes.clone(),
home: if self.slow_home {
"/slow"
} else {
@@ -42,6 +87,9 @@ pub struct FakeSftpTransport {
pub events: Arc<Mutex<Vec<async_channel::Sender<SftpEvent>>>>,
pub hold: Option<mpsc::Receiver<()>>,
pub reconnects: Arc<Mutex<usize>>,
pub files: FakeFiles,
pub reads: Arc<Mutex<Vec<String>>>,
pub writes: FakeWrites,
pub home: &'static str,
}
@@ -80,6 +128,12 @@ impl SftpTransport for FakeSftpTransport {
operation,
} => {
let refused = format!("{operation:?}").contains("denied");
if let shellrs::sftp::RemoteOperation::CreateFile { path } = &operation {
self.files
.lock()
.unwrap()
.insert(path.to_string(), (Vec::new(), 100));
}
self.operations.lock().unwrap().push(operation);
events.send_blocking(SftpEvent::Operated {
request_id,
@@ -90,6 +144,46 @@ impl SftpTransport for FakeSftpTransport {
},
})?;
}
SftpCommand::ReadFile { request_id, path } => {
self.reads.lock().unwrap().push(path.to_string());
if path.as_str().starts_with("/slow") {
continue;
}
let result = match self.files.lock().unwrap().get(path.as_str()) {
Some((bytes, _)) if bytes.len() as u64 > EDIT_LIMIT => {
Err(ReadFailure::TooLarge(bytes.len() as u64))
}
Some((bytes, modified)) => {
TextFile::decode(bytes.clone(), fake_stamp(bytes, *modified))
}
None => Err(ReadFailure::Failed("文件不存在".into())),
};
events.send_blocking(SftpEvent::FileRead { request_id, result })?;
}
SftpCommand::WriteFile {
request_id,
path,
bytes,
expected,
} => {
self.writes
.lock()
.unwrap()
.push((path.to_string(), bytes.clone(), expected));
let mut files = self.files.lock().unwrap();
let now = files.get(path.as_str()).map(|(b, m)| fake_stamp(b, *m));
let result = if expected.is_some() && expected != now {
Err(SaveFailure::Changed)
} else {
// Every save is a second later.
let modified = files.get(path.as_str()).map_or(100, |(_, m)| m + 1);
let stamp = fake_stamp(&bytes, modified);
files.insert(path.to_string(), (bytes, modified));
Ok(stamp)
};
drop(files);
events.send_blocking(SftpEvent::FileWritten { request_id, result })?;
}
SftpCommand::Download(request) => {
self.downloads.lock().unwrap().push(request);
events.send_blocking(SftpEvent::Progress(
@@ -154,6 +248,8 @@ impl SftpTransport for FakeSftpTransport {
#[derive(Clone, Default)]
pub struct FakeLocalDirectory {
pub calls: Arc<Mutex<Vec<String>>>,
/// The files the editor reads and writes, in memory.
pub files: Arc<Mutex<std::collections::BTreeMap<std::path::PathBuf, Vec<u8>>>>,
}
impl FakeLocalDirectory {
@@ -180,8 +276,45 @@ impl LocalDirectoryProvider for FakeLocalDirectory {
self.record(format!("mkdir {}", path.display()))
}
fn create_file(&self, path: &std::path::Path) -> anyhow::Result<()> {
self.files.lock().unwrap().insert(path.into(), Vec::new());
self.record(format!("touch {}", path.display()))
}
fn read_file(
&self,
path: &std::path::Path,
limit: u64,
) -> anyhow::Result<(Vec<u8>, FileStamp)> {
let bytes = self
.files
.lock()
.unwrap()
.get(path)
.cloned()
.ok_or_else(|| anyhow::anyhow!("「{}」不存在", path.display()))?;
if bytes.len() as u64 > limit {
return Err(ReadFailure::TooLarge(bytes.len() as u64).into());
}
let stamp = FileStamp::new(bytes.len() as u64, None);
Ok((bytes, stamp))
}
fn write_file(
&self,
path: &std::path::Path,
bytes: &[u8],
expected: Option<FileStamp>,
) -> anyhow::Result<FileStamp> {
let mut files = self.files.lock().unwrap();
let now = files
.get(path)
.map(|bytes| FileStamp::new(bytes.len() as u64, None));
if expected.is_some() && expected != now {
return Err(SaveFailure::Changed.into());
}
files.insert(path.into(), bytes.to_vec());
drop(files);
self.record(format!("write {}", path.display()))?;
Ok(FileStamp::new(bytes.len() as u64, None))
}
fn set_permissions(
&self,
paths: &[std::path::PathBuf],