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:
1 parent
09dc45e7a2
commit
c679d9d8a0
41 files changed
+3955
-78
No files matched your search
@@ -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
@@ -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
@@ -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 }
|
||||
@@ -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**
|
||||
|
||||
@@ -62,6 +62,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
- 拖动或快捷键上传、下载,支持递归。传输进行中还能继续发起,新批次进传输队列依次执行。
|
||||
- 断点续传:先写 `.filepart`,断线后按 1、3、10 秒自动重连;重启后再传同样的文件,会询问是否续传。
|
||||
- 删除(本地移到废纸篓)、重命名、新建、修改权限(3×3 复选框加八进制,可递归)。
|
||||
- 内置编辑器:双击文本文件直接编辑,⌘S 原地写回服务器,保存前检查文件是否被别人改过;常见格式语法高亮。
|
||||
- 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。
|
||||
|
||||
**从堡垒机打开**
|
||||
|
||||
+15
-2
@@ -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
@@ -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)]
|
||||
|
||||
@@ -65,6 +65,8 @@ icon_assets!(
|
||||
Ban,
|
||||
CircleDot,
|
||||
FileText,
|
||||
FilePenLine,
|
||||
Save,
|
||||
Zap,
|
||||
ExternalLink
|
||||
]
|
||||
|
||||
+12
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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())),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(¤t) {
|
||||
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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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 个文件"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
mod credentials;
|
||||
mod dock_skin;
|
||||
mod editors;
|
||||
mod forwards;
|
||||
mod links;
|
||||
mod recent_hosts;
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod support;
|
||||
mod connection;
|
||||
mod credential;
|
||||
mod docker;
|
||||
mod editor;
|
||||
mod forward;
|
||||
mod history;
|
||||
mod host_dialog;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user