Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ccaa3036 | ||
|
|
3c38c664c2 | ||
|
|
a9e8d8dbef | ||
|
|
befdfc7b02 | ||
|
|
d53bb559e6 | ||
|
|
f1ac434ebf | ||
|
|
40589c9931 | ||
|
|
8e290562f8 | ||
|
|
c679d9d8a0 |
No files matched your search
@@ -2,6 +2,24 @@
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
|
||||
|
||||
## [0.1.3]
|
||||
|
||||
### 新增
|
||||
|
||||
- SFTP 内置编辑器:双击文本文件在编辑器标签里编辑,⌘S 直接保存回服务器,常见语言语法高亮。
|
||||
- SFTP 预览:双击图片快速查看,可放大、缩小、看原图;Markdown 和 SVG 右键「预览」。
|
||||
- SFTP「显示隐藏文件」开关,本地、远程各自独立。
|
||||
- SFTP 右键「复制路径」。
|
||||
- 终端里的网址显示为链接,⌘ 单击(其他平台 Ctrl 单击)用浏览器打开。
|
||||
- 终端支持鼠标:vim、htop、tmux 里可以点击和滚动。
|
||||
- 远程程序(如 tmux、vim)复制的内容直接进本机剪贴板。
|
||||
- 终端通知:程序请求通知或响铃时,ShellRS 不在前台会发系统通知。
|
||||
- 外部 CLI 新增 `shellrs exec --json`,Windows PowerShell 下不再有引号和乱码问题。
|
||||
|
||||
### 改进
|
||||
|
||||
- SFTP 目录的右键菜单去掉了多余的「打开」,双击即可进入。
|
||||
|
||||
## [0.1.2]
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -4,16 +4,63 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 项目是什么
|
||||
|
||||
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
|
||||
ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。功能说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
|
||||
|
||||
**保存的一项叫「主机」,代码里叫 `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/`)。
|
||||
|
||||
已确认的产品决定: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
|
||||
- **术语。** 保存的一项叫「主机」(量词「台」),代码和库里叫 host(`Host`、`HostId`、`HostStore`、`hosts` 表)。它以前叫「会话」/ `Session`,已全部改掉,新代码和文案别再用。填 IP / 域名的字段叫「地址」(`Host.address`)。SSH 协议层的 session(「SSH 会话通道」、russh 的 `Session`、SFTP 会话)照旧。
|
||||
- **两种不保存的连接。** 「外部连接」:堡垒机像调 Xshell(`ssh://`、`-url`)或 WinSCP(`sftp://`)一样拉起 ShellRS,只开一个终端或 SFTP 标签(`HostStore::is_external`)。「临时连接」:标题栏「临时连接…」打开,功能齐全。两者共同的「不保存」在代码里叫 temporary(`HostInfo::temporary`)。
|
||||
- **全是真的。** SQLite(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)、本地终端(`portable-pty` + `alacritty_terminal`)、SSH(`russh`)、SFTP(双栏、传输队列、断点续传)、端口转发(`-L` / `-R` / `-D`)、凭据、连接方式(直连、多级跳板、HTTP / SOCKS5 代理)、右侧栏七个工具、内置编辑器和预览、外部 CLI、在线升级。密码和口令只进系统钥匙串(`keyring`),**数据库里永远不出现秘密**。
|
||||
|
||||
做 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` 为准。
|
||||
|
||||
## 已定的产品决定
|
||||
|
||||
用户定过的事,不要改回去。细节以 `docs/manual.md` 和代码为准。
|
||||
|
||||
- **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。暂不做目录同步、过滤、查找、目录树和 Dock 布局持久化。
|
||||
- **主机树**:首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
|
||||
- **主机对话框**:
|
||||
- 认证方式三选一:「密码」(留空就每次连接时询问,不悄悄试密钥)、「使用凭据」、「无密码」(依次试服务器免认证、SSH Agent、`~/.ssh` 默认私钥,服务器要密码就报错、不询问)。主机自己没有私钥文件,要用私钥就建密钥凭据。
|
||||
- 「测试连接」是真登录,不强制填密码。
|
||||
- 连接方式三选一:直接连接、SSH 跳板、代理连接。跳板只走列出的链(跳板自己的连接方式不参与),不做拖动排序;删掉的跳板留「已删除的主机」占位,连接时直接报错。代理是 HTTP `CONNECT` 或 SOCKS5,失败直接报错、不弹问答。跳板和代理不叠加。
|
||||
- **凭据**:侧栏第三个列表(密码 / 密钥 / SSH Agent),凭据带用户名。
|
||||
- 密钥的来源「本机文件 / 粘贴 / 生成新密钥」在同一个对话框里(Ed25519 默认,另有 RSA 4096)。粘贴和生成的私钥存成数据目录 `keys/` 下的文件,不进钥匙串:Windows 凭据管理器装不下 RSA 私钥。
|
||||
- 删除仍在用的凭据要确认,那些主机改回自己登录(密码凭据的改成「密码」,其余改成「无密码」)。
|
||||
- 主机对话框里不做「新建凭据…」(不叠弹层)。
|
||||
- **端口转发**:侧栏第二个列表,不是 Dock 标签;标题栏「ShellRS」后面三个图标切换主机 / 端口转发 / 凭据。
|
||||
- 一条规则经由一台保存的主机,自己建连接:不算主机的连接状态,主机「断开连接」不停它;删除主机连同规则一起删。
|
||||
- 「创建」只保存不启动,可设「启动 ShellRS 时自动开启」;新建时「经由主机」留空,由用户自己选。
|
||||
- 断线按 1、3、10 秒自动重连三次,重连不弹问答。
|
||||
- 对话框是一行三张类型卡片、实时示意图(服务器按角色叫「SSH 服务器」)、一句话说明和一直循环的流向动画。
|
||||
- **临时连接与外部连接**:
|
||||
- 临时连接的密码只在内存。
|
||||
- 外部连接只认命令行参数,不注册系统的 `ssh://` 链接;打开时收起左侧栏。
|
||||
- 外部 CLI 照样列出这两种主机,分组写「(未保存)」。
|
||||
- **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。
|
||||
- **右侧栏**:窗口最右一列竖排的工具切换。
|
||||
- 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。
|
||||
- 工具从上到下:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。
|
||||
- 已知不是 Linux 的主机不显示系统监控、网络连接、进程管理、系统服务;已知是 Windows 的再去掉 Docker 和历史命令;外部连接只有命令片段。
|
||||
- 系统监控每 2 秒读一次(运行时长随读数刷新,不自己走),磁盘每 30 秒,只在显示时读。≥ 70% 黄、≥ 90% 红。
|
||||
- 网络连接、系统服务、Docker、历史命令的列表不自己刷新:打开、换终端、点刷新、执行命令之后才读。进程管理每 15 秒读一次。
|
||||
- 结束进程,停止 / 重启服务,停止 / 重启 / 删除容器都先确认。不是 root 时用 `sudo -n`,要密码就报「需要 root 权限…」。
|
||||
- 系统服务的标签用 gpui-kit 的下划线 `TabBar`(均分放不下「已停止 202」),Docker 用均分的 `shared::count_tabs`。
|
||||
- 历史命令读主机的 `~/.bash_history`,不自己记录;点一条是放进输入行、不执行,另有执行按钮。
|
||||
- 命令片段所有主机共用(库里预留了「适用范围」列);「点击时自动执行」新建时默认勾上;执行按钮鼠标移上去才出现,位置一直留着。
|
||||
- **在线升级**:
|
||||
- 清单地址 `https://dl.shellrs.com/update/v1/{channel}.json`、平台 key 的名字和公钥槽位一经发布就不能改。
|
||||
- 默认在后台检查并下载,下好、校验完才在标题栏亮按钮(`success` 色,出错时 `warning` 色)。
|
||||
- 更新对话框不列更新内容:「查看更新内容」打开整页的 `https://shellrs.com/changelog`,另一个按钮是「重启并安装」,没有「稍后」。不点也在退出时装好。
|
||||
- 「设置 › 关于」只有「应用更新」一组三行:当前版本、更新渠道(稳定版 / Beta,运行时切换)、自动升级。
|
||||
- 检查只发 User-Agent,不发安装标识。
|
||||
- **编辑器**:中间区的「编辑器」Dock 标签,不用系统程序打开。
|
||||
- 双击、回车、F4、右键「编辑」都打开它,本地文件也行;新建文件后直接打开。
|
||||
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
|
||||
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
|
||||
- **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
|
||||
- **预览**:图片和 Markdown(不做 HTML),显示在快速查看式的大对话框里,不是标签。
|
||||
- 双击图片预览;Markdown 和 SVG 是文本,双击编辑,右键另有「预览」。
|
||||
- 图片默认适合窗口居中,可放大、缩小、看原图、滚动;比例只写百分比。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
@@ -33,179 +80,196 @@ security find-generic-password -s shellrs -a "password:<user>@<host>:<port>" #
|
||||
cargo test -- --ignored # 会读写真实钥匙串的测试,默认跳过
|
||||
```
|
||||
|
||||
工具链:`rust-toolchain.toml` 固定 Rust 1.98.1,因为 `gpui-pre` 用到了 1.94 尚未稳定的 std API。`Cargo.lock` 把 `gpui-pre*` 系列固定在 0.3.2(gpui-kit 0.6.1 发布时配套的版本),不要随手 `cargo update` 它们,除非确认仍能编译。`cargo test` 需要 gpui-kit 的 `test-support` 特性,首次会再编译一遍(同样慢)。
|
||||
工具链:`rust-toolchain.toml` 固定 Rust 1.98.1,因为 `gpui-pre` 用到了 1.94 尚未稳定的 std API。`Cargo.lock` 把 `gpui-pre*` 系列固定在 0.3.2(gpui-kit 0.6.1 发布时配套的版本),不要随手 `cargo update` 它们。`cc` 停在 1.2.x,因为 SQL 高亮的 `tree-sitter-sequel` 要求 `cc ~1.2`。`cargo test` 需要 gpui-kit 的 `test-support` 特性,首次会再编译一遍。
|
||||
|
||||
本终端的 `screencapture` 被 macOS 权限拦截,视觉检查只能靠 UI 集成测试或让用户看窗口。
|
||||
|
||||
## 架构
|
||||
|
||||
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
|
||||
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分:功能模块不依赖 `workspace`(窗口壳),也不碰彼此的内部实现。
|
||||
|
||||
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
|
||||
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`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 写锁。
|
||||
- `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_*` 把错误映射成中文。
|
||||
- `socks.rs`:SOCKS5(无认证、`CONNECT`)与 SOCKS4 / 4a 的纯函数解析(`Parsed::{Incomplete, Done, Invalid}`)加一个喂字节的 `negotiate`。没有引入 SOCKS 依赖。
|
||||
- `manager.rs`:`ForwardManager` 实体,持有每条在跑的规则的工作线程和状态 `ForwardStatus`,每 50 ms 轮询事件(不从后台线程唤醒 GPUI);自己订阅 store:规则被删(含随主机级联)就停,`ForwardSettingsChanged` / `ConnectionSettingsChanged` 就重启在跑的。
|
||||
- `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。
|
||||
- `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。
|
||||
- `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。
|
||||
- `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`、`ExecTarget`),不依赖 `workspace`。
|
||||
- `snippets/` — 命令片段,右侧栏的第七个真工具,片段本身在 `HostStore` 里(`host::Snippet`),这里是列表和对话框:`model.rs`(`rows`:分类标题和片段排成列表的行,纯函数)、`snippet_panel.rs`(`SnippetPanel`:搜索框、gpui `list` 画的分类标题和卡片、挂在列表上的右键菜单,菜单在打开时读 store)、`snippet_dialog.rs`(`open_snippet_dialog(SnippetDialog::{New(分类), Edit(id)}, ..)` 和 `open_category_dialog(CategoryDialog::{New, Rename(id)}, ..)`)。依赖 `host` 和 `shared`,不依赖 `terminal`(输入到终端是工作区处理 `EnterCommand`)。
|
||||
- `history/` — 历史命令,右侧栏的第六个真工具:`bash.rs`(读 `~/.bash_history` 的命令 `command()` 和解析 `parse`)、`model.rs`(`History::new` 去重、按最近排、计次数,`matching` 搜索,`format_when` 写「3 分钟前」,都是纯函数)、`history_panel.rs`(`HistoryPanel`:读取、搜索框、`uniform_list` 画的卡片和挂在列表上的右键菜单)。依赖 `terminal`(读取用 `TerminalView::exec`,把命令放进输入行是工作区经 `TerminalView::enter_command` 做的),不依赖 `workspace`。
|
||||
- `docker/` — Docker,右侧栏的第五个真工具:`linux.rs`(读数的命令 `command()` 和解析 `parse`(每行一个 JSON,`serde_json`),`valid_id`,容器命令 `control_command`、删除的 `remove_command`、详情的 `inspect_command` / `parse_details`、日志的 `logs_command`,结果的 `done`)、`model.rs`(`Container` / `Volume` / `Image` / `Network`,`DockerTable::new` 按项目分组、标出哪些卷 / 镜像 / 网络有容器在用,`rows` 按标签页和折叠排出行,都是纯函数)、`docker_panel.rs`(`DockerPanel`:读取、`count_tabs`、gpui `list` 画的项目卡片、容器行和卷 / 镜像 / 网络卡片)、`container_details.rs`(`open_container_dialog`:详情对话框,正文 `ContainerDetailsView` 打开时 `docker inspect`、切到日志页时读日志)、`object_details.rs`(`open_object_dialog`:卷、镜像、网络的详情对话框,正文 `ObjectDetailsView`)、`details.rs`(两种对话框共用的 `Read`、在终端连接上执行的 `run`,以及把 `DetailSection` 画成标题加内容的 `render_sections`)。卷、镜像、网络的卡片和详情都从 `ObjectSummary`(`Volume::summary` 等:名字、一行说明、标签及其 `Tone`、能不能删、被哪些容器用着)画,`DockerTable::new` 记下的是用着它们的容器名(`used_by`),不只是有没有。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区。
|
||||
- `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、下划线 `TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。
|
||||
- `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` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
||||
- `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 往管道里写的就是这样)。
|
||||
- `protocol.rs`:套接字上的帧:1 字节类型(`J` JSON / `1` stdout / `2` stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;`ErrorCode` 是给 Agent 判断用的稳定英文码。`Request::Activate` 不属于 `shellrs` 命令,是 ShellRS 被再次打开时发的(见下面「一个数据目录只跑一个 ShellRS」)。
|
||||
- `client.rs`:命令这一端,连 `app::cli_socket_path()`(即 `app::cli_endpoint(data_dir)`:Unix 上是 `<data_dir>/cli.sock`,Windows 上是 `\\.\pipe\shellrs-cli-<小写的数据目录绝对路径的 sha256 前 16 位>`,所以 `SHELLRS_DATA_DIR` 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。`connect` 按平台实现,读帧循环 `talk` 共用。进度行用 `\r` 加补空格覆盖,不用 `\x1b[2K`(老 conhost 不认)。
|
||||
- `pipe_windows.rs`:Windows 命名管道的 Win32 调用(`windows-sys`):带 DACL 的实例、`ConnectNamedPipe`、客户端的打开与属主校验。
|
||||
- `server.rs`:`CliServer` 与可注入的 `CliBackend` trait;`backend.rs` 是真实实现 `SshCliBackend`(exec 走 `ssh::run_command`,传输走 `sftp_provider`)。
|
||||
- `install.rs`:把 `shellrs` 放进 PATH(macOS / Linux 是符号链接)、Agent skill 的安装 / 更新 / 移除,纯文件系统操作,路径全来自 `IntegrationPaths`;`SKILL.md` 是 skill 正文(`include_str!`)。`install_windows.rs`:Windows 的做法(复制 + 用户 PATH),`cfg(any(windows, test))`,所以在 Mac 上也跑它的测试。`integration.rs`:设置页用的状态实体 `CliIntegration`。
|
||||
- `update/` — 在线升级,不依赖 `workspace` 和 `settings`(只用 `app` 的路径和动作)。
|
||||
- `build_info.rs`:编译期由发布流程注入的 `SHELLRS_UPDATE_CHANNEL`(stable / beta;没有就是开发构建,从不检查;有的话只决定「更新渠道」的默认值,实际查哪个渠道由设置决定,`UpdateServices::release_build`)、`SHELLRS_COMMIT`、`SHELLRS_BUILD_DATE`、`SHELLRS_APPLE_TEAM_ID`,清单地址 `MANIFEST_URL`,以及发布公钥 `TRUSTED_KEYS`(两个槽位:日常用的和离线备用的;为空时 release workflow 拒绝发布)。
|
||||
- `manifest.rs`(纯函数):清单是一个**信封** `{manifest: <清单原文>, signature: <minisign 签名>}`,一个文件、一次请求,不会出现清单和签名缓存不同步;`Manifest::offer(当前版本, 平台 key, 本机抽签)` 给出 `Offer::{UpToDate, Update(Release), Manual}`。格式只加字段:不认识的字段忽略,`schema` 比 `SCHEMA` 大的清单只读出版本、让用户去下载页。平台 key 是 `macos-aarch64` / `macos-x86_64` / `windows-x86_64` / `linux-x86_64`(`platform.rs`),macOS 两个 key 指向同一个 universal 包。
|
||||
- `verify.rs`:`open_envelope` 先验签再解析,签名的 trusted comment 必须是 `shellrs-manifest <通道> <版本>` 且和清单一致(防拿旧清单冒充新版本、拿 beta 清单冒充 stable);安装包靠清单里的大小和 SHA-256(`verify_file`)。debug 构建额外信任 `SHELLRS_UPDATE_PUBLIC_KEY`,release 构建只信编进去的。
|
||||
- `feed.rs`:`UpdateFeed` trait(阻塞,只搬字节,所以假 feed 绕不过验签)与真实的 `HttpFeed`(reqwest blocking,默认特性里的 rustls + 系统代理:从 Finder / 开始菜单启动的进程没有 `HTTPS_PROXY`);下载写 `.part`、用 Range 续传、`urls` 依次回退,`timeout` 在阻塞客户端里是每次读的超时,所以等于停滞判断;超时被包成「解码响应体出错」,`read_error` 从内层的 `reqwest::Error` 认出来。debug 构建可用 `SHELLRS_UPDATE_URL` 指向本地服务器。客户端在工作线程上按任务新建(阻塞客户端自带 runtime,不能在 async 上下文里建或丢)。
|
||||
- `install.rs`:纯函数 `detect` 决定 `InstallKind`(`MacBundle` / `WindowsSetup` / `AppImage` / `Unsupported(..)`:开发构建、App Translocation、DMG 里运行、没写权限、不是安装程序装的、不是 AppImage);`Installer` trait 的 `stage` 在工作线程上做慢的部分,`apply` 在 UI 线程上只改名或准备一个进程,返回 `Relaunch::{Restart(path), Hand(Launch), Nothing}`。平台实现 `install_macos.rs`(`ditto` 解到 app 旁边同一卷上的 `.ShellRS.app.update-<版本>`,`codesign` 核对 Team ID,两次改名交换,第二步失败就换回去)、`install_windows.rs`(隐藏的 PowerShell 等 ShellRS 退出后静默运行新版本的安装程序,再按需启动 ShellRS)、`install_linux.rs`(复制到 `$APPIMAGE` 旁边,一次改名覆盖;重启用 `$APPIMAGE`,current_exe 是会消失的挂载点)都在 `cfg(test)` 下也编译,Mac 上能跑它们的测试;外部命令在 `MacTools` 之类的接缝后面。
|
||||
- `updater.rs`:`Updater` 实体,阶段 `Phase::{Off, Idle, Checking, UpToDate, Available, Downloading, Verifying, Ready, Installing, Failed{stage, error}}`。每个任务一个 `shellrs-update` 线程,只在有任务时每 100 ms 轮询事件(同端口转发),新任务取消旧任务、丢掉旧任务的事件。`start()`(只在生产路径调用):报告上一次更新(读 `applied.json`)、30 秒后清理并第一次检查、之后每 6 小时(带抖动)、`on_app_quit` 时把 Ready 的版本装好。下载进度只 `notify`(设置页和对话框观察 `Updater`),阶段变化才发 `UpdaterEvent::Changed`,工作区只订阅它来重画标题栏。
|
||||
- `status.rs`(纯函数):设置页的状态行、标题栏按钮 `UpdateBadge`、哪些按钮可用、设置页和对话框共用的下一步 `UpdateStep`(`UpdateSnapshot::step`),以及 `restart_note(RestartImpact)`。`update_dialog.rs`:更新对话框。
|
||||
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、右侧栏工具共用的 `format_bytes` / `format_percent` / `format_duration`(「32 天 23:40:26」)和带状态的淡色标签 `soft_tag`、浅色按钮 `tinted`、`HostMark`、远程终端和 SFTP 标签栏右侧的延迟 `LatencyLabel`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、校验错误的通知 `form_error_notification` 与收起它的 `dismiss_form_error`(见下面「表单的校验错误一律弹错误通知」)、端口字段的 `parse_port`、删除确认 `confirm_delete` 和其他危险操作的确认 `confirm_danger`,侧栏三个列表的行 tooltip `RowTooltips` / `RowTooltip`(见下面「侧栏三个列表的行 tooltip」),以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。
|
||||
- `app/`:
|
||||
- `actions.rs` 是全部用户命令;带 id 的动作由 `id_actions!` 声明。
|
||||
- 另有快捷键、数据目录路径(`paths.rs`)、额外的 Lucide 图标(`CatalogIcon`)、操作系统 logo 和品牌图标(单色 SVG)、退出守卫(`quit.rs`)、macOS 关窗口即隐藏(`window_hiding.rs`)。
|
||||
- `host/`:主机、分组、凭据、端口转发规则和命令片段的模型(它们同库、随主机级联删除,所以都放在这里,host 不依赖其他功能模块),以及:
|
||||
- `HostStore`:内存是唯一事实来源,写穿到 SQLite;
|
||||
- `database.rs`:`SCHEMA`、迁移;
|
||||
- `login.rs`:登录快照 `HostLogin`;
|
||||
- 主机树面板、主机和分组对话框、快速连接、`ssh_link.rs`(解析堡垒机链接)。
|
||||
- `connection.rs`:问答协议(`ConnectionPrompt`…)、`Latency`、测试连接的 `LoginTest` / `ConnectionTester`。
|
||||
- `ssh/`:
|
||||
- `SshConnector`:所有登录都走它(终端、SFTP、exec、测试连接、端口转发、外部 CLI)。
|
||||
- 远程终端的传输:pty、系统探测、延迟、旁路 exec。
|
||||
- 代理握手;外部 CLI 用的 `run_command`。
|
||||
- `terminal/`:本地 PTY、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。
|
||||
- `sftp/`:可注入的传输和本地目录接口、`RemotePath`、`RemoteFs` 协议适配、worker(批次、三次重连、空闲断网检测),以及:
|
||||
- `upload.rs` / `download.rs`:断点续传,续传记录由 `journal.rs` 管;
|
||||
- `operations.rs`:删除、重命名、新建、改权限;
|
||||
- `edit.rs`:编辑器和预览的整文件读写。
|
||||
- `explorer/`:SFTP 标签的界面:两侧面板、列表、路径标签、各种对话框、传输队列,以及:
|
||||
- `file_edit.rs`:给编辑器和预览读写文件;
|
||||
- `preview.rs` / `image_preview.rs`:预览对话框和可缩放的图片。
|
||||
- `editor/`:编辑器标签(`EditorPanel`),语言、缩进和状态栏文字是 `model.rs` 的纯函数。
|
||||
- `forward/`:端口转发的运行(`ForwardManager`、每条规则一个工作线程、SOCKS 解析)和界面。`credential/`:凭据的界面。
|
||||
- `monitor/`、`netstat/`、`processes/`、`services/`、`docker/`、`history/`、`snippets/`:右侧栏的七个工具。各自是「命令(`linux.rs` 等)+ 纯解析和模型(`model.rs`)+ 面板」,确认和通知在工作区。
|
||||
- `secrets/`:`SecretStore`(钥匙串)、`SecretRef`(条目归属)、`TemporarySecretStore`(不保存的连接的密码只在内存)。
|
||||
- `settings/`:`AppSettings`、写 `settings.json` 的 `SettingsStore`、把设置落到窗口上的 `apply`、设置标签。
|
||||
- `cli/`:外部 CLI(`shellrs list/exec/upload/download`,给 AI Agent 用)、套接字协议、应用里的服务端、PATH 和 Agent skill 的安装。
|
||||
- `update/`:在线升级,包括清单、验签、下载、各平台安装器和 `Updater`。
|
||||
- `workspace/`:窗口壳。持有 store、`DockArea`、各类标签的注册表和**全部动作处理器**,按领域拆成多个文件(`forwards.rs`、`credentials.rs`、`links.rs`、`editors.rs`、`tools.rs`、`updates.rs`…)。另有标题栏、状态栏、左侧栏 `Sidebar`、右侧栏 `ToolSidebar`、开始页。
|
||||
- `shared/`:多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`SegmentedControl`、`RowTooltips`、`confirm_danger`、`form_error_notification` 等)。
|
||||
|
||||
关键流程与不变量:
|
||||
## 关键约定与坑
|
||||
|
||||
- **传输队列在标签这一层,引擎一次只跑一批。** `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 连接代次隔离。
|
||||
- **下载严格按顺序写盘。** 读请求并发且可能乱序、短读返回,`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`,因为表格在行模式下也会把它们切成列选择。
|
||||
- **文件列表快捷键按焦点路由。** 一个动作 `ExplorerShortcut(ExplorerCommand)`,侧别写进绑定本身;工作区找 focus handle 含焦点的那个 SFTP 面板(`contains_focus`),不用最近激活的标签,否则两个 SFTP 分栏并排时会发错。同一命令绑多个键时,最后注册的那个会显示在 tooltip 里(`tooltip_with_action`),所以 WinSCP 的键放最后。菜单项派发 `ExplorerAction`,按动作相等查不到绑定,菜单里不显示快捷键。
|
||||
- **框选同 WinSCP 关掉「整行选择」。** 拖文件去另一侧的 `on_drag` 只挂在名称单元格(图标加文件名,`FileListing::drag_from`)上,不挂在行上;名称上不带 ⌘ / Shift 的左键按下会在按下时就把未选中的项单独选中(`FilePane::select_pressed`,右键按下也用它),所以拖走的就是高亮的:未选中的项单独走,已选中的项带着整个选择走(拖动的载荷在 render 时按选择算好,按下前后算出的都是这一项,不会不一致)。拖动中只有指针在接收它的另一侧列表上(且可以传输)时才显示「上传 / 下载 N 个项目」并用 `DragCopy` 光标,其余地方(包括自己的列表)不显示预览、光标是 `OperationNotAllowed`(WinSCP 同):载荷带一个 `DropSpot`,接收侧容器的 `on_drag_move` 在指针落在自己范围内时记下指针位置,预览在 render 时比较它和 `window.mouse_position()`。存位置而不是布尔值,是因为 `on_drag_move` 会通知所有监听这个类型的元素(并排的另一个 SFTP 标签也会收到),「不在我这儿就清掉」会被后调用的覆盖。光标由预览里一个 canvas 在 paint 时 `set_window_cursor_style`:拖动层最后绘制,所以它的光标请求最后一个入栈、生效;测试平台不暴露光标,这点只能肉眼确认,测试只查预览 `file-drag-preview`(`aria_label` 是显示的文字)。列表容器 `("file-list", side)` 自己挂一个 `MarqueeDrag`,它带着开启它的面板的 `EntityId`:GPUI 的 `on_drag_move` 会把同一类型的拖动通知给**所有**挂了监听的元素,不管是谁开启的,所以监听里要核对 `event.drag(cx).pane == cx.entity_id()`,否则另一侧面板(以及别的 SFTP 标签)会跟着画框、改选择。所以从名称以外按下拖动就是框选(GPUI 的拖动由最上层先达到阈值的元素开启并停止冒泡,名称的拖动因此优先;滚动条在按下时就停止冒泡,不会触发框选)。容器在按下时记下位置,从列标题(`Pressed::Title`)开始的拖动不画框,其他地方都画(名称的图标和文件名有自己的文件拖动,GPUI 由它先开启,所以轮不到框选)。行高固定,每行用绝对定位的 `canvas`(**必须** `top_0().left_0()`)在 prepaint 记下「第一行顶端的位置和行高」(`ListGeometry::rows`),选框的起点按「距第一行顶端」保存,所以列表滚动时起点跟着内容走;框住哪些行由纯函数 `selection::swept_rows` / `row_at` 算,`Selection::sweep` 合并(开始时按住 ⌘ 或 Shift 则加到原有选择上)。拖到边缘用 `gpui_kit::base::AutoScroll` 滚动表格的 `vertical_scroll_handle`。拖动中 GPUI 每画一帧都会清掉各元素挂起的按下(`div.rs` 里 `has_active_drag` 时清 `pending_mouse_down`),所以松开时起点那个名称单元格不会再收到单击;拖动开始和松开落在同一帧里(快速一甩)时这条不成立,名称单元格的单击因此还用 `ends_a_drag`(按下和松开相距超过 GPUI 的 2 px 拖动阈值)排除掉;松开(`on_mouse_up` / `on_mouse_up_out`)时清掉选框并停止滚动。选框是容器里的绝对定位 `selection-rectangle`,夹在列表范围内,配色和选中的名称一套(`tokens.table_active` 底色加 `table_active_border` 边框,用户要的;原来是 `primary` 的灰底深色框,和浅蓝的选中不搭)。
|
||||
- **列表右键菜单挂在列表容器 `("file-list", side)` 上**,和主机树一样:名称的右键处理器先把没选中的项选中、把 `MenuHit::Item(名称)` 写进 `menu_hit`(右键空白处则清空选择,给空白处菜单),列标题(委托的 `render_th`)写 `MenuHit::Column(列键)`,容器在捕获阶段清空它;容器的菜单构造器按命中给出文件菜单、「大小」标题的「文件大小显示为」(其他标题给空菜单,不弹出)或空白处菜单。**不要在标题或行上另挂 `context_menu`**:菜单的右键监听不阻止事件冒泡,外层容器的菜单会一起弹出。大小格式 `explorer::FileSizeFormat` 存在 `AppSettings::file_size_format`(`settings.json`),由 `settings::apply` 设成全局量(explorer 不依赖 settings,同终端字体),菜单项派发 `SetFileSizeFormat`,工作区写设置;委托的 `context_menu` 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。
|
||||
- **滚动条挂在不滚动的外层上。** `vertical_scrollbar(&handle)` 往元素里加一个绝对定位、铺满的滚动条图层;挂在 `overflow_y_scroll()` 的元素自己身上时,GPUI 给滚动容器的所有子元素(包括绝对定位的)加滚动偏移,滚动条跟着内容一起移走,滑块和实际位置对不上(用户在系统监控上报过,端口转发、凭据列表和开始页原来也是这样写的)。所以滚动区(`overflow_y_scroll().track_scroll(&handle)`)外面包一层 `.relative()`、不滚动的容器,`vertical_scrollbar` 挂在这一层上(gpui-kit 自己的 Tree、设置页都这样;主机树、端口转发和凭据列表的 `*-list`、开始页的 `recent-hosts` 包 `recent-host-page`);不需要自己的 `ScrollHandle` 时直接用 `overflow_y_scrollbar()`,它自己把两层摆好(系统监控)。滚动条不暴露给测试,改动后只能看真窗口。
|
||||
- **`Select` 必须放进有尺寸的容器。** 它的外层是 `size_full()`,直接放进工具栏行会盖住整行,别的按钮全点不到。
|
||||
- **SFTP 面板的工具栏永远一行高,放不下的按钮整个藏起来。** 折行会让这一侧的列表比另一侧低一截,所以工具栏行是 `flex_wrap` 加固定一行高(`h_6`,和 small 按钮、下拉框一样高)加 `overflow_hidden`:放不下的按钮折到被裁掉的第二行,整个隐藏(不会只露半个),从最右边的开始;命令在菜单和快捷键里都还有。边框和内边距在外层 `frame` 上,行高才不含它们。`sftp_controls_fit_small_window_in_light_dark_and_zoom` 查小窗口大字号下每个按钮要么完整可见要么不可见、两侧列表顶端对齐。新标签的两侧对半分(两个 `resizable_panel` 都 `flex_basis(relative(0.5))`,只管第一帧,之后分栏状态记下实际宽度),默认窗口(1280 px)开着侧栏时每侧约 500 px,第一行(目录下拉框 `rems(10.)`)约 404 px,放得下(`sftp_panes_open_half_and_half` 查按钮都可见、两侧列表顶端对齐)。改工具栏按钮或下拉框宽度时要重新算这笔账。
|
||||
- **断开时对远程侧的操作要有回应。** `ExplorerPanel::execute` 一进来先看 `needs_connection(command)`(远程侧的导航、打开、删改、属性,以及所有传输;选择、书签、复制路径、队列按钮不算):标签是「未连接」时不执行,弹出「与 主机名称 的 SFTP 连接已断开」(说明是远程面板的 `problem`),确定按钮「重新连接」派发 `ExplorerCommand::Reconnect`;`can_reconnect` 为假(队列里有停止的批次,或传输正在自动重连)时只有「知道了」并说明该怎么办。为了让这些命令发得出来,远程面板在「未连接」时不禁用工具栏、菜单和路径标签(`FilePane::takes_commands`:只有「连接中」才禁用),传输按钮和拖放在「未连接」时也可用(`sync_available` 的 `transfer`)。
|
||||
- **SFTP 标签自己持有分栏状态。** 双栏之间和双栏与队列之间的分隔线位置是 `ExplorerPanel` 的 `panes_split` / `queue_split`(`ResizableState` 实体,`with_state` 交给 `h_resizable` / `v_resizable`)。不给 `with_state` 时分栏用 keyed element state,标签一不渲染(切到别的标签)就丢,切回来回到初始宽度(`sftp_panes_keep_their_split_across_tab_switches`)。和 Dock 布局一样只活在内存里,不持久化。
|
||||
- **路径标签按上一帧的宽度折叠。** render 时拿不到布局宽度,所以 `path-parts` 里放一个 `canvas`,在 prepaint 记下宽度,变了就用 `on_next_frame` 通知面板重画(绘制阶段里 `notify` 会丢)。各段宽度在 render 里用 `text_system().shape_line` 按 `text_sm` 量,和实际布局一致(各段不加内边距,否则 `/ home/ tester/` 看起来像多了空格)。折叠规则是纯函数 `folded_parts`:留住根,中段换成 `…`,末尾尽量多留几级;`…` 点了去它藏起的最后一级(WinSCP 同)。「当前面板」是最近获得焦点的一侧(`on_focus_in`,必须 `defer_in`,见下条),标签重新显示时焦点回到它;界面上不标出来(路径标签两侧一样是 `list_active` 底色加普通下边框):以前给当前面板加一条 `list_active_border` 下划线,用户看它像「这一侧处于激活状态」,而它和命令实际作用的一侧对不上。
|
||||
- **焦点监听器里改渲染状态要 `defer_in`。** `on_focus_in` / `on_focus` / `on_blur` 在 `draw` 的焦点阶段同步执行,这时 `cx.notify()` 只记下脏视图、不置窗口脏也不排新帧,改动要等下一次别的原因重绘才看得见。点列表空白处(GPUI 对 `track_focus` 元素在按下时自动聚焦,没有别的处理器触发重绘)因此要点两下才切换当前面板;改为 `cx.defer_in` 在绘制结束后执行就好。测试 `sftp_clicking_empty_list_space_makes_that_pane_current_at_once` 不自己 `render_frame`,只看点击本身触发的帧。
|
||||
- **SFTP 空闲时也要发现断网,和终端同时。** 终端靠主循环一直 `channel.wait()`,russh 的 keepalive(`SshConnector` 里 30 s × 3,所有 SSH 连接共用)一关掉连接它就返回。SFTP 连接同样会被关掉,但 russh-sftp 只在下一次请求时才报 `session closed`,而且不公开关闭状态。所以 `SftpClient` 把通道流包一层 `WatchedStream`:读到结尾或出错就置位一个 `watch`,`SftpClient::closed()` 等它;工作线程空闲等命令时同时等它,一触发就 `forget` 这个连接并发 `Disconnected`。等待时只持有 `Weak` 和那个 `watch` 接收端,**不能持有 `Arc<SftpClient>`**:主动「断开」靠丢掉最后一个引用来关连接,监听攥着强引用会让 SSH 连接一直开着(`an_idle_connection_that_drops_is_reported_without_a_request` 用服务器端的 `ended` 计数守着)。`is_network_error` 也认 russh-sftp 的 `session closed`。
|
||||
- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status`(`FilePane::render_status`)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。列表没有行时表格中央的占位文字(委托的 `render_empty`,id `list-placeholder`,文字来自 `FilePane::placeholder`,经 `ListingContext::placeholder` 传进去)也按阶段说:连接中「正在连接 SFTP…」,还没读到过目录(`listed` 为假)时「正在读取目录…」,读到过之后才是「空目录」(重读一个空目录时同样等 `SLOW_LOAD` 才改口);没读成功过时是「未连接」或「无法读取目录」。非根目录总有 `..` 一行,所以「空目录」实际只出现在空的根目录。改加载状态的地方都要 `sync_listing`,否则委托里的占位文字不更新。读不了目录、远程侧断开的原因是面板的 `problem`,显示在窗口状态栏最左边、平时写「已连接 主机名称」的 `status-connection`:SFTP 标签在前时,工作区用 `ExplorerPanel::status`(这个标签自己的连接状态,不是主机汇总的,加上 `problem`,远程侧优先)生成 `WorkspaceStatus::Explorer`,未连接时写「未连接 主机名称:原因」,已连接但读不了目录时只写原因,两种都是 `danger` 红字;主机(终端标签)断开时同样是红字。`problem` 变化时面板发 `FilePaneEvent::ProblemChanged`,标签转成 `ExplorerPanelEvent::StatusChanged`,工作区只在那个标签在前时 `notify`(不观察整个标签,传输进度每 50 ms 一次)。
|
||||
- **「打开目录/书签」的选中项是推导出来的。** 列表里被选中的就是与目录框(按面板规则展开 `~`、相对路径并去掉末尾分隔符后)相同的书签,没有另存选中状态,和 WinSCP 的 `DirectoryEditChange` 一致:单击书签只是把它写进目录框。书签的增删和排序都派发 `ExplorerCommand`(`AddBookmark { path: Some(..) }`、`RemoveBookmark`、`MoveBookmark`),改动立即写库,取消对话框也不回滚(WinSCP 同)。对话框打开后在 `open_dialog` 同步返回之后立刻 `focus` 目录框并全选,这样能生效(`Root::open_dialog` 是同步聚焦对话框宿主的,之后同一更新里的聚焦会覆盖它);延迟到下一帧的聚焦才会失效。
|
||||
- **书签按主机、按侧持久化。** `bookmarks` 表对 `hosts` 有 `ON DELETE CASCADE`;`remove_unnotified` / `remove_group_unnotified` 必须同时丢掉内存里被删主机的书签。`BookmarkSide` 放在 host 模块,因为 host 不能依赖 explorer。
|
||||
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串;`FakeLocalDirectory` 只记录本地操作,不真的改文件。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。
|
||||
### 状态与持久化
|
||||
|
||||
- **端口转发独立于终端和 SFTP:规则在 store,运行状态在管理器。**
|
||||
- `HostStore` 持有规则(`forwards`,写穿到 `forwards` 表,对 `hosts` 有 `ON DELETE CASCADE`,`remove_unnotified` 同时丢掉内存里的规则,复制主机不复制规则);`ForwardManager` 只持有「在跑」这件事。规则没有运行状态字段,启动应用时全部是停止的,除了 `auto_start` 的(`start_automatic`,工作区构造末尾调用)。
|
||||
- 转发不进 `refresh_host_connection_state`,`on_disconnect_host` 不碰它,`cancel_prompts_for_host` 也跳过 `PromptOwner::Forward`:主机的「断开连接」只管标签。删除主机 / 分组时工作区不用显式停转发,管理器看到规则从 store 消失就停。
|
||||
- 认证问答走和终端、SFTP 同一套对话框,归属是 `PromptOwner::Forward(ForwardId, 代次)`,代次是管理器每次启动递增的编号;`StatusChanged` 时工作区丢掉过期代次的问答。转发可能是自动开启的,屏幕上没有任何东西说明谁在问,所以对话框开头多一行「端口转发“名称”正在通过 主机名称 连接。」(`forward_prompt_origin`)。取消问答等于停止(`manager.stop`),不算失败、不弹通知。
|
||||
- 转发自己失败(`ForwardManagerEvent::Failed`)弹错误通知,因为转发列表可能没在显示。单个连接转发不了(`ForwardEvent::ConnectionFailed`,例如服务器连不上目标)只记成这一行的 `problem`,转发继续跑;连接数变化只 `notify`、不发 `StatusChanged`,繁忙的 SOCKS 代理上它一直在变。
|
||||
- **转发发现断线靠处理器被丢弃,不靠 `Handler::disconnected`。** russh 0.63.3 在套接字 shutdown 失败时会跳过 `disconnected`(`client/mod.rs` 的 `Session::run`)。`SshClientHandler` 由会话任务持有,任何退出路径都会丢掉它,所以 `connect_forwarding` 把一个 `mpsc` 发送端放进处理器,工作线程 `forwarded.recv()` 读到 `None` 就是连接没了(SSH keepalive 30 s × 3,和终端同时发现)。同一个通道也承载远程转发:`server_channel_open_forwarded_tcpip` 把通道连同 `ChannelOpenHandle` 交给工作线程,连上本机目标之后才 `accept`,连不上就 `reject(ConnectFailed)`,对端看到的是拒绝而不是接受后挂断。别的连接(终端、SFTP、exec)没有这个发送端,服务器主动开来的 forwarded-tcpip 通道一律拒绝。
|
||||
- **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。
|
||||
- **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。
|
||||
- **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。
|
||||
- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity<TerminalView> }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`、`NetstatPanel`、`ProcessPanel`、`ServicePanel`、`DockerPanel`、`HistoryPanel`、`SnippetPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它)。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。
|
||||
- **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result<String, String>`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms,`terminal::exec_answer`,几个工具共用;它们的目标是 `terminal::ExecTarget`),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。
|
||||
- **系统监控(`monitor/`)是纯解析加一个面板。** `linux.rs` 是命令 `command(Parts { system, disks })`(每部分以 `@@名称` 行开头:`SYSTEM` 是 `uname`、`/etc/os-release` 和 CPU 型号,每次连接一次;`LOAD` 每次都有,只用 `read` / `case` / `echo` 这些内建命令读 `/proc/uptime`、`/proc/stat`(读到第一行不是 `cpu` 就停,后面很长的 `intr` 行不读)、`/proc/meminfo`(读到 `SwapFree` 就停)、`/proc/net/dev` 和 `/proc/net/route`(找默认路由的网卡);`DISKS` 是 `df`)和纯函数 `parse`(→ `Parsed::Reading(Box<Reading>)` 或 `Unsupported(uname -s)`;没有 `/proc/stat` 的读数就算不支持,cmd.exe 什么都不打印也落在这里;`system` 和 `disks` 只在这次读数要了时才有)。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`、把路径换过去,用本机的 `sh` 真跑脚本再解析,守着内建命令读到的和 `cat` / `grep` 一样;`every_command_is_valid_sh` 对四种组合跑 `sh -n`。`model.rs` 是读数 `Reading`(CPU 的 busy / idle 节拍、内存、网卡的累计字节,要了时才有的系统信息和磁盘)、`Tracker`(和上一次读数比出 `Snapshot`:CPU 百分比、每核百分比、网速;第一次、相隔超过 10 秒、计数变小(重启)都不出比率)和格式化(`format_bytes` 按 1024、两位小数,`format_rate`、`format_percent`、`format_uptime`)、`Load::of`(70 / 90 分档)。`MonitorPanel` 按终端 id 存每台的 `Tracker`、最近一次 `Snapshot`、系统信息(连同读它时终端的连接代次 `TerminalView::generation`,重连可能连到另一台机器,代次变了就重读)和磁盘(连同读它的时刻,满 30 秒 `DISK_INTERVAL` 再读),切回来马上有数;每次读数前 `HostState::parts_due` 决定要哪几部分;`set_target(target, active)` 变了就重启读数任务(丢掉任务就停);时间用 `cx.background_executor().now()`,所以测试时钟下比率也对。卡片是 gpui-kit 的 `GroupBox::fill()`,环是 `ProgressCircle`,条是 `Progress`,会变的数字用等宽字体(`numeric`)免得跳动。一行放几根竖条按上一帧量到的宽度算(同路径标签:竖条那一行里的 `canvas` 在 prepaint 记下宽度,变了就 `on_next_frame` 通知面板重画;`bars_per_row` 是纯函数,竖条 0.375 rem、间隔 0.125 rem),还没量到之前那一行限高一行、`overflow_hidden`,不会先闪出几行。展开 / 折叠按钮(`unfold_button`:ghost xsmall,文字后面跟 ⌄ / ⌃,`toggled`)派发 `ToggleMonitorDetail(MonitorDetail::{Cores, Interfaces})`,工作区经 `ToolSidebar::toggle_monitor_detail` 转给 `MonitorPanel::toggle`(一个命令一个处理器,同 SFTP 队列的展开);按钮派发在工作区的 focus handle 上(`MonitorPanel::new(dispatch)`),终端没焦点也能用。主网卡的挑选是纯函数 `Snapshot::main_interface`(`busy_interfaces` 是有过流量、不是 `lo` 的那些)。面板的滚动用 `overflow_y_scrollbar()`(见下面「滚动条挂在不滚动的外层上」)。
|
||||
- **Docker(`docker/`)一次读出四类对象。** 命令(`linux::command()`)先补 PATH(`/usr/local/bin`、`/opt/homebrew/bin`:Docker Desktop、Homebrew),再定 `$d`:root 或 `docker version` 能连上 daemon(在 docker 组里)就是 `docker`,否则 `sudo -n docker`(所有 Docker 命令都用这一段前缀 `DOCKER`);`@@version` 是 `$d version --format "{{.Server.Version}}" 2>&1`,不以数字开头就是连不上,`complaint` 把「Cannot connect」翻成「Docker 没有运行」、权限问题和 sudo 要密码翻成「没有权限:以 root 登录、把这个用户加入 docker 组…」;其后 `ps -a --no-trunc`、`volume ls`、`images`、`network ls --no-trunc` 都用 `--format "{{json .}}"`,每行一个对象(Docker 1.13 以后都有),按行 `serde_json` 解析,认不出的行跳过。`ps` 的标签是「k=v,k=v」,值里也可能有逗号(compose 的配置文件列表),没有 `=` 的一段接到前一个值上;老 Docker 没有 `State` 字段,从 `Status`(「Up 3 days」)推。卷 / 镜像 / 网络是否在用由 `DockerTable::new` 按容器的 `Mounts`、`Image`(`nginx` 就是 `nginx:latest`;镜像没名字了容器会显示它的 ID)、`Networks` 算。拼进命令的 ID 和名字先过 `valid_id`(字母数字开头,只许字母数字和 `_.:-/`,`/` 是给带仓库地址的镜像名的),加双引号。`docker images` 每个「仓库:标签」一行,同一个镜像打了几个标签就有几行、镜像 ID 相同,所以镜像按 `Image::key` 认:有标签的是 `仓库:标签`,没有标签的是镜像 ID(只有仓库名的 `nginx` 会被 Docker 当成 `nginx:latest`,显示成 `nginx:<none>`,也只按 ID 算在用);卡片 id、详情(`image inspect`)和删除(`rmi`,有别的标签时只去掉这一个)都用它,`DockerTable::new` 按键去重。卡片 id 重复时调试版构建会因无障碍节点 id 重复直接 panic(用户在镜像页碰到过)。`the_script_asks_docker_for_json` 用一个假 `docker` 脚本(按前两个参数回答)真跑读数命令,查模板 `{{json .}}` 原样到了 docker 手里。标签页是 `shared::count_tabs`(见下面「表单里的几选一用 `shared::SegmentedControl`」)。卷 / 镜像 / 网络卡片的名字用 GPUI 自带的中间省略(`overflow_hidden().whitespace_nowrap().text_ellipsis_middle()`,用户要的:「registry.ap-…hello-world:latest」),右边的标签 `flex_shrink_0` 不被挤;镜像的创建时间只写日期,带上时分在 320 px 的卡片里会被截掉。列表同系统服务用 gpui 的 `list`(项目卡片展开后高度变,行里带着展开状态,变了就 `splice`);项目那一行的单击派发 `ToggleDockerProject`,按钮 `stop_propagation`。详情一律是 `DetailSection { id, title, body }` 的列表(`SectionBody::Rows` 标签和值、`List` 一行一个名字、`Text` 等宽文本),`rows()` 把空值写成「—」;容器的 `ContainerDetails::sections()` 也转成它,所以五种详情长得一样,测试 id 都是「{前缀}-{区块}:{标签}」(`container-basics:名称`、`volume-basics:挂载点`、`image-users:<容器>`、`network-subnets:<子网>`),等宽文本是「{前缀}-{区块}」(`container-environment`、`image-environment`)。打开详情是一个动作 `ShowDockerObject { object, id }`(容器也是),工作区按种类开容器的或另外三种的对话框。`ControlContainers { subject, ids, command }` 和 `RemoveDockerObject { object, id, name }` 由工作区处理,执行、通知和之后重读列表走 `tools.rs` 的 `run_on_host(HostCommand { script, outcome, done, failed, then })`,系统服务的命令也用它。
|
||||
- **历史命令(`history/`)读 bash 的历史文件,点了往终端的输入行里打字。** 命令(`bash::command()`)是一行 `sh -c '…'`:`~/.bash_history` 读得到就印 `@@size`(`wc -c`)和 `@@history` 加文件最后 `LIMIT`(512 KB)字节(`tail -c`),否则 `@@missing`;两样都没有(没有 `sh`)是不支持。`@@history` 之后原样是文件,命令本身可能以 `@@` 开头,所以只认第一个;文件比 `LIMIT` 长时丢掉第一行(从行中间切开的)。设了 `HISTTIMEFORMAT` 的 bash 在每条命令前写一行「#秒数」,多行命令写成几行:照 bash 自己读回的规则,跟在带时间的命令后面、自己没有时间的行接到它上面,没有时间的文件一行一条(`#` 后面不全是数字的是注释命令,不是时间)。`the_script_reads_the_end_of_the_history_file` 把 `$HOME` 换成临时目录、用本机的 `sh` 真跑。卡片的单击和执行按钮派发 `EnterCommand { command, run }`(按钮 `stop_propagation`),工作区的 `on_enter_command` 对当前远程终端调 `TerminalView::enter_command`,成功就把焦点给终端;引擎的 `encode_command` 先发 Ctrl-E、Ctrl-U(bash、zsh、fish 的 emacs 键:到行尾、删掉前面所有的,替换已经输入的内容;`replace` 为假时不发,工作区对已知是 Windows 的主机这样做),再把命令当粘贴发(bracketed paste 时多行命令留在输入行上),`run` 时再加回车;`ALT_SCREEN`(vim、less)拒绝,没有 bracketed paste 时多行命令只能执行、不能只输入(第一个换行就会执行它)。复制是 `CopyCommand(String)`。
|
||||
- **系统服务(`services/`)一次 `systemctl show` 读全部服务。** 列表命令(`linux::command()`)先判 `/run/systemd/system` 存不存在(sd_booted 的判法,不在就 `@@unsupported` 加 `uname -s`,Linux 没 systemd 时界面写「这台主机没有使用 systemd」),`@@version`、`@@state` 是 `systemctl --version` 和 `is-system-running`;然后 `list-unit-files` 和 `list-units --all --plain` 的第一列合起来,去掉模板(`*@.service`,只能以实例运行),交给**一次** `systemctl show -p Id -p Description … -- $names`(属性一个 `-p` 一个,systemd 230 以前不认逗号)。别名(`sshd.service`)在 `show` 里的 `Id` 是本名,`ServiceTable::new` 按名字去重;`LoadState=not-found`(被引用但没有 unit 文件)的只在失败时列出;自定义与否看 `FragmentPath` 在不在 `/etc/systemd/system/`。`$(…)` 里的 `case` 分支写成 `(pattern)`:bash 3.2 会把分支裸的 `)` 当成命令替换的结尾(macOS 的 `sh` 就是它,`the_commands_are_valid_sh` 在本机这样失败过);服务名用 `printf "%s\n"` 印,dash 的 `echo` 会解释反斜杠。**拼进命令的服务名先过 `valid_name`**(只许字母、数字和 `:_.@-\`,systemd 自己的规则,且不许连续两个反斜杠),放在双引号里(`\x2d` 这种转义原样留下),不合格就不执行。命令(`control_command`)是 `if test "$(id -u)" = 0; then systemctl <动词> -- "名字"; else sudo -n systemctl …; fi 2>&1; echo @@status $?`,`controlled` 把要密码、要交互认证、不在 sudoers 都翻成「需要 root 权限…」,别的取 systemctl 的第一行(「Job for … failed because …」)。日志是 `journalctl -u … -n 200 --no-pager`,非 root 先试 `sudo -n`、不行就看自己看得到的。`the_script_shows_every_service_but_templates` 用一个假 `systemctl` 脚本(按第一个参数回答)真跑列表命令。列表用 gpui 的 `list`(`ListState`,量每行的高度)而不是 `uniform_list`,因为分组标题和卡片不一样高;`ServicePanel::sync_list` 在行变了时 `splice`、换标签或搜索时 `reset`(回到顶上)。卡片上的按钮 `stop_propagation`,免得连卡片的单击(打开详情)一起触发。`ControlService { name, command }` 由工作区处理:停止、重启走 `confirm_danger`,其余直接执行;详情对话框的按钮同进程详情,先 `close_dialog` 再在工作区的 focus handle 上派发。
|
||||
- **进程管理(`processes/`)只用 shell 内建命令读 `/proc`。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:`@@uptime`、`@@memory`(`MemTotal`)、`@@clock` / `@@page` 是 `getconf CLK_TCK` / `getconf PAGESIZE`(没有 `getconf` 时按 100 和 4096),`@@users` 读 `/etc/passwd`,然后 `for d in /proc/[0-9]*` 每个进程读一行 `stat`、再读 `status` 读到 `Uid:` 为止,印出「uid stat 行」;进程在列出和读取之间退出时 `read … || continue` 跳过(dash、bash 遇到重定向失败都不退出),整个循环的错误输出丢掉;数到 `LIMIT`(2500,一行约 300 字节,exec 的输出最多 1 MB)就印 `@@more` 停下。`stat` 的名字在括号里、可能带空格和括号,所以 `parse_process` 从最后一个 `)` 往后数字段(第 9 个是 flags,14、15 是 utime、stime,22 是启动时刻,24 是 rss 页数);flags 带 `PF_KTHREAD`(0x200000)的是内核线程,不列。`Tracker` 以「PID + 启动时刻」认进程(PID 会被新进程重用),用主机自己的 uptime 差做分母,uptime 变小(重启、另一台机器)不出比率;终端重连(`TerminalView::generation` 变了)就换一个新的 `Tracker`。启动时间是「本机现在 − uptime + 启动节拍 / 每秒节拍」,按本机时区显示,和主机的时钟差多少就偏多少。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`(含一个内核线程和一个读不到 `stat` 的进程)用本机的 `sh` 真跑。结束进程:右键菜单挂在 `processes-list` 上(同主机树,卡片是 `uniform_list` 在布局之后画的),卡片的右键按下记下 PID(`menu_hit`)并把焦点交给面板,菜单项才有派发路径;菜单派发 `EndProcess { pid, force }`,工作区的 `on_end_process` 从 `ToolSidebar::process` 取名称和用户,用 `shared::confirm_danger` 确认(`confirm_delete` 也是它),确认后在同一个终端上执行 `end_command`(`kill -TERM|-KILL <pid> 2>&1; echo @@status $?`,PID 是数字,命令里不拼进程名之类主机给的文字),`ended` 把「Operation not permitted」「No such process」翻成中文,通知结果,再 `refresh_processes_soon`(1 秒后重读)。排序按钮派发 `SortProcesses(ProcessSort)`、刷新派发 `RefreshProcesses`,都经工作区转给面板;搜索框是面板自己的状态。详情:卡片的单击派发 `ShowProcess(pid)`,工作区的 `on_show_process` 从 `ToolSidebar::process_details` 取 `ProcessDetails`(`Snapshot::details`:进程、父进程名、按 ppid 数出的直接子进程和后代,列表里没有父进程时写「PID n」,ppid 0 是「无」),交给 `processes::open_process_dialog`;详情要的 `stat` 字段(父进程、进程组、会话、终端、前台进程组、优先级、nice、线程、虚拟内存)和读数一起解析进 `ProcessInfo`,`ps` 的状态字母(`<` / `N`、`s`、`l`、`+`)和终端名(136–143 是 `pts/n`,4 是 `ttyN` / `ttySN`)在 `parse_process` 里算好;命令行由 `ProcessDetailsView` 打开时在同一个终端上执行 `cat /proc/<pid>/cmdline`(`command_line_command`),NUL 分隔的参数用空格连起来(`command_line`),空的是僵尸或已退出。对话框的标题和页脚在 `open_dialog` 的构造闭包里,每帧读正文实体(复制按钮按一下变成「已复制」两秒,状态在正文实体里);「结束进程」「强制结束」先 `close_dialog` 再在工作区的 focus handle 上派发 `EndProcess`(关掉对话框后焦点是空的,`window.dispatch_action` 没有落点)。卡片的行数必须一样(同网络连接,`uniform_list` 按第一项的高度排)。
|
||||
- **网络连接(`netstat/`)读一次,按需刷新。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:先 `export PATH=$PATH:/usr/sbin:/sbin`(Debian 上普通用户的 PATH 里没有 sbin,有的发行版把 `ss` 放在那里),`@@uid` 是 `id -u`(是不是 root),`@@users` 用内建命令读 `/etc/passwd` 得到「uid 名称」,然后有 `ss` 就 `ss -tuanpe | awk …`:awk 跳过表头、只印前 `LIMIT`(3000)行、在 `@@counts` 下按「协议 状态」数**全部**的,所以列表被截断时计数仍然准确(`ss` 先列 UDP 和监听的,截掉的只会是连接);没有 `ss` 就对 `/proc/net/{tcp,tcp6,udp,udp6}` 中存在的每个文件跑一个 awk(`-v p=$f`),印出地址、对端、状态、uid、inode,同样截断和计数。awk 程序按原样写在常量里,由 `double_quoted` 转义成 sh 的双引号串(`$`、`"`);不能用 `!`(csh 会展开,所以写 `$1 == "Netid" { next }` 而不是 `!=`),也不能出现 `\\`(fish 在单引号里把它读成一个反斜杠),`the_command_survives_any_login_shell` 守着。`ss` 省略 `uid:0`,所以有 inode、没有 uid 的是 root 的;`ino:0` 的没有主人(TIME_WAIT),用户留空。进程名可能带空格,所以 `parse_ss` 按位置跳过前 6 个字段再找 `users:((…))`,老版本 `ss` 的 `("name",pid,fd)` 也认。`/proc` 的地址是内存里的 32 位字(小端),IPv6 是四个这样的字,`proc_address` 照 `ss` 的写法输出(端口 0 写 `*`,IPv6 加方括号)。`the_script_reads_ss_or_else_proc` 在临时目录里摆好假的 `/proc/net`、`/etc/passwd` 和一个假 `ss` 脚本,用本机的 `sh` 和 awk 真跑两条路。面板:`set_target` 变了(换终端、显示 / 隐藏、换工具)就重读,之外只有刷新按钮读(派发 `RefreshConnections`,工作区经 `ToolSidebar::refresh_connections` 转给 `NetstatPanel::refresh`);搜索框和两个下拉是面板自己的状态(同左侧列表的搜索,不是动作),改了就滚回顶上。列表用 `uniform_list`,它按第一项的高度排所有项,所以**每张卡片的行数必须一样**(没有进程也画「进程 —」那一行),改卡片时别加只有部分卡片才有的行;滚动条挂在不滚动的 `netstat-list` 上。
|
||||
- **内存说了算,写穿。** `HostStore` 的 mutator「改内存 → 写库 → `notify`」,`*_unnotified` 是纯内存的一半(给单元测试和加载)。写失败时内存照样改,store 发 `PersistFailed`,工作区弹错误通知,绝不静默吞掉。消费方 `cx.observe(&store, ..)`。设置同理:写穿到 `settings.json`(整文件替换),工作区观察后用 `settings::apply` 落到窗口上。
|
||||
- **秘密只进钥匙串。** `schema_never_contains_secret_columns` 守着这条线。`SecretStore` 的方法是阻塞的,只在后台执行器或工作线程上调;写入只经 `HostStore::save_secret`。
|
||||
- `SecretRef` 决定条目归属:密码按 `user@host:port`,私钥口令按文件路径,密码凭据按随机的 `keychain_id`,代理密码按代理地址加用户名,不保存的连接只在内存。
|
||||
- 没人再用的条目由 store 清理(`password_in_use`),私钥口令从不自动删。
|
||||
- 秘密不放进派生了 `Debug` 的 `HostDraft` / `CredentialDraft`。
|
||||
- 对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前,空字段不算「用户清空了」。
|
||||
- **私钥文件。** ShellRS 保存的私钥在 `keys/` 下(目录 0700、文件 0600,经临时文件改名写入)。只有没人再用时才删这个目录下的文件;用户自己的私钥文件从不改动或删除。
|
||||
- **登录快照在 UI 线程解析。** 工作线程读不到 store,拿到的是 `HostLogin`:凭据和跳板都已查好。它只含影响登录的字段并派生 `PartialEq`:改主机或凭据时比较前后快照,不同才发 `ConnectionSettingsChanged` 重连,所以只改名称不重连。
|
||||
- **id 由内存分配**,四个独立计数器,重启后从库里最大 id + 1 开始,删掉的最大号会被复用。对外只给 `PublicId`(16 位随机串,改名、改地址都不变,不复用),不给 `HostId`。
|
||||
- **改表**:现在 `SCHEMA_VERSION` 是 14,所有表都是 `STRICT`。
|
||||
- 改表要升版本、在 `STEPS` 里加一步,并加一个「用冻结的旧 schema 升级、再和新库比对」的测试。
|
||||
- 加列直接 `ALTER TABLE ADD COLUMN`,在 `SCHEMA` 里也写在最后一列之后。
|
||||
- 库只往前升:旧版本遇到新库直接报错、不动文件。迁移前先 `VACUUM INTO` 备份。
|
||||
- **一个数据目录只跑一个 ShellRS。** 起图形界面之前先连外部 CLI 的套接字:连得上就发 `Request::Activate`(带启动链接)然后退出。为兼容已装的 CLI,不升 `PROTOCOL_VERSION`。
|
||||
- **临时主机在 `HostStore` 的 `temporary` 里**:`host()` / `login()` 找得到,`hosts()` 看不到,也不写库。会写外键的检查用 `saved_host`。最后一个标签关掉就 `remove_temporary`。
|
||||
|
||||
- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `HostPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
|
||||
- **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。
|
||||
- **`ListItem` 里会增长的文字要零宽加裁剪。** `ListItem` 把内容包在两层 `w_full` 里、`suffix` 是它们的兄弟,整行靠内容的最小宽度收缩来给 `suffix` 让位。`flex_1().min_w_0()` 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 `suffix`(状态和启停按钮)挤出列表。转发行的文字列因此是 `w_0().flex_grow(1.).overflow_x_hidden()`(`the_forward_dialog_and_a_long_row_fit_the_smallest_window` 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:`RadioGroup::horizontal` 的那一行是 `flex_wrap`,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 `flex_1().min_w_0().self_stretch()`(那一行是 `items_center`,不拉伸的话高度不齐),卡片里那句用途同样是 `w_0().flex_grow(1.).overflow_x_hidden()`,放不下时在卡片内折行。`Radio` 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(`RADIO_DOT_COLUMN`:small 圆点 `size_3p5` 加 `gap_x_2`)拉回到圆点下面,占满卡片的整个宽度。对话框宽 `rems(42.)` 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 `Radio` 的圆点尺寸或间距时 `RADIO_DOT_COLUMN` 要跟着改,`the_three_kinds_of_forward_share_one_row` 查用途这一行的两端离卡片边缘的距离。
|
||||
- **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。
|
||||
- **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。
|
||||
- **表结构(`SCHEMA_VERSION` 是 14)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件。14 加了命令片段的 `snippet_categories` 和 `snippets`(`category_id` 级联;`scope_group_id` / `scope_host_id` 是预留的「适用范围」,都为空就是所有主机,最多一个有值,对 `groups` / `hosts` 级联,内存里 `remove_unnotified` / `remove_group_unnotified` 照样丢掉;最后一列 `run_on_click` 是「点击时自动执行」,在 14 发布之前加进来的,所以没有再升版本),`STEPS` 里是 13 → 14 这一步(`Step::Sql`,步骤是历史,`SCHEMA` 以后再改它也不跟着改),`a_version_13_file_gets_the_snippets_and_keeps_its_hosts` 用冻结的 13 版 schema 守着。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。
|
||||
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path(), link)`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate { open }`(带着启动时的链接)后直接退出。不带链接时它序列化成和以前一字不差的 `{"type":"activate"}`(`skip_serializing_if`),旧版收到带 `open` 的也只当普通的 Activate(serde 忽略多余字段),所以**不升** `PROTOCOL_VERSION`:升了会让 PATH 里已装的 `shellrs-cli` 全部报版本不符。
|
||||
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把链接追加进 `Shared::activation`(`Mutex<Option<Vec<OpenLink>>>`);请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,是 `Some` 就 `bring_forward`,再按顺序 `open_link`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
|
||||
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
|
||||
- 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
|
||||
- **临时连接不保存,功能齐全。** 标题栏右上角原来的「新建主机…」换成了「临时连接…」(用户要的;新建主机还在主机列表的「+」、右键菜单和 ⌘N),派发 `NewTemporaryConnection`。
|
||||
- 对话框是 `HostForm::temporary`(`open_temporary_connection_dialog`):新建主机的地址、端口、认证方式(密码 / 使用凭据 / 无密码)和密码,没有分组、连接方式和备注,「测试连接」照旧(用户要的),名称可以不填(用地址),下面一行说明「仅当前使用的临时会话,不会保存到主机列表。」(用户给的文案)。页脚 `form_footer` 三个主机对话框共用。
|
||||
- `commit_temporary` 调 `HostStore::insert_temporary`:分组和连接方式一律丢掉(直连),填的密码只 `remember` 在 `TemporarySecretStore` 的内存里(用户定的:不进钥匙串)。然后在对话框关掉之后(`window.defer`)经工作区的 focus handle 派发 `ConnectHost`,新标签才留得住焦点。
|
||||
- 它不记进 `external`:不是 shell_only,右侧栏工具全有,也不收起左侧栏。其余和外部连接一样:不进主机树、最近连接和数据库,最后一个标签关掉就 `remove_temporary`,标签菜单没有「编辑主机…」,外部 CLI 照样列出。
|
||||
- `login_of` 只把端点自己的密码(`SecretRef::Password`)换成 `Temporary`,用密码凭据时照用凭据的。改凭据(用户名跟着变、重连)和删凭据(改回自己登录)也照顾临时主机:`logins`、`update_credential_unnotified`、`remove_credential_unnotified` 都把 `temporary` 算进去;凭据的用量和删除确认只数保存的主机。`a_temporary_connection_goes_directly_and_may_use_a_credential` 和 UI 的 `a_temporary_connection_has_every_feature_and_saves_nothing` 守着。
|
||||
- **外部连接不保存,只开一个标签。** 堡垒机按 Xshell 的写法拉起 ShellRS(`ShellRS ssh://user[:password]@host[:port]` 或 `-url … -newtab 名称`),开一个终端标签连过去;按 WinSCP 的写法(`ShellRS sftp://…`,JumpServer 的模板就是单独一个链接,前面可以有 `/sessionname=名称`)则开一个 SFTP 标签(`open_link` 按 `SshLink::kind` 调 `connect_host` 或 `open_explorer`),其余规则两种一样(用户定的:和 Xshell 的 `-url` 一样是临时的;只认命令行参数,不注册系统的 `ssh://` / `sftp://` 链接、不改安装包;`sftp://` 链接里的路径暂不认,`scp://` 不做)。
|
||||
- `HostStore` 把它放在单独的 `temporary`(临时主机)里,并记进 `external`:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `temporary_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(未保存)」(用户定的:临时连接和外部连接在外部 CLI 里不区分,agent 用不着,ID 失效时 CLI 自己报 `host_not_found`);它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。
|
||||
- **终端只开一个通道**(`HostLogin::shell_only`,`login_of` 只对 `is_external` 的置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 `ssh/transport.rs` 对 `shell_only` 不做系统探测、`Exec` 直接答 `SHELL_ONLY` 不发给服务器(`a_terminal_a_bastion_host_opened_carries_nothing_beside_it` 守着),右侧栏只给不执行命令的工具(`ToolKind::runs_commands` 为假的只有命令片段,`offered_tools` 看 `is_external`)。延迟测量是全局请求、不开通道,照常。SFTP 标签本来就只开一个通道(`sftp/client.rs` 的 sftp 子系统,延迟同样是全局请求),所以 `sftp://` 的外部连接不用另外处理;以后给 SFTP 加别的通道(执行命令之类)要对 `is_external` 关掉。
|
||||
- 登录用「密码」方式,`login_of` 把临时主机的密码引用换成 `SecretRef::Temporary { host }`:链接里的密码只在 `TemporarySecretStore` 的内存里(钥匙串、数据库都不进),也不读同一 `user@host:port` 的已保存密码;没带密码就照常 none → 弹框。
|
||||
- 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_temporary_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_temporary`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。
|
||||
- **打开时标签占满窗口**(用户要的「终端默认全屏」「SFTP 全屏显示」,选的是收起左侧栏):`open_link` 成功后左侧 Dock 开着就 `toggle_dock` 收起,和 ⌘B 一样,用户按 ⌘B 或双击标签展开;解析失败什么也不打开时不收。不用 Dock 的放大,也不动窗口本身。
|
||||
- **命令片段照常全有**:片段面板列出库里的全部片段,不看是哪台主机(`store.snippets()`),界面只会建「所有主机」范围的片段。以后做「适用范围」(分组 / 主机)的筛选时,外部连接和临时连接不属于任何分组、也不是保存的主机,只显示「所有主机」的(用户定的)。
|
||||
- 链接解析失败(不是 `ssh://` / `sftp://`、缺用户名 / 地址、端口不对)弹「无法打开链接」通知,文字不带链接原文(可能有密码)。第二个进程在 Windows 发布版里没有控制台,所以错误一律由正在运行的那个显示。
|
||||
- `tests/cli_bin.rs` 的 `shellrs_opened_with_a_link_hands_it_to_the_running_app` 用真的 `shellrs` 二进制交接链接(含中文 `-newtab`,Windows CI 上也跑),子进程 20 秒不退就杀掉,免得交接坏了起出一个窗口。
|
||||
- **macOS 上关窗口是隐藏,不是退出。** ShellRS 只有一个窗口,在跑的东西(连接、传输、端口转发、外部 CLI)都归窗口里的工作区;GPUI 在 macOS 上默认不随最后一个窗口退出(`QuitMode::Default`),真关掉窗口会停掉这一切,却留下一个没有窗口、点 Dock 图标也唤不回的进程。所以 `main` 给窗口挂 `app::hide_when_closed`(`on_window_should_close` 里 `cx.hide()` 并返回 `false`),点 Dock 图标(`Application::on_reopen`)和再次打开 ShellRS(`come_forward_when_opened_again`)都走 `app::bring_forward`:先 `NSApplication::unhide`(只 `activate` 不一定让隐藏的窗口重新出现),再激活窗口。⌘Q 才退出。Windows / Linux 关窗口即退出(GPUI 的默认)。测试平台的 `hide()` 是 `unimplemented!()`,所以这条只挂在生产路径上,没有 UI 测试,改动后要在真实窗口里试。
|
||||
- **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动升级」和「更新渠道」存在 `AppSettings::update`(`automatic`、`channel`,后者直接用 `update::Channel`,在 settings 里实现 `Choice`),工作区在 `observe_in(&settings)` 里 `sync_updater`(`set_automatic`、`set_channel`),`update` 模块不依赖 `settings`。动作是 `CheckForUpdates` / `DownloadUpdate` / `ShowUpdate` / `RestartToUpdate` / `OpenDownloadPage`,处理器在 `workspace/updates.rs`;对话框里「重启会中断什么」由工作区数(`restart_impact`:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 `cx.set_restart_path` + `cx.restart()`,它会照常跑 `on_app_quit`(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。**Windows 不在退出过程中起进程**:gpui 退出时在 App 仍被借用的状态下跑 `on_app_quit`,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 `update::start_handed_over()`,由 `main` 在 `run()` 返回之后启动(Windows 的事件循环结束后 `run` 会返回)。更新前写 `updates/applied.json`(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 `app::updates_dir()`(用户缓存目录;设了 `SHELLRS_DATA_DIR` 时在数据目录下)。
|
||||
- **发布走 tag。** 推 `v<版本>`(和 `Cargo.toml` 一致,`CHANGELOG.md` 里要有 `## [版本]` 一节)触发 `.github/workflows/release.yml`:分平台构建打包(`packaging/macos/bundle.sh`、`packaging/windows/shellrs.iss`、`packaging/linux/appimage.sh`),在需要人工批准的 `release` 环境里上传 R2、建 GitHub Release,**最后**才用 `packaging/make-manifest.sh` 生成并上传清单、清 CDN 缓存,再调用部署挂钩(`SITE_DEPLOY_HOOK`)让官网 shellrs.com 重新构建。清单格式见 `packaging/manifest.example.json`(`the_example_manifest_parses` 守着它和解析器一致,`the_release_script_signs_what_the_client_accepts` 守着脚本和验签一致)。一次性准备和 secrets 见 `docs/release.md`。
|
||||
- **库只往前升,旧版本不碰新库。** `migrate` 遇到 `user_version > SCHEMA_VERSION`(或早于第一步)直接报错、不动文件(`open_store` 退回内存 store 并提示);迁移已有的库之前先 `VACUUM INTO '<库>.v<旧版本>.bak'`(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。
|
||||
- **窗口还在搭的时候不能直接弹通知。** 通知层属于窗口的 `Root`,而 `Root` 要等 `open_window` 的构造闭包返回才挂上;在那之前 `window.push_notification` 会 panic(「window first layer should be a gpui_component::Root」),应用启动即崩溃。`main` 里读库 / 读设置的问题、`Workspace::new` 里外部 CLI 启动失败,都用 `workspace::notify_once_open`(`window.defer`)延后到窗口建好。`cx.emit` 出去的事件本来就在构造结束后才送达,订阅里弹通知没有这个问题(自动开启的转发启动失败就是这样报的)。
|
||||
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
|
||||
- **设置写穿到 JSON,由工作区落到窗口上。** 设置是应用级的,不进主机数据库,存在 `settings.json`(`app::settings_path`),整文件先写临时文件再改名替换。`SettingsStore` 和 `HostStore` 一样内存说了算:`update` 改内存 → 写文件 → `notify`,写失败发 `SettingsStoreEvent::PersistFailed`,工作区弹错误通知;`update_unnotified` 是给单元测试的纯内存一半。文件缺字段用默认值,读不出来就用默认设置并在启动时提示,文件留到下一次改动时覆盖。设置页的下拉只写 store(下拉的 setter 拿不到 `Window`);工作区 `observe_in` store 调 `settings::apply`,另外 `observe_window_appearance` 让「跟随系统」跟着系统切换,构造时在第一帧之前先应用一次。标题栏的主题开关(`ToggleTheme`)也只是把「应用外观」写成当前的反面,不再直接改主题,所以两处永远一致并且会保存。界面语言目前只影响 gpui-kit 自己的文案(`set_locale`),ShellRS 的文案还是中文,所以默认是「简体中文」而不是「跟随系统」;翻译完成后再改默认值。locale 是进程级全局量,UI 测试并行运行,所以 UI 测试不切换语言,只由单元测试覆盖 `locale` 的解析。
|
||||
- **外部 CLI 经由正在运行的应用执行。** 命令本身不读钥匙串、不开数据库,只把请求交给应用:所以不会多弹钥匙串授权框,也不会和「内存说了算」、内存里分配 id 冲突。
|
||||
- **服务端**:生产路径 `Workspace::new` 用终端同一个 `SshConnector`(共享 known_hosts 锁)和 SFTP 同一个 provider 启动 `CliServer`;`new_with_services`(UI 测试)不启服务,测试绝不监听真实套接字。工作区在 store / 设置变化时 `sync_cli_server`:主机快照(`CliTarget::all`,带分组全路径)和「启用外部 CLI」开关。
|
||||
- **一直监听,按开关拒绝**:开关关着时回 `not_enabled`,Agent 才分得清「没运行」和「没开」。套接字 `0600`,每个连接再用 `peer_cred` 核对 uid。残留的套接字文件:连得上说明另一个实例在跑(启动失败并弹通知),连不上就删掉接管。
|
||||
- **Windows 用命名管道,两端互相确认。** 管道名是整机的命名空间,不在用户自己的目录里,所以:
|
||||
- 服务端每个实例都带 DACL `D:P(A;;GA;;;<当前用户 SID>)` 和 `PIPE_REJECT_REMOTE_CLIENTS`,第一个实例带 `FILE_FLAG_FIRST_PIPE_INSTANCE`(名字已被占用就启动失败,报 `AddrInUse`)。DACL 在创建时原子生效,所以不像 Unix 那样再查对方的身份(查进程属主会误伤以管理员身份运行的 Agent)。
|
||||
- 客户端用 `SECURITY_IDENTIFICATION` 打开(抢注者不能冒充当前用户),发请求之前用 `GetSecurityInfo` 确认管道属主是当前用户或 BUILTIN\Administrators(提权运行的 ShellRS 建出来的属主是后者),否则拒绝。
|
||||
- 用同步的 Win32 管道,不用 tokio 的:请求线程要阻塞的 `File`,而 tokio 的管道是 overlapped 句柄,拿去做同步 IO 时 std 会中止进程。
|
||||
- 监听线程先建好下一个实例再把已连接的交给请求线程,否则那一刻来的客户端会看到「没运行」。请求线程结束时直接关句柄,**不要** `DisconnectNamedPipe`(会丢掉客户端还没读的数据)也不要 `FlushFileBuffers`(客户端挂住时会一直阻塞)。停止时置标志、反复以客户端身份连一下唤醒 `ConnectNamedPipe`,直到线程结束。
|
||||
- **Windows 的 PATH 是副本加用户 PATH。** `IntegrationPaths::system()` 在 Windows 上:`exe` 是 `shellrs.exe` 旁边的 `shellrs-cli.exe`,`bin_link` 是 `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe`(副本,不是链接),`user_path` 是 `UserPath::Registry`(`HKCU\Environment\Path`,写回 `REG_EXPAND_SZ` 后广播 `WM_SETTINGCHANGE`;只有之后启动的程序看得到)。副本按长度加修改时间判断是否本版(安装时用 `set_modified` 把时间设成源文件的)。正在运行的副本不能覆盖但能改名,所以安装、移除都先把旧的改名成 `shellrs.exe.<uuid>.old` 再尽量删掉。生产环境启动时 `CliIntegration::update_outdated` 在后台更新旧副本,否则 Agent 会一直碰到 `version_mismatch`。测试一律 `user_path: None` 或 `UserPath::File`,绝不碰注册表。开发时 `cargo run` 只构建 `shellrs`,要 `cargo build` 才有 `shellrs-cli.exe`,否则设置页显示找不到。
|
||||
- **Windows 只在 CI 上编译和测试**(`.github/workflows/windows.yml`:clippy 全 crate、`cargo test --lib cli::`、`cargo test --lib update::`、`cargo test --lib sftp::tests::local_listing`(本地面板的路径:std 在 Windows 上把路径规范化成 `\\?\C:\…`,读目录改用 `dunce::canonicalize`)、`cargo test --test cli_bin`),本机没有 Windows 工具链;改 `pipe_windows.rs`、`install_windows.rs` 的 Windows 部分和 `cfg(windows)` 代码后要看 CI。它不是每次推送都跑:只在推送改到 `paths` 里列的文件(含 `cfg(windows)` 等平台代码的文件、`build.rs`、`Cargo.toml` / `Cargo.lock`)时才跑,tag 不触发;在别的文件里新加 Windows 代码时把文件加进这个列表,或者在 Actions 里手动运行。
|
||||
- **每个请求一个线程**,后端方法都是阻塞的;SSH 句柄绑在创建它的 runtime 上,所以 `ssh::run_command` 和 SFTP 工作线程各自建 current-thread runtime。
|
||||
- **exec 不许挂住**:`SshPrompts` 非交互;陌生主机在 events 回调里直接返回 `false`(`ask` 没人回答会永远等下去,因为超时不计提示时间),缺凭据由 `MissingCredential` 失败。`exec(true)` 后立刻 `eof()`,远程没有 stdin;读循环读到 `Close` / `None` 为止,退出码可能在 EOF 之后才到。输出回调写失败(Agent 断开)就放弃命令、断开连接。
|
||||
- **传输无人值守**:`SshCliBackend::transfer` 先等 `Connected` 再发请求;连接前的 `Prompt` 一律回 `Cancel` 并按提示种类给出错误码;`Question` 按固定策略回答(冲突覆盖并勾选全部、续传一律重新开始、出错跳过并记为失败);以 `Idle` 为结束。请求用 `UploadRequest::scp` / `DownloadRequest::scp`:worker 连上后按 scp 规则解析目标(`scp_remote_target` / `scp_local_target`,目标目录必须存在,远程还要 `stat` 确认,因为有的服务器 realpath 不存在的路径也成功),并让新建文件保留源文件的权限位(上传整套 `0o777`,下载只补可执行位);SFTP 标签的请求不受影响。批次准备失败时 worker 也发 `Idle`。
|
||||
- **安装集成的路径保护**:`CliIntegration` 没有路径(UI 测试默认)时页面显示「暂不支持」、没有安装按钮,测试不会写到真实的 `~`;需要时用 `workspace.cli_integration()` 注入临时目录。安装 / 移除是动作(`InstallCliCommand`、`RemoveCliCommand`、`InstallAgentSkill(AgentKind)`、`RemoveAgentSkill(AgentKind)`、`RefreshCliIntegration`、`CopyAgentSkill`),由工作区在后台线程执行后 `refresh`。
|
||||
- **终端字体走全局量。** 终端模块不能依赖 settings,所以 `settings::apply` 把字体设成 `terminal::TerminalFont` 全局量并 `window.refresh()`,`TerminalView` 在 render(字体族、字号)和 prepaint(行高 `row_height`,按设备像素取整)里读它;格子变了,下一帧 prepaint 就按新尺寸 resize,已打开的终端立即生效。默认值就是加设置之前的样子:主题的等宽字体(macOS 上是 Menlo)、13 px、行高 1.54(13 × 1.54 取整后正好是原来的 20 px 行)。字体族存 `None` 表示跟随主题默认,下拉里选默认那一项也存 `None`。文件里的字体族只有装着才用(`is_font_installed`,进程内读一次字体列表;GPUI 找不到字体族时会换成别的字体),否则退回默认,文件里的值保留。数字在 `AppSettings::normalized` 里夹到范围并取整(字号整像素、行高两位小数),读文件和每次修改都过一遍;gpui-kit 的数字框用 `f64::to_string` 显示,步进 0.1 会显示 `1.7000000000000002`,store 取整后数字框下一次渲染会同步成取整后的值。字号用下拉不用数字框:数字框每敲一个字符就夹到最小值,输入 14 时敲下「1」就变成 8。字体下拉只列等宽字体:`monospace_font_families` 要加载每个字体族(macOS 上约 150 ms),在后台线程跑一次、进程内缓存,结果回来前下拉里只有默认字体;判定是「i」和「M」一样宽且窄于 0.8 em,后一条排除 Webdings 这种方块符号字体。预览 `TerminalFontPreview` 用和终端一样的排法(每格一个字形、宽字符后面补一个空格格子、终端的行高和配色),不是普通文字排版,否则中文的宽度和终端里对不上。
|
||||
- **持久化写穿,内存说了算。** `HostStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `HostStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删主机的 id 返回给工作区去关标签页。
|
||||
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,主机保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `HostStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_host` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,主机树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加主机名称第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。主机树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。
|
||||
- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `HostLogin`(`HostStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 `route: LoginRoute`——每台跳板主机的登录也查好了,`JumpLogin::Host { name, login }` 或已删除的 `JumpLogin::Deleted`;跳板自己的 `route` 一律是直连;主机表单测试连接时用 `HostStore::route_login` 把表单上的 `Route` 解析成同样的东西),不是 `Host`。`HostLogin` 只含影响登录的字段并派生 `PartialEq`(`JumpLogin` 手写 `PartialEq`、不比 `name`,跳板改名不让后面的主机重连),主机 `update` 和 `update_credential` 都在改之前取一遍**全部主机**的快照(`logins`),改完按「前后快照不相等」逐台发 `ConnectionSettingsChanged`(`emit_changed_logins`),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的主机也会重连;删除主机不发(同删除凭据,已打开的连接不断)。引用凭据的主机在内存的 `Host.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`;库里这一行的 `username` 是 NULL、`auth` 是 `credential`),store 在保存主机(`normalized`)、改凭据和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `host.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。
|
||||
- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `HostStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或主机在用时原地覆盖:路径不变,用它的主机不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。
|
||||
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。唯一不进钥匙串的是不保存的连接的密码(`SecretRef::Temporary`):只在 `TemporarySecretStore` 的内存里,随临时主机一起忘掉。
|
||||
- **id 由内存分配。** `next_host_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`HostStore::seed()` 生产环境不再使用,是 UI 测试的夹具。
|
||||
- **对外只给 `PublicId`,不给 `HostId`。** 主机右键「复制 ID」复制的是 `Host.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `hosts.public_id`(可为空,`UNIQUE`)。新建和复制主机时由 `HostStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建主机时必须带上原来的 `public_id`,`update_host` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。
|
||||
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<HostStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
|
||||
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
|
||||
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer` / `CloseLocalTerminal` / `CloseSettings`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`HostStore::recent_hosts()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把主机移到最前。
|
||||
- **终端内容区的右键菜单与查找、清屏。**
|
||||
- **菜单**:`TerminalView` 自己给出复制、粘贴、查找、清屏。面板通过 `set_menu_items` 在下面追加一段:远程终端追加打开 SFTP、重新连接、断开连接(`DisconnectTerminal` 只断这一个标签);本地终端不追加。追加段的构造器在菜单打开时运行,拿到的是事先读好的 `TerminalLifecycle`,因为这时 `TerminalView` 正在更新,读它会重入。
|
||||
- **动作**:`FindInTerminal` / `FindNextInTerminal` / `FindPreviousInTerminal` / `DismissTerminalFind` / `ClearTerminal` 和复制、粘贴一样,由工作区通过 `active_terminal` 转给当前标签的 `TerminalView`。
|
||||
- **快捷键**:⌘K 在 `Terminal` 上下文里是清屏,比全局的 `FocusSearch` 更深,所以优先生效。
|
||||
- **查找**是字面量加智能大小写(`RegexSearch` 本身就是智能大小写)。
|
||||
- 渲染时只扫视口,起止扩到所在逻辑行的首尾,每帧几十行。
|
||||
- 全量扫描只在查询变化(去抖 80 ms)和上下切换时各做一次,而且是流式计数,不收集结果:一个字母可能匹配上百万次。release 构建里一万行乘 200 列约 15–55 ms。
|
||||
- 焦点匹配按回滚历史的增长量跟着输出上移,再用 `regex_search_right` 核对,对不上就丢掉。计数不随新输出刷新。
|
||||
- **查找条**必须是终端 `div` 的兄弟元素,不能是子元素:否则输入框里敲的键会冒泡到终端的 `on_key_down`,被发给 shell。它还要 `.occlude()`,否则点它会被下面终端的鼠标处理器抢走焦点。
|
||||
- **清屏**只在本地模拟器里做:把光标所在的逻辑行(含折行)挪到第 0 行,清空回滚历史,远端不知情。`ALT_SCREEN` 下什么也不做,菜单项置灰。
|
||||
- **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。双击标题派发 `ToggleHostPanel`(显示 / 隐藏左侧栏,同 ⌘B),也挂在标题上,所有中间区标签共用;第一下单击照常选中标签,关闭按钮的单击停止冒泡,双击它不会切换侧栏。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。中间区各面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 `custom_title`)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(`RenamableTab::default_title`:终端是主机名称,SFTP 是「主机名称 · SFTP」)时回到默认,默认名随主机改名。SFTP 标签的菜单(`explorer_panel.rs` 的 `TabMenu`)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑主机…」;标签栏按钮和远程终端一样是「SFTP」(`("open-sftp", id)`)和「重新连接」(`("reconnect-sftp", id)`)。SFTP 的「重新连接」是 `ExplorerCommand::Reconnect`:已连接时先 `disconnect` 再发 `Resume`(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。
|
||||
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦主机面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效,要预先聚焦某个字段只能在 `open_dialog` 返回后的同一次更新里同步调用(见 `open_directory_dialog.rs`)。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_host_stay_closable`)。
|
||||
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`HostPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `HostPanel` 比对 `known_groups` / `seen_hosts` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
|
||||
- **侧栏三个列表的行 tooltip 都开在行的右侧,共用 `shared::RowTooltips`。** 主机、端口转发、凭据一样:悬停行 500 ms 后在行右侧弹出,配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字),第一行是要点,有补充时下面用小一号、淡一些的字(`RowTooltip::note`,最宽 20 rem、自动折行,超过 6 行省略)。主机是 `user@address:port`,下面是备注(不显示系统);转发是完整的「本地转发 · 8080 → db:3306 · web-01」(行里第二行会截断,出错时还会换成原因);凭据是行里第二行的全文「deploy · id_ed25519 · 2 台主机」,密钥凭据下面是私钥文件的完整路径。转发和凭据原来用 `ListItem::tooltip`(GPUI 自己的):贴着鼠标弹、盖住下面的行、`popover` 色,和主机列表不一样,用户要统一,统一成了主机列表的样子。只有这类 tooltip 用反色:gpui-kit 的 tooltip 和菜单、下拉共用 `popover` 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip 只开放给它自己的组件(`Button::tooltip_placement`),普通元素的 `.tooltip()` 只会贴着鼠标弹,所以每个面板持有一个 `RowTooltips<K>`(里面是一个 `gpui_kit::base::TooltipOverlay`,`overlay()` 作为面板的子元素渲染,面板收起就一起消失;动画仿 gpui-kit 自己的),渲染行时 `row(key, RowTooltip).attach(row)`:行在 `on_hover` 里带着行的边界 `request_show(...placement(Placement::Right))`,任意鼠标按下立即 `hide`,拖动中不显示。**只有 tooltip 的当前主人才能收起它**(`owner`):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 `request_hide` 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(`the_row_tooltip_follows_the_pointer_down_the_list`)。按键会收起 tooltip 是 GPUI 的 `HoverListenerMode::InputModalityAware`:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 `canvas` 在 prepaint 记下,**必须**带 `top_0().left_0()`:不设偏移的绝对定位元素会排在内容下方(gpui-base 的 `ElementExt::on_prepaint` 就是这样),tooltip 随之偏下将近一行。绝对定位是相对**直接父元素**的,而 `ListItem` 把子元素放进内容盒、`suffix` 是内容盒的兄弟,所以挂在行上的 canvas 量到的只是内容盒,不含 suffix:转发行右端的启停按钮用 `exclude` 包一层,它自己再量一次,tooltip 按两者的并集定位,否则会压在按钮上(`a_forward_rows_tooltip_opens_beside_it_and_makes_way_for_its_switch`)。`exclude` 的另一半:按钮有自己的 tooltip(「启动」「停止」),指针在按钮上时行的 tooltip 立刻收起,回到行的其他地方再出来;离开按钮时看指针还在不在行的范围里,而不是看行的悬停状态,因为往下移到下一行时按钮的「离开」先于行的「离开」触发。行里以后再加带 tooltip 的按钮,照样 `exclude`。
|
||||
- **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `HostPanel` 把 `.context_menu(..)` 挂在 `#host-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc<Cell<Option<HostNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。开始页的列表 `recent-host-list` 照样挂在容器上(右键行同时选中该行,空白处得到空菜单、不弹出);开始页是 `deferred` 画的,但 `Deferred` 的子元素在主布局阶段就做 `request_layout`,菜单抢焦点仍早于任何 prepaint,不会触发上面的断言。
|
||||
- **表单里的几选一用 `shared::SegmentedControl`。** 用户给的样式:背景色的轨道加 `border` 细边框,选中的一段是 `accent` 底色、比轨道深一级的细边框(`border` 混入 8% 的 `foreground`,浅色主题约 neutral-300,深色主题反过来变亮)和轻阴影的块(截图里的灰太深,`secondary_active` 是 neutral-300,用户要浅一些;`accent` 浅色主题是 neutral-100,深色是 neutral-800),没选中的是 `muted_foreground` 字、悬停时半透明的 `accent` 底,比选中的淡;各段等宽铺满轨道(`flex_1`),高度是中号控件的 `h_8`,选中块的圆角按 2 px 内缩与轨道同心。行为全来自 gpui-base 的 `Radio` / `RadioGroup`(点击、回车 / 空格、每段一个 Tab 停靠点、无障碍角色),这里只管外观;每段的元素 id 是它的序号,和 gpui-component 的 `RadioGroup` 一样,所以测试照旧 `within(组).click(1usize)`。gpui-base 的 `RadioGroup` 是 `RenderOnce`,不能 `test_support()`,测试里找不到轨道本身,只能量各段。主机对话框的认证方式和连接方式、凭据对话框的类型 / 私钥来源 / 算法都用它;**右侧栏工具切换列表也用它**:`shared::count_tabs` 是小号(`small()`,`h_7`)、每段带数量(`Segment::count`,淡色数字紧跟文字,无障碍名是「容器 9」)的它,外面包一个带 id 的盒子给测试量宽度,四段均分整行(用户选的),**只给标签短的 Docker 用**:系统服务的「已停止 202」在 320 px 均分的一段里放不下、被缩略,用户让它换回 gpui-kit 的下划线 `TabBar`(按文字宽度排,放不下时左右拖动)。不用 gpui-kit 的 `TabBar`:它的标签按文字宽度排,从外面给 `Tab` 加 `flex_1` 不起作用(分段样式为了滑动高亮还给每个标签包一层不伸缩的盒子),标签挤在左边、右边空一截底轨;`Tab::suffix` 又在标签居中的内容框外面,拉宽的标签里会被推到最右边(用户指出过)。`assert_tabs_share_the_width`(`tests/ui/support`)守着 Docker 的各段等宽、占满整行。详情对话框里的「状态 / 日志」「详情 / 日志」是同一对象的分页,仍用 gpui-kit 的下划线 `TabBar`。端口转发的三张类型卡片带说明和示意图,仍是 gpui-component 的 `RadioGroup`。
|
||||
- **主机对话框和转发对话框一样从窗口高度的 5% 开始、最高占 90%**,选了跳板或代理、框里的内容多起来时正文滚动、按钮不动。
|
||||
- **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `window.open_dialog` 都要 `.overlay_closable(false)`(默认是 `true`,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(`a_click_beside_a_dialog_does_not_close_it` 查主机、分组、端口转发和凭据四个)。`open_alert_dialog` 开的确认框本来就不会被点外面关掉。
|
||||
- **表单的校验错误一律弹错误通知。** 新建 / 编辑对话框(主机、分组、端口转发、凭据,SFTP 的重命名 / 新建和「打开目录」)校验不过时 `window.push_notification(form_error_notification(..))` 并让 `on_ok` 返回 `false`,不在表单底下放错误行(用户要的样子)。同一种通知新的替换旧的,不会一次次叠起来;点它就收起;每个对话框的 `on_close` 调 `dismiss_form_error`,免得对话框关了错误还挂着。从别处 `window.close_dialog` 不经过 `on_close`(凭据对话框在口令加密完后就是这样关的),那里要自己调。新加的对话框照此办理(`a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it` 逐个查)。
|
||||
- **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`DialogAction`)和回车都是 `window.dispatch_action(Confirm)`,先到焦点元素;gpui-base 的 `Select` 在收起状态下收到 `Confirm` 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」、主机对话框的「凭据」「分组」都没有绕开(`ForwardForm::focus_bind_port` 只在打开新建对话框时聚焦一次,代码里没有订阅 `SelectEvent`;「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这些下拉之后要先点一下别的字段再点 `commit`,否则后面第一次点击会被吃掉。
|
||||
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_hosts_are_read_back_from_the_database`)。
|
||||
- **通知层要 `deferred` 并高过对话框。** gpui-base 的对话框(连同半透明背景)是用 `deferred` 画的,优先级是 `10 + 对话框的层号`(`gpui-base` 的 `dialog.rs`;开始页是 0);通知层的优先级比它低就会画在对话框背景底下,被压暗,点它点到的是背景。所以工作区把 `Root::render_notification_layer` 包成 `deferred(..).with_priority(NOTIFICATION_PRIORITY)`(= `POPUP_PRIORITY - 1` = 99:高过任意层叠的对话框,低于菜单等弹出层的 `POPUP_PRIORITY` = 100 和 tooltip 的 200)。以前这里写成 1,以为对话框是 0,通知一直在对话框背景底下。无头测试里点击走真实的命中测试,被背景盖住的元素收不到点击:`a_form_error_sits_above_the_dialog_and_a_click_puts_it_away` 点主机对话框的错误通知,它在背景底下时这个测试会超时。
|
||||
- **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。
|
||||
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如主机面板里各主机系统的 `Rc<HashMap<HostId, HostOs>>`)。
|
||||
- `px(..)` 只允许出现在 API 要求 `Pixels` 的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和 `cx.theme()` token。
|
||||
### GPUI / gpui-kit
|
||||
|
||||
- **一个命令一个处理器。** 按钮、菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理,不写临时闭包直接改状态。例外是对话框内部自己的状态(预览的缩放):工作区够不着对话框里的视图,由视图自己处理。
|
||||
- **焦点。**
|
||||
- `window.dispatch_action` 只能到达焦点路径上的处理器,所以工作区里必须有东西持有焦点。
|
||||
- 永远不要聚焦工作区根节点的 handle,否则对话框的焦点陷阱拿不到焦点。
|
||||
- 中间区每个面板都要在 `set_active(true)` 里收下焦点,否则「×」、⌘W 和快捷键会静默失效。
|
||||
- 对话框关掉后焦点为空:标题栏、开始页、对话框和右侧栏的按钮一律经工作区 focus handle 的 `dispatch_action` 派发,在对话框里还要用 `window.defer` 延后。
|
||||
- 要预先聚焦对话框里的字段,只能在 `open_dialog` 返回后的同一次更新里同步调用。
|
||||
- **焦点监听器里改渲染状态要 `defer_in`**:`on_focus_in` 等在绘制的焦点阶段执行,这时 `notify` 不排新帧。
|
||||
- **后台线程不直接唤醒 GPUI 前台任务。** 工作线程的事件由 UI 定时轮询:终端和 SFTP 每 16 ms,转发和右侧栏工具每 50 ms,升级每 100 ms。等回答的 `oneshot` 在轮询的事件处理里(UI 线程)完成。
|
||||
- **渲染回调不得读取实体**(树行、`render_td`),只传快照。
|
||||
- **窗口还在搭的时候不能弹通知**:`Root` 还没挂上,会 panic。用 `notify_once_open` 延后。
|
||||
- **对话框**:
|
||||
- 每个 `open_dialog` 都要 `.overlay_closable(false)`。
|
||||
- 表单校验错误一律弹 `form_error_notification`,并在 `on_close` 里 `dismiss_form_error`。
|
||||
- 通知层要包成 `deferred`、优先级 99,高过对话框,否则被压在对话框背景底下。
|
||||
- 下拉框攥着焦点时,提交会把它弹开,这是 gpui-kit 的行为。
|
||||
- **右键菜单挂在容器上,不挂在行上**(主机树、文件列表、开始页、进程列表)。`ContextMenu` 在 `request_layout` 里抢焦点,挂在 `uniform_list` 的行上会触发「set_focus called more than once」断言。行把命中的项写进一个 `Rc<Cell<..>>`,容器在捕获阶段先清空它。行和容器不要各挂一个菜单,两个会一起弹出。
|
||||
- **滚动条挂在不滚动的外层上**,或直接用 `overflow_y_scrollbar()`:挂在滚动元素自己身上,滚动条会跟着内容一起移走。
|
||||
- **按上一帧的尺寸重排**(路径标签、监控的竖条、图片预览的画框):render 时拿不到布局尺寸。放一个 `canvas`,在 prepaint 记下尺寸,变了就 `on_next_frame` 通知重画。绝对定位的 `canvas` 必须 `top_0().left_0()`。
|
||||
- **`img` 会给自己的盒子加上图片的宽高比**,压过百分比高度。要控制尺寸就给宽和高都写绝对值。
|
||||
- **`Select` 必须放进有尺寸的容器**,否则会盖住整行。`ListItem` 里会增长的文字要 `w_0().flex_grow(1.).overflow_x_hidden()`,否则会把 `suffix` 挤出去。
|
||||
- **要彩色就不能用 `Icon`**:它把 SVG 当成单色遮罩画。系统徽章是「主题色圆底 + 单色 logo」。
|
||||
- **`px(..)` 只用在 API 要 `Pixels` 的地方**,其余用 rem 助手和 `cx.theme()` 的 token。
|
||||
- **标签页全部可关。** 关闭走 `DockArea::remove_panel`(`TabGroup::close_panel` 拒绝关最后一个)。中间区面板的 `closable()` 返回 `false`,否则「…」菜单会多一个绕过确认的「关闭」。标题用 `ClosableTabTitle`,它带标签菜单,双击切换侧栏。`panel_name()` 是持久化键,不能改。
|
||||
- **左右侧栏各是一个面板。** 左侧 Dock 里只有 `Sidebar`(三个列表),右侧 Dock 里只有 `ToolSidebar`。不用 `set_dock` 换面板,否则焦点会丢。
|
||||
- 当前标签只经 `Workspace::set_active_tab` 改,否则右侧栏不跟着变。
|
||||
- 右侧 Dock 不可折叠,否则标签栏会多出 gpui-kit 的折叠按钮;关闭时临时设成可折叠。
|
||||
- 最窄 320 px,由 `hold_tool_sidebar_width` 维持。
|
||||
- **动画用 gpui-kit 的 `animate_keyframes`**(转发示意图),不自己写播放。
|
||||
|
||||
### SSH
|
||||
|
||||
- **登录。** 所有登录都走 `SshConnector`,共享 known_hosts 写锁。known_hosts 写在 `<data_dir>/known_hosts`,不碰 `~/.ssh/known_hosts`。
|
||||
- 每次先发 `none`,再按 `LoginMethod` 走。
|
||||
- 密码和口令先查钥匙串,查不到或被拒才弹框;被拒时不删钥匙串条目。
|
||||
- 失败的说明一律经 `describe_login_error`,它沿错误链找真正的原因。
|
||||
- **算法按 OpenSSH 的默认来**:加了 `ecdh-sha2-nistp*`,素数组交换最小 2048 位,因为用户的堡垒机是老 Java 上的 Apache SSHD。没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的算法。
|
||||
- **区分断网和要人处理**:`is_network_error`,它要显式认 `russh::Error::IO`。只有网络类错误才自动重连。agent 的错误只格式化进文字,不挂进错误链。
|
||||
- **跳板。** 每台跳板开好通道就丢掉自己的 `Handle`,整条链靠下一跳攥着的通道流活着:最终连接一断,链依次关闭。问答写明是哪台跳板;跳板和代理的失败包一层 `RouteFailure`。
|
||||
- **外部连接只开一个通道**(`shell_only`)。用户的堡垒机在同一连接上多开一个通道就会结束会话,所以不做系统探测,旁路 exec 直接拒绝。以后给 SFTP 加别的通道,也要对 `is_external` 关掉。
|
||||
- **右侧栏的工具在终端自己的连接上执行命令**(`TerminalTransportCommand::Exec`),不另起登录。
|
||||
- 一次一条,10 秒超时,输出最多 1 MB。
|
||||
- 命令写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`,因为要经得过 bash、zsh、fish、csh。
|
||||
- 拼进命令的名字先过 `valid_*`。
|
||||
- 各工具都有用本机 `sh` 真跑脚本的单元测试。
|
||||
- **每次连上都重探主机系统**(`uname` / `os-release`,Windows 再试 `cmd /c ver`),结果经 `HostStore::set_host_os` 入库。往返延迟每 5 秒量一次(`keepalive@openssh.com`)。
|
||||
|
||||
### SFTP
|
||||
|
||||
- **每个标签独立连接、独立传输引擎。** 引擎一次只跑一批,其余在 `TransferQueue` 里排队,引擎的 `Idle` 推动队列前进;进度只归队首;停止的批次挡在队首,等用户继续或移出。
|
||||
- **SFTP 独立于终端。** 关闭终端不停传输;断开或删除主机会停止传输,并保留续传数据。
|
||||
- **下载严格按偏移顺序写 `.filepart`**,因为续传只信它的长度。
|
||||
- **文件操作从不跟随符号链接**:删除只删链接本身,改权限跳过链接,传输原样创建链接。
|
||||
- **空闲时也要发现断网**:通道流包一层 `WatchedStream`。等待时只持有 `Weak`,不能攥着 `Arc<SftpClient>`,否则「断开」关不掉连接。
|
||||
- **选择归面板,不归表格。** `DataTable` 设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`。「名称」单元格才是项目,拖文件只从图标和文件名开始,其余地方按下拖动是框选。
|
||||
- GPUI 会把同一类型的拖动通知给所有监听者,所以框选的监听要核对是不是自己发起的。
|
||||
- 快速一甩时拖动的开始和松开落在同一帧,起点仍会收到单击,用 `ends_a_drag` 排除。
|
||||
- 文件列表的快捷键按焦点路由(`ExplorerShortcut`,找 `contains_focus` 的那个标签),不按最近激活的标签。
|
||||
- **工具栏永远一行高**,放不下的按钮整个藏起来。新标签两侧对半分,改工具栏宽度时要重算放不放得下。分栏状态归标签(`ResizableState`)。读目录不挪动列表;出的问题写在窗口左下角(`status-connection`)。
|
||||
- **断开时**,对远程侧的操作要弹「SFTP 连接已断开 / 重新连接」。
|
||||
- **编辑器和预览:先读后开**,读写走 SFTP 标签自己的连接。
|
||||
- `ReadFile`、`ReadBytes`、`WriteFile` 和 `List` / `Operate` 一样另起任务,和传输并行。
|
||||
- 文件读成功才开标签或对话框;读不了的在 `refuse_open` 说明,远程的给「下载…」。
|
||||
- 查重在工作区(`editor_for_location`):已开就切过去。
|
||||
- **编辑器写回**:`open_replace` 截断写,保存前核对 `FileStamp`(大小加修改时间)。写到一半失败是 `SaveFailure::Interrupted`:作为 context 包在原错误外面,所以仍认得出断网;这时作废戳,下一次保存不再核对。
|
||||
- **编辑器的文本规则**:只认 UTF-8;所有换行都是 CRLF 才转成 LF 给编辑器、保存时换回,BOM 原样保留。⌘S 绑在 `FileEditor` 上下文。
|
||||
- **编辑器的关闭和退出**:关 SFTP 标签会带走它的远程编辑器;批量关闭、关 SFTP 标签、退出时有未保存的文件,只问一次。
|
||||
- 退出守卫是全局量(`app::set_quit_guard`):对话框关掉后焦点为空,`Quit` 到不了工作区的元素。Windows / Linux 点窗口的关闭按钮也走它。
|
||||
- macOS 程序坞右键退出、注销关机,以及「重启并安装」都拦不住。
|
||||
- **高亮语言**就是 `Cargo.toml` 给 gpui-kit 开的 `tree-sitter-*` 特性;`language_for` 只能给出这些名字。
|
||||
- **预览的图片**:
|
||||
- `ImagePreview` 自己在后台解码,所以解不开时能说出来;用 `ImageSource::Render` 画,关掉时 `drop_image`。
|
||||
- 缩放的四个动作由它自己处理(`ImagePreview` 上下文:⌘= / ⌘- 放大缩小,⌘0 原图,⌘9 适合窗口)。
|
||||
- 打开后在同一次更新里把焦点交给它,快捷键才有落点。
|
||||
|
||||
### 端口转发
|
||||
|
||||
- **规则在 store,运行状态在 `ForwardManager`。** 管理器订阅 store:规则删了就停,设置变了就重启。转发不计入主机的连接状态。
|
||||
- **认证问答**归属 `PromptOwner::Forward(id, 代次)`,对话框开头写明是哪条转发;取消问答就等于停止。
|
||||
- **发现断线靠处理器被丢弃**,不靠 `Handler::disconnected`:russh 有时会跳过它。远程转发先连上本机目标,再 `accept` 通道。
|
||||
- **本地和动态转发先监听再登录**,监听器跨重连保留。停止时要 `handle.disconnect`。停止中的运行占着自己的位置(`Link::stopping`),否则重启会撞上「端口已被占用」。
|
||||
- **首次登录可以问,重连不问**:重连非交互,陌生主机直接答否;只对网络类错误重试。
|
||||
|
||||
### 外部 CLI、升级、发布、平台
|
||||
|
||||
- **外部 CLI 经正在运行的应用执行**:命令本身不读钥匙串、不开数据库。
|
||||
- 服务端一直监听,按「启用外部 CLI」开关拒绝。
|
||||
- Unix 套接字 0600,并用 `peer_cred` 核对 uid。
|
||||
- Windows 用命名管道:带 DACL 和 `FIRST_PIPE_INSTANCE`;客户端以 `SECURITY_IDENTIFICATION` 打开,并核对属主;用同步的 Win32 管道;关句柄时不 `DisconnectNamedPipe`。
|
||||
- exec 和传输都不许挂住:非交互,陌生主机直接答否,问题按固定策略回答。
|
||||
- `exec --json` 的输入输出都是 JSON,输出只用 ASCII(`ascii_json` 转义其余字符),出错也是 JSON:给 Windows PowerShell 用,绕开引号和代码页。不做 MCP:主流 Agent 都能执行命令,CLI 加 skill 已经够用。
|
||||
- UI 测试不监听真实套接字,`CliIntegration` 默认没有路径。
|
||||
- **Windows**:
|
||||
- 发布版是 GUI 子系统,命令改由 `shellrs-cli.exe` 承担;PATH 里放的是副本,加进用户 PATH。
|
||||
- `main` 先用 `link_arguments` 认堡垒机的链接参数,认出来就不走 CLI。
|
||||
- Windows 只在 CI 上编译和测试:`windows.yml` 按 `paths` 触发,新加 `cfg(windows)` 代码的文件要加进那个列表。
|
||||
- **在线升级**:
|
||||
- `new_with_services` 建的 `Updater` 没有服务(测试从不联网),生产路径再 `set_services` 加 `start`。
|
||||
- 重启走 `set_restart_path` 加 `restart`。Windows 不在退出过程中起进程,交给 `run` 返回之后的 `start_handed_over`。
|
||||
- **发布走 tag** `v<版本>`(`release.yml`,需要人工批准,清单最后才上传)。`CHANGELOG.md` 那一节由改版本号的提交写,功能提交不改。一次性准备见 `docs/release.md`。
|
||||
- **macOS 上关窗口是隐藏**(`hide_when_closed`),⌘Q 才退出。测试平台的 `hide()` 没实现,所以这条只挂在生产路径上。
|
||||
|
||||
### 终端
|
||||
|
||||
- **查找条必须是终端 `div` 的兄弟**,不能是子元素,否则输入会被发给 shell;它还要 `.occlude()`。
|
||||
- **清屏只在本地模拟器里做**,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的 `FocusSearch` 深一层,所以优先。
|
||||
- **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。
|
||||
- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(`theme.blue`,主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。
|
||||
- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。
|
||||
- **OSC 52 只写不读**(`Osc52::OnlyCopy`),只认剪贴板 `c`,主选择区忽略。
|
||||
- **通知**:OSC 9 / 777 由 `terminal/notices.rs` 的扫描器在解析线程上认(alacritty 会丢掉),OSC 9 里「数字;」开头的是 ConEmu 的命令,不算通知。投递在 `workspace/notices.rs`:窗口不在前台走 GPUI 的 `show_system_notification`(tag 是 `terminal:local:<id>` / `terminal:remote:<id>`,点击回调按 tag 切标签),在前台走应用内通知。`shellrs::init` 里的 `set_app_identity` 用 bundle id,测试平台没有它就不发系统通知;macOS 上 `cargo run` 没有 bundle,看不到系统通知。
|
||||
|
||||
## 测试约定
|
||||
|
||||
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/main.rs`,`cargo test --test ui`)按功能分模块,共用的夹具和假实现在 `support/`。只有一个模块用的助手就放在那个模块里。不要在 `tests/` 下另开测试文件:每个文件都要再链接一遍 GPUI。
|
||||
- **全部注入。** `Workspace::new_with_services` 注入终端、SFTP、本地目录、测试连接和端口转发的假实现。测试不碰用户的服务器、钥匙串(用 `InMemorySecretStore`)、设置文件(用 `SettingsStore::in_memory()`)、真实套接字和网络。`HostStore::seed()` 是夹具:种子主机按插入顺序编号(`web-01` = 1 … `dev-box` = 6),分组也一样(生产 = 1、测试 = 2、开发 = 3);`web-01` 和 `staging-api` 初始已连接,并打开了终端标签。
|
||||
- **元素定位。** 被测试查询的元素带稳定的领域 id,自定义的 `div` 还要加 `.test_support()`。id 从源码或已有测试里找,这里不列。
|
||||
- **不要用右键菜单驱动测试**:菜单项的 id 只是序号,`PopupMenu` 还会让测试报「leaked handles」。直接派发菜单会派发的那个动作。测试模块里显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。
|
||||
- **异步与时序。**
|
||||
- 动作派发和 `set_active` 都是延迟执行的:先 `run_until_parked` 再 `render_frame`,然后断言。
|
||||
- 工作线程的事件要推进时钟(`advance_clock`)或用 `cx.wait_for` 等。
|
||||
- 夹具一律 `set_reduce_motion(true)`。
|
||||
- GPUI 只给激活的窗口发焦点事件,需要时先 `activate_window`。
|
||||
- 按上一帧尺寸重排的元素,要 `simulate_next_frame` 才会重画。
|
||||
- 对话框关掉后焦点是空的,接着派发动作前先点一下面板。
|
||||
- **看不到的东西换个法子查。**
|
||||
- 被遮蔽的输入框读不到值,密码一律查 `InMemorySecretStore`。
|
||||
- 按钮的禁用状态用「是否还能取得焦点」判断。
|
||||
- ⌘ / Shift 单击用 `modified_click`。
|
||||
- 测试的文字系统每个字符都是 0.6 em(真实中文是 1 em),靠文字宽度撑出的布局问题在测试里看不出来。
|
||||
- **单元测试起子进程一律经 `crate::testing`**(`sh`、`sh_accepts`、`no_forks()`),免得子进程继承别的测试刚建的套接字。
|
||||
- **测试里的二进制常量**(PNG 等)用脚本生成,别手抄:抄错一个字节就解不开。
|
||||
Generated
+196
-4
@@ -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",
|
||||
]
|
||||
@@ -7686,7 +7702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -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"
|
||||
|
||||
+38
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
@@ -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. Images and Markdown can be previewed.
|
||||
- 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 原地写回服务器,保存前检查文件是否被别人改过;常见格式语法高亮。图片和 Markdown 可以预览。
|
||||
- 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。
|
||||
|
||||
**从堡垒机打开**
|
||||
|
||||
+36
-4
@@ -127,6 +127,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
```sh
|
||||
shellrs list [--query <关键词>] [--json] # 列出主机;输出不是终端时自动用 JSON
|
||||
shellrs exec <ID> "<命令>" # 或 --stdin 从标准输入读命令
|
||||
shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON
|
||||
shellrs upload <ID> <本地路径> <远程路径>
|
||||
shellrs download <ID> <远程路径> <本地路径>
|
||||
```
|
||||
@@ -135,7 +136,8 @@ shellrs download <ID> <远程路径> <本地路径>
|
||||
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
|
||||
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里,多行或带引号的命令用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
|
||||
- `exec --json` 从标准输入读 `{"host": "<ID>", "command": "<命令>"}`,命令结束后输出 `{"exit_code", "stdout", "stderr"}`,出错时输出 `{"error": {"code", "message"}}`,退出码和不加 `--json` 时一样。输出只含 ASCII,中文等字符都转成 `\uXXXX`;不是 UTF-8 的输出换成 U+FFFD。命令不经过本机 shell 的引号处理,控制台的代码页也不会把输出弄乱,适合 Windows PowerShell 和需要分开读标准输出、标准错误的 Agent。
|
||||
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里执行命令优先用 `exec --json`(`@{ host = '<ID>'; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`);要边执行边看输出时(长时间构建、大量日志),改用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`),并和 `list`、`upload`、`download` 一样,先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`,否则中文会变成问号或乱码。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
|
||||
|
||||
## 从堡垒机打开(外部连接)
|
||||
|
||||
@@ -171,6 +173,10 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
|
||||
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
|
||||
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
|
||||
- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。
|
||||
- **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键始终是 ShellRS 的菜单。
|
||||
- **通知**:程序可以请求通知(OSC 9,或 OSC 777 的 `notify;标题;正文`),终端响铃时也会提醒。ShellRS 不在前台时发系统通知(macOS 第一次会询问是否允许),点通知回到那个终端;ShellRS 在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。程序请求的通知即使终端就在眼前也会提示;响铃只在看不见那个终端时提醒(Tab 补全等也会响铃)。通知标题是标签名,看得出是哪台主机发的。同一终端的程序通知 2 秒内、响铃 10 秒内只提醒一次。设置 › 终端 › 通知可以分别关掉。命令跑完想被提醒,可以在后面加上 `; printf '\e]9;完成\a'` 或 `; tput bel`;Claude Code 等命令行工具需要你处理时也会这样提醒。
|
||||
- **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
|
||||
|
||||
## 右侧栏
|
||||
|
||||
@@ -224,6 +230,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
|
||||
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。
|
||||
- 书签:点按钮打开「打开目录/书签」(见下);旁边的下拉箭头列出这台主机这一侧的书签,点击即前往,也可添加或移除当前路径。书签保存在本地数据库,删除主机时一并删除。
|
||||
- 上级目录、根目录、主目录、刷新、后退、前进。
|
||||
- 显示 / 不显示隐藏文件:名称以 `.` 开头的文件和目录。睁开的眼睛表示正在显示,划掉的眼睛表示不显示。本地和远程各自一个开关,互不影响;每一侧的选择对所有 SFTP 标签生效,保存在设置里,默认都不显示。不显示时面板底部写明「隐藏 N 项」;显示时隐藏文件的文字颜色浅一些。
|
||||
- 第二行(文件):
|
||||
- 远程是「下载…」,本地是「上传…」,下拉里还有「选择文件上传…」。
|
||||
- 删除、重命名…、属性…、新建(文件夹… / 文件…)。
|
||||
@@ -265,8 +272,8 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
|
||||
- 框选:在名称右边的空白、大小、类型、时间等列或列表下方按下拖动,画出选框,框住的行被选中;按住 ⌘ 或 Shift 开始拖动则加到已有选择上。拖到列表上下边缘时自动滚动。拖动文件名才是把文件拖到另一侧传输:按下未选中的文件名时只选中它(其他选择取消),拖走的就是它一个;按下已选中的文件名则拖走全部已选。拖动时只有到了另一侧列表上才显示「上传 / 下载 N 个项目」,在自己的列表或其他不能放下的地方,指针显示为禁止。
|
||||
- 键盘:↑↓、Home、End、PageUp、PageDown 移动,加 Shift 扩展;空格或 Insert 切换当前行;⌘A 全选;Tab 切到另一侧。
|
||||
- 右键未选中的名称格会先选中它;右键空白处取消选择并弹出空白处菜单。
|
||||
- 文件名上的菜单:打开、上传… / 下载…、删除、重命名…、属性…。
|
||||
- 空白处的菜单:前往(上级 / 根 / 主目录 / 后退 / 前进)、刷新、添加路径到书签、新建。
|
||||
- 文件名上的菜单:编辑(文件;图片、SVG 和 Markdown 另有预览,SVG 以外的图片只有预览)、上传… / 下载…、复制路径(选中项目的完整路径,多个时每行一个)、删除、重命名…、属性…。目录双击进入,菜单里没有「打开」。
|
||||
- 空白处的菜单:前往(上级 / 根 / 主目录 / 后退 / 前进)、刷新、显示隐藏文件(勾选表示这一侧显示)、添加路径到书签、新建。
|
||||
|
||||
### 快捷键
|
||||
|
||||
@@ -277,14 +284,16 @@ 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 |
|
||||
| 显示 / 不显示隐藏文件(按所在面板) | macOS ⌘⇧.,其他平台 Ctrl+Alt+H |
|
||||
|
||||
### 文件操作
|
||||
|
||||
@@ -302,6 +311,29 @@ 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 等其他编码的文件会说明原因、不打开,远程文件可以改用「下载…」。
|
||||
|
||||
### 预览
|
||||
|
||||
图片(PNG、JPG、GIF、WebP、SVG、BMP、ICO、TIFF)和 Markdown 可以预览,远程和本地的都行:
|
||||
|
||||
- 打开:双击图片;Markdown 和 SVG 是文本,双击是编辑,预览用右键「预览」。
|
||||
- 预览是一个占窗口大部分的对话框,Esc、右上角的关闭或「关闭」按钮关掉。Markdown 排版后显示,可以滚动、选中复制,点链接用浏览器打开。
|
||||
- 图片默认「适合窗口」:按原比例缩小到放得下(小图不放大),居中显示;上方写像素尺寸、文件大小和当前比例。放大、缩小按钮(或 ⌘= / ⌘-,其他平台 Ctrl)按 10%–800% 的档位缩放,按住 ⌘(Ctrl)滚动滚轮或触控板可以连续缩放;「原图」(⌘0)按一个图片像素一个点显示,「适合窗口」(⌘9)回到放得下的大小;双击图片在两者之间切换。图片比预览区大时可以上下左右滚动查看,缩放时画面中间的位置保持不动。
|
||||
- 下方的「下载…」把远程文件下载到本机(打开下载确认框),Markdown 的「编辑」打开编辑器(已打开就切过去)。
|
||||
- 预览的是服务器或磁盘上的文件,编辑器里没保存的修改不会出现在预览里。
|
||||
- 限制:图片 20 MB 以内,Markdown 5 MB 以内且是 UTF-8;更大的会说明原因,远程文件可以改用「下载…」。
|
||||
|
||||
### 上传与下载
|
||||
|
||||
- 入口:
|
||||
|
||||
@@ -60,8 +60,11 @@ Source: "{#BuildDir}\shellrs.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#BuildDir}\shellrs-cli.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\ShellRS"; Filename: "{app}\shellrs.exe"
|
||||
Name: "{autodesktop}\ShellRS"; Filename: "{app}\shellrs.exe"; Tasks: desktopicon
|
||||
; The AppUserModelID ShellRS gives itself (`set_app_identity`, for its
|
||||
; notifications): the same here keeps a pinned shortcut and the running
|
||||
; window one taskbar button.
|
||||
Name: "{autoprograms}\ShellRS"; Filename: "{app}\shellrs.exe"; AppUserModelID: "com.shellrs.ShellRS"
|
||||
Name: "{autodesktop}\ShellRS"; Filename: "{app}\shellrs.exe"; AppUserModelID: "com.shellrs.ShellRS"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
; Interactive installs only: an update is restarted by ShellRS's own helper,
|
||||
|
||||
+80
-2
@@ -6,7 +6,8 @@ use gpui_kit::*;
|
||||
use crate::{
|
||||
cli::AgentKind,
|
||||
docker::{ContainerCommand, DockerObject},
|
||||
explorer::{ExplorerId, FileSizeFormat},
|
||||
editor::EditorId,
|
||||
explorer::{ExplorerId, FileSizeFormat, PaneSide},
|
||||
host::{
|
||||
CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop, SnippetCategoryId, SnippetId,
|
||||
},
|
||||
@@ -114,6 +115,14 @@ gpui_kit::actions!(
|
||||
ZoomReset,
|
||||
/// Quit the application.
|
||||
Quit,
|
||||
/// Show the previewed image larger, by one step.
|
||||
ZoomPreviewIn,
|
||||
/// Show the previewed image smaller, by one step.
|
||||
ZoomPreviewOut,
|
||||
/// Fit the previewed image into its frame.
|
||||
FitPreview,
|
||||
/// Show the previewed image at its own size (100%).
|
||||
ActualSizePreview,
|
||||
/// Put the `shellrs` command on the PATH.
|
||||
InstallCliCommand,
|
||||
/// Take the `shellrs` command off the PATH.
|
||||
@@ -197,6 +206,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);
|
||||
|
||||
@@ -207,6 +219,9 @@ id_actions! {
|
||||
|
||||
/// Show the SFTP 大小 column in another format, from its title's menu.
|
||||
SetFileSizeFormat(FileSizeFormat);
|
||||
/// Show the dot files of one side of the SFTP tabs, or stop showing
|
||||
/// them: that side of every tab, kept in the settings.
|
||||
ToggleHiddenFiles(PaneSide);
|
||||
|
||||
/// Show a tool in the right sidebar, or hide the sidebar if it is
|
||||
/// already showing that tool.
|
||||
@@ -338,6 +353,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,17 +439,35 @@ 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>,
|
||||
},
|
||||
/// 预览: show an image or a Markdown file, `path` or else the one under
|
||||
/// the cursor.
|
||||
Preview {
|
||||
remote: bool,
|
||||
path: Option<String>,
|
||||
},
|
||||
/// WinSCP's 打开目录/书签 dialog: type a directory or pick a bookmark.
|
||||
OpenDirectory {
|
||||
remote: bool,
|
||||
},
|
||||
/// 复制路径 on the path label: the directory shown.
|
||||
CopyPath {
|
||||
remote: bool,
|
||||
},
|
||||
/// 复制路径 on a row: the selected rows' full paths, one per line.
|
||||
CopySelectedPaths {
|
||||
remote: bool,
|
||||
},
|
||||
/// Move keyboard focus to a pane's file list.
|
||||
FocusPane {
|
||||
remote: bool,
|
||||
@@ -555,6 +589,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,12 @@ icon_assets!(
|
||||
Ban,
|
||||
CircleDot,
|
||||
FileText,
|
||||
FilePenLine,
|
||||
Save,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Zap,
|
||||
ExternalLink
|
||||
]
|
||||
|
||||
+39
-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,10 @@ 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";
|
||||
/// Key context of the image in a preview.
|
||||
pub const IMAGE_PREVIEW_CONTEXT: &str = "ImagePreview";
|
||||
pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList";
|
||||
pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList";
|
||||
|
||||
@@ -36,7 +42,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 +86,21 @@ 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),
|
||||
),
|
||||
// Over a previewed image, the interface zoom keys zoom the image;
|
||||
// 0 and 9 as in macOS Preview.
|
||||
KeyBinding::new(&primary("="), ZoomPreviewIn, Some(IMAGE_PREVIEW_CONTEXT)),
|
||||
KeyBinding::new(&primary("-"), ZoomPreviewOut, Some(IMAGE_PREVIEW_CONTEXT)),
|
||||
KeyBinding::new(
|
||||
&primary("0"),
|
||||
ActualSizePreview,
|
||||
Some(IMAGE_PREVIEW_CONTEXT),
|
||||
),
|
||||
KeyBinding::new(&primary("9"), FitPreview, Some(IMAGE_PREVIEW_CONTEXT)),
|
||||
KeyBinding::new("shift-escape", ToggleZoom, None),
|
||||
KeyBinding::new("enter", ConnectSelected, Some(HOST_PANEL_CONTEXT)),
|
||||
KeyBinding::new("enter", ConnectSelected, Some(RECENT_HOSTS_CONTEXT)),
|
||||
@@ -155,6 +176,21 @@ fn file_list_key_bindings() -> Vec<KeyBinding> {
|
||||
let bind = |keys: &str, command: ExplorerCommand, context: &str| {
|
||||
KeyBinding::new(keys, ExplorerShortcut(command), Some(context))
|
||||
};
|
||||
// 显示隐藏文件: Finder's ⌘⇧. (macOS reports it as ⌘>), WinSCP's
|
||||
// Ctrl+Alt+H elsewhere.
|
||||
let side = crate::explorer::PaneSide::from_remote(remote);
|
||||
#[cfg(target_os = "macos")]
|
||||
bindings.push(KeyBinding::new(
|
||||
"cmd->",
|
||||
ToggleHiddenFiles(side),
|
||||
Some(context),
|
||||
));
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
bindings.push(KeyBinding::new(
|
||||
"ctrl-alt-h",
|
||||
ToggleHiddenFiles(side),
|
||||
Some(context),
|
||||
));
|
||||
// Several keys per command: the last one registered is the one
|
||||
// tooltips show, so WinSCP's key goes last.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -207,6 +243,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.
|
||||
|
||||
+12
-9
@@ -41,23 +41,26 @@ shellrs download <host-id> /tmp/app.tar.gz ./latest.tar.gz
|
||||
|
||||
## Windows
|
||||
|
||||
The same commands work in PowerShell, cmd, and Git Bash. Local paths may be Windows paths (`.\dist`, `C:\Users\me\app.tar.gz`); remote paths are still POSIX paths.
|
||||
The same commands work in PowerShell, cmd, and Git Bash. Local paths may be Windows paths (`.\dist`, `C:\Users\me\app.tar.gz`); remote paths are still POSIX paths. In Git Bash, use the heredoc form above.
|
||||
|
||||
In PowerShell, pass multi-line commands and commands containing quotes through stdin with a here-string instead of a heredoc. Windows PowerShell 5.1 breaks double quotes inside arguments, so do not rely on them there:
|
||||
In PowerShell, prefer `exec --json`: build the request as an object, pipe it in, and read the result as JSON. Nothing in the command needs quoting (Windows PowerShell 5.1 breaks double quotes inside arguments), and the result is plain ASCII, so the console's code page cannot garble remote output:
|
||||
|
||||
```powershell
|
||||
@{ host = '<host-id>'; command = 'grep -c "error" /var/log/app.log' } | ConvertTo-Json -Compress | shellrs exec --json | ConvertFrom-Json
|
||||
```
|
||||
|
||||
`exec --json` reads `{"host": "<host-id>", "command": "<command>"}` from stdin and, once the command ends, prints `{"exit_code": N, "stdout": "...", "stderr": "..."}`, or `{"error": {"code": "...", "message": "..."}}` when it could not run it. The exit code is the same as without `--json`, in `$LASTEXITCODE`. Windows PowerShell 5.1 pipes text to programs as ASCII: write non-ASCII characters in the command as `\uXXXX` escapes.
|
||||
|
||||
`--json` prints nothing until the command ends. To see the output as it comes (a long build, a large log), pass the command through stdin with a here-string instead, after making PowerShell read and write UTF-8:
|
||||
|
||||
```powershell
|
||||
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
|
||||
@'
|
||||
<command>
|
||||
'@ | shellrs exec <host-id> --stdin
|
||||
```
|
||||
|
||||
Before running `shellrs` in PowerShell, make it read and write UTF-8, or non-ASCII text (such as the Chinese names of saved hosts and remote output) turns into `?` or mojibake:
|
||||
|
||||
```powershell
|
||||
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
|
||||
```
|
||||
|
||||
The exit code is in `$LASTEXITCODE`.
|
||||
The `@'` must end its line and the `'@` must start one. Set the same encodings before `list`, `upload` and `download`, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake.
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
+51
-1
@@ -29,17 +29,54 @@ pub struct Console<'a> {
|
||||
pub stderr: &'a mut dyn Write,
|
||||
/// JSON instead of text, asked for or implied by a pipe.
|
||||
pub json: bool,
|
||||
/// `exec --json`: the output gathered into one ASCII JSON object, and
|
||||
/// errors in JSON as well.
|
||||
pub exec_json: bool,
|
||||
/// Progress lines only make sense on a terminal.
|
||||
pub stderr_is_terminal: bool,
|
||||
}
|
||||
|
||||
impl Console<'_> {
|
||||
pub fn error(&mut self, code: ErrorCode, message: &str) -> i32 {
|
||||
let _ = writeln!(self.stderr, "shellrs: [{}] {message}", code.as_str());
|
||||
if self.exec_json {
|
||||
let error = serde_json::json!({
|
||||
"error": { "code": code.as_str(), "message": message }
|
||||
});
|
||||
let _ = writeln!(self.stdout, "{}", ascii_json(&error));
|
||||
} else {
|
||||
let _ = writeln!(self.stderr, "shellrs: [{}] {message}", code.as_str());
|
||||
}
|
||||
FAILURE_EXIT
|
||||
}
|
||||
}
|
||||
|
||||
/// What `exec --json` prints once the command ends. Output that is not
|
||||
/// UTF-8 has U+FFFD in place of what is not.
|
||||
#[derive(serde::Serialize)]
|
||||
struct ExecResult<'a> {
|
||||
exit_code: i32,
|
||||
stdout: std::borrow::Cow<'a, str>,
|
||||
stderr: std::borrow::Cow<'a, str>,
|
||||
}
|
||||
|
||||
/// `value` as JSON in ASCII alone: every other character escaped, which
|
||||
/// only ever happens inside strings. PowerShell and the Windows console
|
||||
/// read it right whatever code page they assume.
|
||||
pub(super) fn ascii_json(value: &impl serde::Serialize) -> String {
|
||||
let json = serde_json::to_string(value).unwrap_or_default();
|
||||
let mut ascii = String::with_capacity(json.len());
|
||||
for character in json.chars() {
|
||||
if character.is_ascii() {
|
||||
ascii.push(character);
|
||||
} else {
|
||||
for unit in character.encode_utf16(&mut [0; 2]) {
|
||||
ascii.push_str(&format!("\\u{unit:04x}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
ascii
|
||||
}
|
||||
|
||||
/// Send `request` to the app listening at `endpoint` and print the answer;
|
||||
/// the exit code.
|
||||
pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 {
|
||||
@@ -170,11 +207,15 @@ where
|
||||
let mut reader = BufReader::new(stream);
|
||||
// The progress line on screen, to be blanked before anything else.
|
||||
let mut progress = ProgressLine::default();
|
||||
// What `exec --json` holds until the command ends.
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
loop {
|
||||
let Some((kind, payload)) = read_frame(&mut reader)? else {
|
||||
return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into());
|
||||
};
|
||||
match kind {
|
||||
FrameKind::Stdout if console.exec_json => stdout.extend_from_slice(&payload),
|
||||
FrameKind::Stderr if console.exec_json => stderr.extend_from_slice(&payload),
|
||||
FrameKind::Stdout => {
|
||||
console.stdout.write_all(&payload)?;
|
||||
console.stdout.flush()?;
|
||||
@@ -199,6 +240,15 @@ where
|
||||
return Ok(0);
|
||||
}
|
||||
Reply::TransferDone(summary) => return Ok(print_summary(&summary, console)?),
|
||||
Reply::Exit { code } if console.exec_json => {
|
||||
let result = ExecResult {
|
||||
exit_code: code,
|
||||
stdout: String::from_utf8_lossy(&stdout),
|
||||
stderr: String::from_utf8_lossy(&stderr),
|
||||
};
|
||||
writeln!(console.stdout, "{}", ascii_json(&result))?;
|
||||
return Ok(code);
|
||||
}
|
||||
Reply::Exit { code } => return Ok(code),
|
||||
Reply::Activated => return Ok(0),
|
||||
Reply::Error { code, message } => return Ok(console.error(code, &message)),
|
||||
|
||||
+52
-13
@@ -73,14 +73,22 @@ enum Command {
|
||||
/// remote command's.
|
||||
Exec {
|
||||
/// Host ID, from `shellrs list`.
|
||||
id: String,
|
||||
#[arg(required_unless_present = "json")]
|
||||
id: Option<String>,
|
||||
/// One complete remote shell command, quoted as one argument.
|
||||
#[arg(required_unless_present = "stdin")]
|
||||
#[arg(required_unless_present_any = ["stdin", "json"])]
|
||||
command: Option<String>,
|
||||
/// Read the command from stdin instead, for commands with quotes,
|
||||
/// pipes, `$` or several lines.
|
||||
#[arg(long, conflicts_with = "command")]
|
||||
stdin: bool,
|
||||
/// Read {"host": ID, "command": COMMAND} from stdin and print
|
||||
/// {"exit_code", "stdout", "stderr"} once the command ends, errors as
|
||||
/// {"error": {"code", "message"}}. The output is ASCII, everything
|
||||
/// else escaped: no shell quoting, and no console code page can
|
||||
/// garble it.
|
||||
#[arg(long, conflicts_with_all = ["id", "command", "stdin"])]
|
||||
json: bool,
|
||||
},
|
||||
/// Copy a local file or folder to a saved host.
|
||||
///
|
||||
@@ -134,6 +142,7 @@ pub fn main(args: Vec<OsString>) -> i32 {
|
||||
stdout: &mut stdout,
|
||||
stderr: &mut stderr,
|
||||
json,
|
||||
exec_json: false,
|
||||
stderr_is_terminal,
|
||||
};
|
||||
let code = match request(cli.command, &mut console) {
|
||||
@@ -153,23 +162,27 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
|
||||
console.json |= json;
|
||||
Request::List { query }
|
||||
}
|
||||
Command::Exec { id, command, stdin } => {
|
||||
let command = if stdin {
|
||||
let mut text = String::new();
|
||||
if let Err(error) = std::io::stdin().read_to_string(&mut text) {
|
||||
return Err(
|
||||
console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}"))
|
||||
);
|
||||
}
|
||||
text
|
||||
Command::Exec {
|
||||
id,
|
||||
command,
|
||||
stdin,
|
||||
json,
|
||||
} => {
|
||||
// From here on, errors are JSON too.
|
||||
console.exec_json = json;
|
||||
let (host, command) = if json {
|
||||
exec_request(&read_stdin(console)?)
|
||||
.map_err(|message| console.error(ErrorCode::BadRequest, &message))?
|
||||
} else if stdin {
|
||||
(id.unwrap_or_default(), read_stdin(console)?)
|
||||
} else {
|
||||
command.unwrap_or_default()
|
||||
(id.unwrap_or_default(), command.unwrap_or_default())
|
||||
};
|
||||
let command = normalize_command(&command);
|
||||
if command.trim().is_empty() {
|
||||
return Err(console.error(ErrorCode::BadRequest, "命令不能为空"));
|
||||
}
|
||||
Request::Exec { host: id, command }
|
||||
Request::Exec { host, command }
|
||||
}
|
||||
Command::Upload {
|
||||
id,
|
||||
@@ -200,6 +213,32 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_stdin(console: &mut Console) -> Result<String, i32> {
|
||||
let mut text = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|error| {
|
||||
console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}"))
|
||||
})?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// What `exec --json` reads.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ExecJson {
|
||||
host: String,
|
||||
command: String,
|
||||
}
|
||||
|
||||
/// The host and command of an `exec --json` request.
|
||||
fn exec_request(text: &str) -> Result<(String, String), String> {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let request: ExecJson = serde_json::from_str(text).map_err(|error| {
|
||||
format!("标准输入不是 {{\"host\": ..., \"command\": ...}} 形式的 JSON:{error}")
|
||||
})?;
|
||||
Ok((request.host, request.command))
|
||||
}
|
||||
|
||||
/// A command as the remote shell should see it. PowerShell ends every line
|
||||
/// it pipes to a program with CRLF, and a POSIX shell would take the CR as
|
||||
/// part of the command; a BOM would be the start of the first word.
|
||||
|
||||
+93
-1
@@ -9,12 +9,13 @@ use std::{
|
||||
|
||||
use clap::Parser as _;
|
||||
|
||||
use super::client::ascii_json;
|
||||
use super::client::{self, Console};
|
||||
use super::link::OpenLink;
|
||||
use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
|
||||
use super::server::{CliBackend, CliServer, CliTarget};
|
||||
use super::{Cli, Command};
|
||||
use super::{ConsoleText, normalize_command};
|
||||
use super::{ConsoleText, exec_request, normalize_command};
|
||||
use crate::app::cli_endpoint;
|
||||
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin, HostStore, SshLink};
|
||||
use crate::secrets::SecretRef;
|
||||
@@ -140,6 +141,7 @@ fn run_on(
|
||||
stdout: &mut stdout,
|
||||
stderr: &mut stderr,
|
||||
json,
|
||||
exec_json: false,
|
||||
stderr_is_terminal,
|
||||
},
|
||||
);
|
||||
@@ -555,6 +557,19 @@ fn exec_takes_its_command_as_one_argument_or_from_stdin() {
|
||||
// Neither, or both.
|
||||
assert!(parse(&["shellrs", "exec", "ID"]).is_err());
|
||||
assert!(parse(&["shellrs", "exec", "ID", "ls", "--stdin"]).is_err());
|
||||
// JSON brings the host and the command with it, and nothing else does.
|
||||
assert!(matches!(
|
||||
parse(&["shellrs", "exec", "--json"]),
|
||||
Ok(Command::Exec {
|
||||
id: None,
|
||||
command: None,
|
||||
json: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(parse(&["shellrs", "exec"]).is_err());
|
||||
assert!(parse(&["shellrs", "exec", "ID", "--json"]).is_err());
|
||||
assert!(parse(&["shellrs", "exec", "--json", "--stdin"]).is_err());
|
||||
// Split words are a mistake, not a longer command.
|
||||
assert!(parse(&["shellrs", "exec", "ID", "ls", "-la"]).is_err());
|
||||
}
|
||||
@@ -602,3 +617,80 @@ fn a_windows_console_gets_text_even_from_bytes_that_are_not_utf8() {
|
||||
raw.finish().unwrap();
|
||||
assert_eq!(raw.inner, b"\xff\xd6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_json_reads_the_host_and_the_command() {
|
||||
assert_eq!(
|
||||
exec_request("\u{feff}{\"host\": \"ID\", \"command\": \"echo \\\"hi\\\" | wc -c\"}"),
|
||||
Ok(("ID".to_string(), "echo \"hi\" | wc -c".to_string()))
|
||||
);
|
||||
// Escaped as an agent may write it in PowerShell 5.1, which pipes ASCII.
|
||||
assert_eq!(
|
||||
exec_request(r#"{"host":"ID","command":"grep \u751f\u4ea7 a.log"}"#),
|
||||
Ok(("ID".to_string(), "grep 生产 a.log".to_string()))
|
||||
);
|
||||
assert!(exec_request(r#"{"command":"ls"}"#).is_err());
|
||||
assert!(exec_request("ls -la").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_json_output_is_ascii_whatever_it_holds() {
|
||||
assert_eq!(
|
||||
ascii_json(&serde_json::json!({ "s": "生产 😀\n" })),
|
||||
r#"{"s":"\u751f\u4ea7 \ud83d\ude00\n"}"#
|
||||
);
|
||||
}
|
||||
|
||||
/// `exec --json` against the fixture: the exit code, and stdout parsed.
|
||||
fn run_exec_json(socket: &Path, request: Request) -> (i32, serde_json::Value, Vec<u8>) {
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let code = client::run(
|
||||
socket,
|
||||
request,
|
||||
&mut Console {
|
||||
stdout: &mut stdout,
|
||||
stderr: &mut stderr,
|
||||
json: true,
|
||||
exec_json: true,
|
||||
stderr_is_terminal: false,
|
||||
},
|
||||
);
|
||||
assert!(stdout.is_ascii(), "{}", String::from_utf8_lossy(&stdout));
|
||||
(code, serde_json::from_slice(&stdout).unwrap(), stderr)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_json_gathers_the_output_and_says_errors_in_json() {
|
||||
let fixture = fixture();
|
||||
let (code, result, stderr) = run_exec_json(
|
||||
&fixture.socket,
|
||||
Request::Exec {
|
||||
host: id(&fixture.web),
|
||||
command: "uname -a".into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(code, 3);
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!({ "exit_code": 3, "stdout": "out\n", "stderr": "\u{fffd}\n" })
|
||||
);
|
||||
assert!(stderr.is_empty());
|
||||
|
||||
fixture.server.set_enabled(false);
|
||||
let (code, result, stderr) = run_exec_json(
|
||||
&fixture.socket,
|
||||
Request::Exec {
|
||||
host: id(&fixture.web),
|
||||
command: "true".into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(code, 255);
|
||||
assert_eq!(result["error"]["code"], "not_enabled");
|
||||
assert!(
|
||||
result["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("设置 → 外部 CLI")
|
||||
);
|
||||
assert!(stderr.is_empty());
|
||||
}
|
||||
@@ -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,9 @@
|
||||
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::{Opening, PendingFile},
|
||||
pane_operations::PendingOperation,
|
||||
preview::PreviewKind,
|
||||
};
|
||||
use crate::app::ExplorerDispatch as _;
|
||||
use crate::{
|
||||
@@ -12,7 +15,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 +32,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 +47,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 +62,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 +222,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 +292,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 +491,13 @@ 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::BytesRead { request_id, result } => {
|
||||
self.finish_bytes_read(request_id, result)
|
||||
}
|
||||
SftpEvent::Progress(progress) => {
|
||||
let complete = progress.phase() == TransferPhase::Completed;
|
||||
let direction = progress.direction();
|
||||
@@ -636,15 +655,43 @@ 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 picture is previewed; any other file (an SVG too,
|
||||
// being text) opens in the editor, through the workspace,
|
||||
// which shows the tab when the file is open already.
|
||||
let path = Some(pane.read(cx).child_path_of(&entry.name));
|
||||
let remote = *remote;
|
||||
let command =
|
||||
if PreviewKind::of(&entry.name).is_some_and(|kind| kind.preview_only()) {
|
||||
ExplorerCommand::Preview { remote, path }
|
||||
} else {
|
||||
ExplorerCommand::Edit { remote, path }
|
||||
};
|
||||
self.dispatch.dispatch_explorer_action(
|
||||
&ExplorerAction::new(self.id, command),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
ExplorerCommand::Edit { remote, path } => {
|
||||
if let Some(location) = self.edit_target(*remote, path.as_deref(), cx) {
|
||||
self.open_file(location, Opening::Edit, window, cx);
|
||||
}
|
||||
}
|
||||
ExplorerCommand::Preview { remote, path } => {
|
||||
if let Some((location, kind)) = self.preview_target(*remote, path.as_deref(), cx) {
|
||||
self.open_file(location, Opening::Preview(kind), window, cx);
|
||||
}
|
||||
}
|
||||
ExplorerCommand::OpenDirectory { remote } => {
|
||||
@@ -657,6 +704,23 @@ impl ExplorerPanel {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(path));
|
||||
}
|
||||
}
|
||||
ExplorerCommand::CopySelectedPaths { remote } => {
|
||||
let pane = self.pane(*remote).read(cx);
|
||||
let paths: Vec<String> = pane
|
||||
.selected_entries(cx)
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
if entry.is_parent() {
|
||||
pane.parent_path()
|
||||
} else {
|
||||
pane.child_path_of(&entry.name)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !paths.is_empty() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(paths.join("\n")));
|
||||
}
|
||||
}
|
||||
ExplorerCommand::FocusPane { remote } => {
|
||||
let focus = self.pane(*remote).read(cx).focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
@@ -925,6 +989,8 @@ fn needs_connection(command: &ExplorerCommand) -> bool {
|
||||
| C::Back { remote }
|
||||
| C::Forward { remote }
|
||||
| C::Open { remote }
|
||||
| C::Edit { remote, .. }
|
||||
| C::Preview { remote, .. }
|
||||
| C::OpenDirectory { remote }
|
||||
| C::Delete { remote }
|
||||
| C::Rename { remote }
|
||||
@@ -1128,6 +1194,9 @@ mod tests {
|
||||
assert!(!needs_connection(&ExplorerCommand::CopyPath {
|
||||
remote: true
|
||||
}));
|
||||
assert!(!needs_connection(&ExplorerCommand::CopySelectedPaths {
|
||||
remote: true
|
||||
}));
|
||||
assert!(!needs_connection(&ExplorerCommand::AddBookmark {
|
||||
remote: true,
|
||||
path: None
|
||||
|
||||
@@ -156,7 +156,7 @@ impl ExplorerPanel {
|
||||
let existing: Vec<String> = self
|
||||
.pane(remote)
|
||||
.read(cx)
|
||||
.entries(cx)
|
||||
.listed_entries(cx)
|
||||
.iter()
|
||||
.filter(|entry| !entry.is_parent())
|
||||
.map(|entry| entry.name.to_string())
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
//! Files for the editor and for previews: an SFTP tab reads them and writes
|
||||
//! the remote ones back over its own connection, so neither ever logs in
|
||||
//! again.
|
||||
//!
|
||||
//! A file is read before any editor tab or preview exists: one that cannot
|
||||
//! be shown (too large, binary, not UTF-8) is explained here and never
|
||||
//! flashes a tab.
|
||||
|
||||
use super::preview::{IMAGE_LIMIT, Preview, PreviewContent, PreviewKind, open_preview_dialog};
|
||||
use super::{ExplorerPanel, ExplorerPanelEvent, format_size};
|
||||
use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _};
|
||||
use crate::host::ConnectionState;
|
||||
use crate::sftp::{
|
||||
EDIT_LIMIT, FileBytes, FileStamp, ReadFailure, RemotePath, SaveFailure, SftpCommand, TextFile,
|
||||
read_local_bytes, 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>>),
|
||||
Bytes(oneshot::Sender<Result<FileBytes, ReadFailure>>),
|
||||
Write(oneshot::Sender<Result<FileStamp, SaveFailure>>),
|
||||
}
|
||||
|
||||
/// Why a file is read: for the editor, or for a preview of its kind.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Opening {
|
||||
Edit,
|
||||
Preview(PreviewKind),
|
||||
}
|
||||
|
||||
/// A file as read for an `Opening`: text, or an image's bytes.
|
||||
enum Opened {
|
||||
Text(TextFile),
|
||||
Bytes(FileBytes),
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
/// The file `path` names on one side, or the one under the cursor,
|
||||
/// when it can be previewed.
|
||||
pub fn preview_target(
|
||||
&self,
|
||||
remote: bool,
|
||||
path: Option<&str>,
|
||||
cx: &App,
|
||||
) -> Option<(FileLocation, PreviewKind)> {
|
||||
let location = self.edit_target(remote, path, cx)?;
|
||||
let kind = PreviewKind::of(&location.name())?;
|
||||
Some((location, kind))
|
||||
}
|
||||
|
||||
/// Read a file for a new editor tab or a preview. `EditFile` brings one
|
||||
/// for the editor to the workspace; a preview opens here; a refusal is
|
||||
/// explained here.
|
||||
pub(super) fn open_file(
|
||||
&mut self,
|
||||
location: FileLocation,
|
||||
opening: Opening,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.opening.insert(location.clone()) {
|
||||
return;
|
||||
}
|
||||
let read = match opening {
|
||||
Opening::Preview(PreviewKind::Image(_)) => self
|
||||
.read_bytes(&location, IMAGE_LIMIT, window, cx)
|
||||
.map(|read| cx.spawn(async move |_, _| read.await.map(Opened::Bytes))),
|
||||
Opening::Edit | Opening::Preview(PreviewKind::Markdown) => self
|
||||
.read_file(&location, window, cx)
|
||||
.map(|read| cx.spawn(async move |_, _| read.await.map(Opened::Text))),
|
||||
};
|
||||
let Some(read) = read 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 (opening, result) {
|
||||
(Opening::Edit, Ok(Opened::Text(file))) => cx.emit(
|
||||
ExplorerPanelEvent::EditFile(this.id(), location, Rc::new(file)),
|
||||
),
|
||||
(Opening::Preview(kind), Ok(opened)) => {
|
||||
this.show_preview(&location, kind, opened, window, cx)
|
||||
}
|
||||
(Opening::Edit, Ok(Opened::Bytes(_))) => {}
|
||||
(_, Err(failure)) => this.refuse_open(&location, opening, 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())))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a whole file as it is, up to `limit` bytes. `None` when the
|
||||
/// remote side is not connected, which this tab has already said.
|
||||
pub(super) fn read_bytes(
|
||||
&mut self,
|
||||
location: &FileLocation,
|
||||
limit: u64,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<Task<Result<FileBytes, ReadFailure>>> {
|
||||
match location {
|
||||
FileLocation::Local(path) => {
|
||||
let (provider, path) = (self.local_provider.clone(), path.clone());
|
||||
Some(cx.background_spawn(async move {
|
||||
read_local_bytes(provider.as_ref(), &path, limit)
|
||||
}))
|
||||
}
|
||||
FileLocation::Remote(path) => {
|
||||
if !self.reachable(window, cx) {
|
||||
return None;
|
||||
}
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let request_id = self.next_file_request(PendingFile::Bytes(sender));
|
||||
self.send(SftpCommand::ReadBytes {
|
||||
request_id,
|
||||
path: path.clone(),
|
||||
limit,
|
||||
});
|
||||
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_bytes_read(&mut self, id: u64, result: Result<FileBytes, ReadFailure>) {
|
||||
if let Some(PendingFile::Bytes(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
|
||||
}
|
||||
|
||||
/// The preview dialog for a file just read.
|
||||
fn show_preview(
|
||||
&mut self,
|
||||
location: &FileLocation,
|
||||
kind: PreviewKind,
|
||||
opened: Opened,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let remote = location.is_remote();
|
||||
let (content, size) = match (kind, opened) {
|
||||
(PreviewKind::Image(format), Opened::Bytes(bytes)) => {
|
||||
let size = bytes.len() as u64;
|
||||
let image = Image::from_bytes(format, bytes.into_inner());
|
||||
(PreviewContent::Image(image), size)
|
||||
}
|
||||
(PreviewKind::Markdown, Opened::Text(file)) => {
|
||||
let size = file.stamp().size();
|
||||
let (text, _, _) = file.into_parts();
|
||||
(PreviewContent::Markdown(text.into()), size)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let id = self.id();
|
||||
let download = self.download_action(location, cx);
|
||||
let edit = (kind == PreviewKind::Markdown).then(|| {
|
||||
ExplorerAction::new(
|
||||
id,
|
||||
ExplorerCommand::Edit {
|
||||
remote,
|
||||
path: Some(location.path()),
|
||||
},
|
||||
)
|
||||
});
|
||||
open_preview_dialog(
|
||||
Preview {
|
||||
name: location.name(),
|
||||
size,
|
||||
content,
|
||||
download: download.map(|command| ExplorerAction::new(id, command)),
|
||||
edit,
|
||||
dispatch: self.dispatch.clone(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// 下载… for a remote file, into the local pane's directory.
|
||||
fn download_action(&self, location: &FileLocation, cx: &App) -> Option<ExplorerCommand> {
|
||||
match location {
|
||||
FileLocation::Remote(path) => Some(ExplorerCommand::DownloadPaths {
|
||||
paths: vec![path.to_string()],
|
||||
target: self.local().read(cx).path(),
|
||||
}),
|
||||
FileLocation::Local(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_open(
|
||||
&mut self,
|
||||
location: &FileLocation,
|
||||
opening: Opening,
|
||||
failure: ReadFailure,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let name = location.name();
|
||||
let (title, what, limit) = match opening {
|
||||
Opening::Edit => (format!("无法在编辑器中打开“{name}”"), "编辑器", EDIT_LIMIT),
|
||||
Opening::Preview(PreviewKind::Image(_)) => {
|
||||
(format!("无法预览“{name}”"), "预览", IMAGE_LIMIT)
|
||||
}
|
||||
Opening::Preview(PreviewKind::Markdown) => {
|
||||
(format!("无法预览“{name}”"), "预览", EDIT_LIMIT)
|
||||
}
|
||||
};
|
||||
let reason = match &failure {
|
||||
ReadFailure::TooLarge(size) => format!(
|
||||
"文件有 {},{what}只打开 {} 以内的文件。",
|
||||
format_size(*size),
|
||||
format_size(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 = self.download_action(location, cx);
|
||||
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(title.clone()).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");
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,12 @@ pub(super) enum MenuHit {
|
||||
/// the pane's; the table's own row selection stays off.
|
||||
pub struct FileListing {
|
||||
columns: Vec<Column>,
|
||||
/// Everything the directory holds, as read.
|
||||
listed: Vec<FileEntry>,
|
||||
/// The rows shown, sorted: `listed` less the hidden files while they
|
||||
/// are not shown.
|
||||
rows: Vec<FileEntry>,
|
||||
show_hidden: bool,
|
||||
sort: Option<(usize, ColumnSort)>,
|
||||
side: PaneSide,
|
||||
context: ListingContext,
|
||||
@@ -132,7 +137,9 @@ impl FileListing {
|
||||
pub fn new(side: PaneSide) -> Self {
|
||||
Self {
|
||||
columns: columns(side),
|
||||
listed: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
show_hidden: false,
|
||||
sort: None,
|
||||
side,
|
||||
context: ListingContext::default(),
|
||||
@@ -143,10 +150,21 @@ impl FileListing {
|
||||
self.context = context;
|
||||
}
|
||||
|
||||
/// The rows shown, in display order.
|
||||
pub fn rows(&self) -> &[FileEntry] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
/// Everything the directory holds, hidden files included.
|
||||
pub fn listed(&self) -> &[FileEntry] {
|
||||
&self.listed
|
||||
}
|
||||
|
||||
/// How many hidden files are left out.
|
||||
pub fn hidden_count(&self) -> usize {
|
||||
self.listed.len() - self.rows.len()
|
||||
}
|
||||
|
||||
/// Row names in display order, `..` included.
|
||||
pub fn order(&self) -> Vec<&str> {
|
||||
self.rows.iter().map(|row| row.name.as_ref()).collect()
|
||||
@@ -247,7 +265,29 @@ impl FileListing {
|
||||
}
|
||||
|
||||
pub fn set_rows(&mut self, rows: Vec<FileEntry>) {
|
||||
self.rows = rows;
|
||||
self.listed = rows;
|
||||
self.filter();
|
||||
}
|
||||
|
||||
pub fn shows_hidden(&self) -> bool {
|
||||
self.show_hidden
|
||||
}
|
||||
|
||||
pub fn set_show_hidden(&mut self, show: bool) {
|
||||
if self.show_hidden != show {
|
||||
self.show_hidden = show;
|
||||
self.filter();
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(&mut self) {
|
||||
let show_hidden = self.show_hidden;
|
||||
self.rows = self
|
||||
.listed
|
||||
.iter()
|
||||
.filter(|entry| show_hidden || !entry.is_hidden())
|
||||
.cloned()
|
||||
.collect();
|
||||
self.apply_sort();
|
||||
}
|
||||
|
||||
@@ -366,7 +406,8 @@ impl TableDelegate for FileListing {
|
||||
.child(
|
||||
icon_for(entry)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.when(entry.is_hidden(), |icon| icon.opacity(0.6)),
|
||||
)
|
||||
.child(div().min_w_0().truncate().child(entry.name.clone()));
|
||||
let label = if parent {
|
||||
@@ -447,8 +488,13 @@ impl TableDelegate for FileListing {
|
||||
// `render_td`. `DataTable` paints its hover over the whole row after
|
||||
// this row's own style, so the row covers it with the table's own
|
||||
// (opaque) background, under the cells.
|
||||
// A hidden file, when shown, is lighter than the rest, every column
|
||||
// of it.
|
||||
let row = div()
|
||||
.id(ElementId::Name(format!("file:{name}").into()))
|
||||
.when(entry.is_hidden(), |row| {
|
||||
row.text_color(cx.theme().muted_foreground)
|
||||
})
|
||||
.child(div().absolute().inset_0().bg(cx.theme().tokens.table))
|
||||
// Where this row paints tells where every row is. Offsets are
|
||||
// explicit: an absolute child without them lands below the cells.
|
||||
|
||||
+116
-10
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
ClickMode, CursorMotion, ExplorerId, FileEntry, FileListing, FileSizeFormat, LoadIntent,
|
||||
NavigationHistory, Selection, child_path,
|
||||
NavigationHistory, PreviewKind, Selection, ShowHiddenFiles, child_path,
|
||||
file_listing::{ListGeometry, ListingContext, MenuHit, Pressed, accept_drops, offer_drop},
|
||||
pane_menu::{
|
||||
PaneMenuState, bookmark_menu, directory_menu, item_menu, new_menu, size_format_menu,
|
||||
@@ -12,7 +12,7 @@ use crate::app::ExplorerDispatch as _;
|
||||
use crate::{
|
||||
app::{
|
||||
CatalogIcon, ExplorerAction, ExplorerCommand, ExplorerShortcut, LOCAL_FILE_LIST_CONTEXT,
|
||||
REMOTE_FILE_LIST_CONTEXT,
|
||||
REMOTE_FILE_LIST_CONTEXT, ToggleHiddenFiles,
|
||||
},
|
||||
host::{BookmarkSide, ConnectionState, HostId, HostStore},
|
||||
sftp::{DirectoryListing, SharedLocalDirectoryProvider},
|
||||
@@ -77,6 +77,9 @@ pub enum PaneSide {
|
||||
Remote,
|
||||
}
|
||||
impl PaneSide {
|
||||
pub fn from_remote(remote: bool) -> Self {
|
||||
if remote { Self::Remote } else { Self::Local }
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
if self == Self::Local {
|
||||
"本地"
|
||||
@@ -195,6 +198,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>,
|
||||
@@ -223,7 +230,8 @@ impl FilePane {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let listing = FileListing::new(side);
|
||||
let mut listing = FileListing::new(side);
|
||||
listing.set_show_hidden(ShowHiddenFiles::get(side, cx));
|
||||
// The pane owns selection (multi-select by name); the table's single
|
||||
// row selection stays off so it never paints a competing highlight.
|
||||
let table = cx.new(|cx| {
|
||||
@@ -253,6 +261,7 @@ impl FilePane {
|
||||
},
|
||||
),
|
||||
cx.observe(&store, |_, _, cx| cx.notify()),
|
||||
cx.observe_global::<ShowHiddenFiles>(|pane, cx| pane.sync_hidden_files(cx)),
|
||||
];
|
||||
let mut pane = Self {
|
||||
side,
|
||||
@@ -278,6 +287,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 {
|
||||
@@ -349,9 +360,28 @@ impl FilePane {
|
||||
let listing = table.delegate();
|
||||
listing.entry(listing.position(cursor)?).cloned()
|
||||
}
|
||||
/// The rows shown, in display order.
|
||||
pub fn entries(&self, cx: &App) -> Vec<FileEntry> {
|
||||
self.table.read(cx).delegate().rows().to_vec()
|
||||
}
|
||||
/// Everything the directory holds, hidden files included: what a new
|
||||
/// name must not clash with.
|
||||
pub fn listed_entries(&self, cx: &App) -> Vec<FileEntry> {
|
||||
self.table.read(cx).delegate().listed().to_vec()
|
||||
}
|
||||
/// List the rows again when this side's hidden files are shown or no
|
||||
/// longer are. A hidden file leaves the selection with its row.
|
||||
fn sync_hidden_files(&mut self, cx: &mut Context<Self>) {
|
||||
let show = ShowHiddenFiles::get(self.side, cx);
|
||||
if self.table.read(cx).delegate().shows_hidden() == show {
|
||||
return;
|
||||
}
|
||||
self.table.update(cx, |table, cx| {
|
||||
table.delegate_mut().set_show_hidden(show);
|
||||
table.refresh(cx);
|
||||
});
|
||||
self.update_selection(cx, |selection, order| selection.retain(order));
|
||||
}
|
||||
pub fn column_names(&self, cx: &App) -> Vec<SharedString> {
|
||||
let table = self.table.read(cx);
|
||||
let listing = table.delegate();
|
||||
@@ -459,7 +489,9 @@ impl FilePane {
|
||||
pane: Some(cx.entity().downgrade()),
|
||||
menu_hit: self.menu_hit.clone(),
|
||||
geometry: self.geometry.clone(),
|
||||
placeholder: self.placeholder().into(),
|
||||
placeholder: self
|
||||
.placeholder(self.table.read(cx).delegate().hidden_count())
|
||||
.into(),
|
||||
};
|
||||
self.table.update(cx, |table, cx| {
|
||||
table.delegate_mut().configure(context);
|
||||
@@ -470,12 +502,15 @@ impl FilePane {
|
||||
/// has been read, what is going on until then, in the status line's
|
||||
/// words. An empty directory being read again keeps saying 空目录 until
|
||||
/// the read is slow, like the status line, so a quick refresh does not
|
||||
/// flicker.
|
||||
fn placeholder(&self) -> &'static str {
|
||||
/// flicker. A directory of nothing but hidden files, not shown, is not
|
||||
/// empty.
|
||||
fn placeholder(&self, hidden: usize) -> &'static str {
|
||||
if self.connection == ConnectionState::Connecting {
|
||||
"正在连接 SFTP…"
|
||||
} else if self.loading && (!self.listed || self.slow_load) {
|
||||
"正在读取目录…"
|
||||
} else if self.listed && hidden > 0 {
|
||||
"只有隐藏文件"
|
||||
} else if self.listed {
|
||||
"空目录"
|
||||
} else if self.connection == ConnectionState::Disconnected {
|
||||
@@ -650,14 +685,22 @@ impl FilePane {
|
||||
/// count. It is always there, so none of these move the list. What went
|
||||
/// wrong goes to the window's status line instead, see `problem`.
|
||||
fn render_status(&self, file_count: usize, selected: usize, cx: &App) -> impl IntoElement {
|
||||
let hidden = self.table.read(cx).delegate().hidden_count();
|
||||
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 {
|
||||
format!("{file_count} 个项目")
|
||||
} else {
|
||||
format!("{file_count} 个项目 · 已选择 {selected} 项")
|
||||
let mut status = format!("{file_count} 个项目");
|
||||
if hidden > 0 {
|
||||
status.push_str(&format!(" · 隐藏 {hidden} 项"));
|
||||
}
|
||||
if selected > 0 {
|
||||
status.push_str(&format!(" · 已选择 {selected} 项"));
|
||||
}
|
||||
status
|
||||
};
|
||||
h_flex()
|
||||
.id("pane-status")
|
||||
@@ -781,6 +824,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;
|
||||
@@ -904,7 +965,16 @@ impl FilePane {
|
||||
PaneMenuState {
|
||||
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,
|
||||
},
|
||||
preview_kind: match entries.as_slice() {
|
||||
[entry] if !entry.is_dir() && !entry.is_parent() => PreviewKind::of(&entry.name),
|
||||
_ => None,
|
||||
},
|
||||
targets,
|
||||
can_go_up: self.parent_path() != self.path,
|
||||
can_go_home: !self.home.is_empty() && self.home != self.path,
|
||||
@@ -914,6 +984,7 @@ impl FilePane {
|
||||
bookmarks: self.bookmarks(cx),
|
||||
can_modify: self.takes_commands() && !self.busy,
|
||||
can_transfer: self.transfer_enabled,
|
||||
show_hidden: ShowHiddenFiles::get(self.side, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -967,6 +1038,11 @@ impl Render for FilePane {
|
||||
.border_color(cx.theme().border)
|
||||
.child(row)
|
||||
};
|
||||
let hidden_files_tip = if state.show_hidden {
|
||||
"不显示隐藏文件"
|
||||
} else {
|
||||
"显示隐藏文件"
|
||||
};
|
||||
let navigation = toolbar()
|
||||
// `Select` fills its parent (`size_full`), so it needs a sized
|
||||
// box of its own or it covers the whole toolbar row.
|
||||
@@ -1054,6 +1130,36 @@ impl Render for FilePane {
|
||||
ExplorerCommand::Forward { remote },
|
||||
)
|
||||
.disabled(!navigable || !state.can_go_forward),
|
||||
)
|
||||
.child(Separator::vertical())
|
||||
// This side's switch, for every SFTP tab. The icon is how things
|
||||
// are, an open eye while hidden files show; the tooltip is what a
|
||||
// click does.
|
||||
.child(
|
||||
Button::new("hidden-files")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(Icon::new(if state.show_hidden {
|
||||
CatalogIcon::Eye
|
||||
} else {
|
||||
CatalogIcon::EyeOff
|
||||
}))
|
||||
.accessibility_label(hidden_files_tip)
|
||||
.tooltip_with_action(
|
||||
hidden_files_tip,
|
||||
&ToggleHiddenFiles(self.side),
|
||||
Some(context),
|
||||
)
|
||||
.on_click({
|
||||
let dispatch = self.dispatch.clone();
|
||||
let side = self.side;
|
||||
move |_, window, cx| {
|
||||
let dispatch = dispatch.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
dispatch.dispatch_action(&ToggleHiddenFiles(side), window, cx)
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
let transfer_shortcut = ExplorerShortcut(ExplorerCommand::Transfer { remote });
|
||||
let (id, icon, label, tip) = if remote {
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
//! The image in a preview: fitted into its frame and centered, or zoomed,
|
||||
//! with scrolling once it is larger than the frame.
|
||||
//!
|
||||
//! The zoom is the preview's own state, like a list's search text: its
|
||||
//! actions are handled here, where the keys reach it while the preview has
|
||||
//! focus, not by the workspace, which never holds a dialog's content.
|
||||
|
||||
use std::{cell::Cell, rc::Rc, sync::Arc};
|
||||
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, Icon, Selectable as _, Sizable as _,
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
scroll::ScrollableElement as _,
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::format_size;
|
||||
use crate::app::{
|
||||
ActualSizePreview, CatalogIcon, FitPreview, IMAGE_PREVIEW_CONTEXT, ZoomPreviewIn,
|
||||
ZoomPreviewOut,
|
||||
};
|
||||
|
||||
/// The zoom levels the buttons and keys step through, as a share of the
|
||||
/// image's own size (one image pixel to one point at 100%).
|
||||
const STEPS: [f32; 14] = [
|
||||
0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 5.0, 8.0,
|
||||
];
|
||||
const MIN_SCALE: f32 = 0.1;
|
||||
const MAX_SCALE: f32 = 8.0;
|
||||
|
||||
/// How the image is sized: to fit the frame (never above its own size),
|
||||
/// or at a scale of its own.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Zoom {
|
||||
Fit,
|
||||
Scale(f32),
|
||||
}
|
||||
|
||||
/// The scale at which the whole image fits the frame; a small image keeps
|
||||
/// its own size.
|
||||
pub(super) fn fit_scale(frame: Size<Pixels>, image: Size<Pixels>) -> f32 {
|
||||
if image.width <= px(0.) || image.height <= px(0.) {
|
||||
return 1.0;
|
||||
}
|
||||
(frame.width / image.width)
|
||||
.min(frame.height / image.height)
|
||||
.min(1.0)
|
||||
}
|
||||
|
||||
/// The next step above `scale`.
|
||||
pub(super) fn zoom_in(scale: f32) -> f32 {
|
||||
STEPS
|
||||
.into_iter()
|
||||
.find(|step| *step > scale + 0.001)
|
||||
.unwrap_or(MAX_SCALE)
|
||||
}
|
||||
|
||||
/// The next step below `scale`.
|
||||
pub(super) fn zoom_out(scale: f32) -> f32 {
|
||||
STEPS
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|step| *step < scale - 0.001)
|
||||
.unwrap_or(MIN_SCALE)
|
||||
}
|
||||
|
||||
/// The area scrolled over: the image, or the frame where the image is
|
||||
/// smaller, so that it stays in the middle.
|
||||
pub(super) fn scroll_area(frame: Size<Pixels>, shown: Size<Pixels>) -> Size<Pixels> {
|
||||
size(shown.width.max(frame.width), shown.height.max(frame.height))
|
||||
}
|
||||
|
||||
/// The scroll offset that keeps the point in the middle of the frame where
|
||||
/// it was when the area changes size (zooming).
|
||||
pub(super) fn recenter(
|
||||
frame: Size<Pixels>,
|
||||
old_area: Size<Pixels>,
|
||||
new_area: Size<Pixels>,
|
||||
offset: Point<Pixels>,
|
||||
) -> Point<Pixels> {
|
||||
let axis = |offset: Pixels, frame: Pixels, old: Pixels, new: Pixels| {
|
||||
let center = (-offset + frame / 2.) / old;
|
||||
let start = new * center - frame / 2.;
|
||||
-start.clamp(px(0.), (new - frame).max(px(0.)))
|
||||
};
|
||||
point(
|
||||
axis(offset.x, frame.width, old_area.width, new_area.width),
|
||||
axis(offset.y, frame.height, old_area.height, new_area.height),
|
||||
)
|
||||
}
|
||||
|
||||
/// 「37%」.
|
||||
pub(super) fn percent(scale: f32) -> String {
|
||||
format!("{}%", (scale * 100.).round())
|
||||
}
|
||||
|
||||
pub(super) struct ImagePreview {
|
||||
/// The decoded image: `None` while it is decoded, an error when it
|
||||
/// cannot be.
|
||||
decoded: Option<Result<Arc<RenderImage>, String>>,
|
||||
name: SharedString,
|
||||
/// The file's size in bytes.
|
||||
bytes: u64,
|
||||
zoom: Zoom,
|
||||
scroll: ScrollHandle,
|
||||
/// The frame's size as last laid out: the fit, the centering and the
|
||||
/// scroll area go by it.
|
||||
frame: Rc<Cell<Option<Size<Pixels>>>>,
|
||||
focus_handle: FocusHandle,
|
||||
_decode: Task<()>,
|
||||
}
|
||||
|
||||
impl ImagePreview {
|
||||
/// The image is decoded on the background executor, here rather than
|
||||
/// by `img`'s loading, so that one that cannot be decoded says so
|
||||
/// instead of loading for ever.
|
||||
pub(super) fn new(
|
||||
image: Image,
|
||||
name: SharedString,
|
||||
bytes: u64,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let renderer = cx.svg_renderer();
|
||||
let decode = cx.spawn(async move |this, cx| {
|
||||
let decoded = cx
|
||||
.background_spawn(async move { image.to_image_data(renderer) })
|
||||
.await
|
||||
.map_err(|error| format!("{error:#}"));
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.decoded = Some(decoded);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
// The decoded image lives in the window's texture atlas until it is
|
||||
// dropped from there; the window is free again once the dialog that
|
||||
// released this view has closed.
|
||||
cx.on_release(|this, cx| {
|
||||
if let Some(Ok(image)) = this.decoded.take() {
|
||||
cx.defer(move |cx| cx.drop_image(image, None));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
Self {
|
||||
decoded: None,
|
||||
name,
|
||||
bytes,
|
||||
zoom: Zoom::Fit,
|
||||
scroll: ScrollHandle::new(),
|
||||
frame: Rc::new(Cell::new(None)),
|
||||
focus_handle: cx.focus_handle(),
|
||||
_decode: decode,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn focus_handle(&self) -> &FocusHandle {
|
||||
&self.focus_handle
|
||||
}
|
||||
|
||||
/// The image's own size, one image pixel to one point; `None` until it
|
||||
/// is decoded, or when it cannot be.
|
||||
fn pixels(&self) -> Option<Size<Pixels>> {
|
||||
let Some(Ok(decoded)) = &self.decoded else {
|
||||
return None;
|
||||
};
|
||||
let pixels = decoded.size(0);
|
||||
Some(size(
|
||||
px(u32::from(pixels.width) as f32),
|
||||
px(u32::from(pixels.height) as f32),
|
||||
))
|
||||
}
|
||||
|
||||
fn scale(&self, frame: Size<Pixels>, image: Size<Pixels>) -> f32 {
|
||||
match self.zoom {
|
||||
Zoom::Fit => fit_scale(frame, image),
|
||||
Zoom::Scale(scale) => scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom, keeping what is in the middle of the frame there.
|
||||
fn set_zoom(&mut self, zoom: Zoom, cx: &mut Context<Self>) {
|
||||
if let (Some(image), Some(frame)) = (self.pixels(), self.frame.get()) {
|
||||
let old = self.scale(frame, image);
|
||||
self.zoom = zoom;
|
||||
let new = self.scale(frame, image);
|
||||
let area =
|
||||
|scale: f32| scroll_area(frame, size(image.width * scale, image.height * scale));
|
||||
self.scroll
|
||||
.set_offset(recenter(frame, area(old), area(new), self.scroll.offset()));
|
||||
} else {
|
||||
self.zoom = zoom;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn current_scale(&self) -> f32 {
|
||||
match (self.pixels(), self.frame.get()) {
|
||||
(Some(image), Some(frame)) => self.scale(frame, image),
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn zoom_in(&mut self, _: &ZoomPreviewIn, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let scale = zoom_in(self.current_scale());
|
||||
self.set_zoom(Zoom::Scale(scale), cx);
|
||||
}
|
||||
|
||||
fn zoom_out(&mut self, _: &ZoomPreviewOut, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let scale = zoom_out(self.current_scale());
|
||||
self.set_zoom(Zoom::Scale(scale), cx);
|
||||
}
|
||||
|
||||
fn fit(&mut self, _: &FitPreview, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_zoom(Zoom::Fit, cx);
|
||||
}
|
||||
|
||||
fn actual_size(&mut self, _: &ActualSizePreview, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_zoom(Zoom::Scale(1.0), cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ImagePreview {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let viewport = window.viewport_size();
|
||||
let height = viewport.height * 0.7;
|
||||
let pixels = self.pixels();
|
||||
// Before the first layout, a guess at the frame: the next frame has
|
||||
// its measure.
|
||||
let frame = self
|
||||
.frame
|
||||
.get()
|
||||
.unwrap_or_else(|| size(viewport.width * 0.8 - px(48.), height));
|
||||
let scale = pixels.map(|image| self.scale(frame, image));
|
||||
let info: SharedString = match pixels {
|
||||
Some(image) => format!(
|
||||
"{} × {} 像素 · {}",
|
||||
f32::from(image.width) as u32,
|
||||
f32::from(image.height) as u32,
|
||||
format_size(self.bytes)
|
||||
),
|
||||
None => format_size(self.bytes),
|
||||
}
|
||||
.into();
|
||||
// Only the scale: whether it fits is the 适合窗口 button's state.
|
||||
let zoom_label: SharedString = scale.map(percent).unwrap_or_default().into();
|
||||
let focus = self.focus_handle.clone();
|
||||
let command = move |action: Box<dyn Action>| {
|
||||
let focus = focus.clone();
|
||||
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
|
||||
focus.dispatch_action(action.as_ref(), window, cx)
|
||||
}
|
||||
};
|
||||
let toolbar = h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("preview-info")
|
||||
.test_support()
|
||||
.aria_label(info.clone())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(info),
|
||||
)
|
||||
.child(
|
||||
Button::new("preview-zoom-out")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(Icon::new(CatalogIcon::ZoomOut))
|
||||
.accessibility_label("缩小")
|
||||
.tooltip_with_action("缩小", &ZoomPreviewOut, Some(IMAGE_PREVIEW_CONTEXT))
|
||||
.on_click(command(Box::new(ZoomPreviewOut))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("preview-zoom")
|
||||
.test_support()
|
||||
.aria_label(zoom_label.clone())
|
||||
.min_w_20()
|
||||
.text_center()
|
||||
.text_sm()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(zoom_label),
|
||||
)
|
||||
.child(
|
||||
Button::new("preview-zoom-in")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(Icon::new(CatalogIcon::ZoomIn))
|
||||
.accessibility_label("放大")
|
||||
.tooltip_with_action("放大", &ZoomPreviewIn, Some(IMAGE_PREVIEW_CONTEXT))
|
||||
.on_click(command(Box::new(ZoomPreviewIn))),
|
||||
)
|
||||
.child(
|
||||
Button::new("preview-fit")
|
||||
.ghost()
|
||||
.small()
|
||||
.label("适合窗口")
|
||||
.selected(self.zoom == Zoom::Fit)
|
||||
.tooltip_with_action("适合窗口", &FitPreview, Some(IMAGE_PREVIEW_CONTEXT))
|
||||
.on_click(command(Box::new(FitPreview))),
|
||||
)
|
||||
.child(
|
||||
Button::new("preview-actual-size")
|
||||
.ghost()
|
||||
.small()
|
||||
.label("原图")
|
||||
.selected(self.zoom == Zoom::Scale(1.0))
|
||||
.tooltip_with_action(
|
||||
"原图(100%)",
|
||||
&ActualSizePreview,
|
||||
Some(IMAGE_PREVIEW_CONTEXT),
|
||||
)
|
||||
.on_click(command(Box::new(ActualSizePreview))),
|
||||
);
|
||||
|
||||
let measured = self.frame.clone();
|
||||
let this = cx.entity().downgrade();
|
||||
let decoded = match &self.decoded {
|
||||
Some(Ok(decoded)) => Some(decoded.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let picture = pixels
|
||||
.zip(scale)
|
||||
.zip(decoded)
|
||||
.map(|((image, scale), decoded)| {
|
||||
let shown = size(image.width * scale, image.height * scale);
|
||||
let area = scroll_area(frame, shown);
|
||||
// The area is at least the frame, so a smaller image sits in its
|
||||
// middle; a larger one fills it and scrolls.
|
||||
div()
|
||||
.w(area.width)
|
||||
.h(area.height)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
img(ImageSource::Render(decoded))
|
||||
.id("preview-picture")
|
||||
.w(shown.width)
|
||||
.h(shown.height)
|
||||
.flex_none()
|
||||
// A double click goes between fitting and 100%.
|
||||
.on_click(cx.listener(|this, event: &ClickEvent, _, cx| {
|
||||
if event.click_count() == 2 {
|
||||
let zoom = if this.zoom == Zoom::Fit {
|
||||
Zoom::Scale(1.0)
|
||||
} else {
|
||||
Zoom::Fit
|
||||
};
|
||||
this.set_zoom(zoom, cx);
|
||||
}
|
||||
}))
|
||||
.test_support(),
|
||||
)
|
||||
});
|
||||
let frame_box = div()
|
||||
.id("preview-image")
|
||||
.test_support()
|
||||
.aria_label(self.name.clone())
|
||||
.relative()
|
||||
.h(height)
|
||||
.w_full()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().muted)
|
||||
.overflow_hidden()
|
||||
// The frame's size decides the fit; a new size takes effect on
|
||||
// the next frame.
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, window, _| {
|
||||
if measured.get() != Some(bounds.size) {
|
||||
measured.set(Some(bounds.size));
|
||||
if let Some(this) = this.upgrade() {
|
||||
let id = this.entity_id();
|
||||
window.on_next_frame(move |_, cx| cx.notify(id));
|
||||
}
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("preview-scroll")
|
||||
.size_full()
|
||||
.overflow_scroll()
|
||||
.track_scroll(&self.scroll)
|
||||
// ⌘ (Ctrl) with the wheel or trackpad zooms instead of
|
||||
// scrolling.
|
||||
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| {
|
||||
if !event.modifiers.secondary() {
|
||||
return;
|
||||
}
|
||||
cx.stop_propagation();
|
||||
let delta = event.delta.pixel_delta(px(20.)).y;
|
||||
let scale = this.current_scale();
|
||||
let scale =
|
||||
(scale * (1. + f32::from(delta) / 200.)).clamp(MIN_SCALE, MAX_SCALE);
|
||||
this.set_zoom(Zoom::Scale(scale), cx);
|
||||
}))
|
||||
.children(picture)
|
||||
.when(pixels.is_none(), |scroll| {
|
||||
let note = match &self.decoded {
|
||||
Some(Err(_)) => "无法显示这张图片",
|
||||
_ => "正在显示图片…",
|
||||
};
|
||||
scroll.child(
|
||||
div()
|
||||
.id("preview-note")
|
||||
.test_support()
|
||||
.aria_label(note)
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(note),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.vertical_scrollbar(&self.scroll)
|
||||
.horizontal_scrollbar(&self.scroll);
|
||||
|
||||
v_flex()
|
||||
.id("image-preview")
|
||||
.key_context(IMAGE_PREVIEW_CONTEXT)
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::zoom_in))
|
||||
.on_action(cx.listener(Self::zoom_out))
|
||||
.on_action(cx.listener(Self::fit))
|
||||
.on_action(cx.listener(Self::actual_size))
|
||||
.gap_2()
|
||||
.child(toolbar)
|
||||
.child(frame_box)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fit_scale, percent, recenter, scroll_area, zoom_in, zoom_out};
|
||||
use gpui_kit::{point, px, size};
|
||||
|
||||
#[test]
|
||||
fn a_large_image_fits_and_a_small_one_keeps_its_size() {
|
||||
let frame = size(px(800.), px(600.));
|
||||
assert_eq!(fit_scale(frame, size(px(4000.), px(1000.))), 0.2);
|
||||
assert_eq!(fit_scale(frame, size(px(100.), px(3000.))), 0.2);
|
||||
assert_eq!(fit_scale(frame, size(px(10.), px(10.))), 1.0);
|
||||
assert_eq!(fit_scale(frame, size(px(0.), px(0.))), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_steps_up_and_down_within_bounds() {
|
||||
assert_eq!(zoom_in(0.2), 0.25);
|
||||
assert_eq!(zoom_in(1.0), 1.25);
|
||||
assert_eq!(zoom_in(8.0), 8.0);
|
||||
assert_eq!(zoom_out(1.0), 0.75);
|
||||
assert_eq!(zoom_out(0.3), 0.25);
|
||||
assert_eq!(zoom_out(0.1), 0.1);
|
||||
assert_eq!(percent(0.333), "33%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_middle_stays_in_the_middle_when_zooming() {
|
||||
let frame = size(px(100.), px(100.));
|
||||
// A small image centered: nothing to scroll.
|
||||
let small = scroll_area(frame, size(px(50.), px(20.)));
|
||||
assert_eq!(small, frame);
|
||||
// From fitting to twice the frame: the middle of the area.
|
||||
let large = scroll_area(frame, size(px(200.), px(400.)));
|
||||
assert_eq!(
|
||||
recenter(frame, small, large, point(px(0.), px(0.))),
|
||||
point(px(-50.), px(-150.))
|
||||
);
|
||||
// Scrolled to the top, then zoomed out: the offset stays in range.
|
||||
assert_eq!(
|
||||
recenter(frame, large, small, point(px(-100.), px(-300.))),
|
||||
point(px(0.), px(0.))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,30 @@
|
||||
|
||||
mod explorer_panel;
|
||||
mod file_dialogs;
|
||||
mod file_edit;
|
||||
mod file_listing;
|
||||
mod file_pane;
|
||||
mod history;
|
||||
mod image_preview;
|
||||
mod model;
|
||||
mod open_directory_dialog;
|
||||
mod pane_menu;
|
||||
mod pane_operations;
|
||||
mod path_label;
|
||||
mod preview;
|
||||
mod properties_dialog;
|
||||
mod queue_panel;
|
||||
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};
|
||||
pub use model::*;
|
||||
pub use pane_operations::PaneOperation;
|
||||
pub use preview::{IMAGE_LIMIT, PreviewKind};
|
||||
pub use selection::{ClickMode, CursorMotion, Selection};
|
||||
pub use transfer_queue::{
|
||||
QueueEntry, QueueId, QueueState, Removal, TransferJob, TransferQueue, percent,
|
||||
|
||||
+45
-1
@@ -1,7 +1,8 @@
|
||||
//! Directory row snapshots and display formatting.
|
||||
|
||||
use super::PaneSide;
|
||||
use crate::sftp::{DirectoryEntry, EntryKind};
|
||||
use gpui_kit::{Global, SharedString};
|
||||
use gpui_kit::{App, Global, SharedString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stable identity for one SFTP tab. A host can have several, each with
|
||||
@@ -139,6 +140,12 @@ impl FileEntry {
|
||||
self.name.as_ref() == ".."
|
||||
}
|
||||
|
||||
/// A dot file, which the lists leave out while hidden files are not
|
||||
/// shown. `..` is not one.
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
!self.is_parent() && self.name.starts_with('.')
|
||||
}
|
||||
|
||||
/// The type shown in the 类型 column: the kind, or the extension for files.
|
||||
pub fn type_label(&self) -> String {
|
||||
if self.is_parent() {
|
||||
@@ -265,6 +272,43 @@ pub enum FileSizeFormat {
|
||||
|
||||
impl Global for FileSizeFormat {}
|
||||
|
||||
/// 显示隐藏文件: whether the lists show dot files, for each side on its own
|
||||
/// (dot files on the server are often wanted, the local home's seldom). A
|
||||
/// side's choice holds for every SFTP tab, kept in the settings and set from
|
||||
/// them like `FileSizeFormat`. Off until the user turns it on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ShowHiddenFiles {
|
||||
pub local: bool,
|
||||
pub remote: bool,
|
||||
}
|
||||
|
||||
impl Global for ShowHiddenFiles {}
|
||||
|
||||
impl ShowHiddenFiles {
|
||||
/// Whether `side` shows them now.
|
||||
pub fn get(side: PaneSide, cx: &App) -> bool {
|
||||
cx.try_global::<Self>()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.on(side)
|
||||
}
|
||||
|
||||
pub fn on(self, side: PaneSide) -> bool {
|
||||
match side {
|
||||
PaneSide::Local => self.local,
|
||||
PaneSide::Remote => self.remote,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self, side: PaneSide) {
|
||||
match side {
|
||||
PaneSide::Local => self.local = !self.local,
|
||||
PaneSide::Remote => self.remote = !self.remote,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileSizeFormat {
|
||||
/// In the order the menu lists them.
|
||||
pub const ALL: [FileSizeFormat; 3] = [
|
||||
|
||||
+57
-15
@@ -2,8 +2,10 @@
|
||||
//! and the toolbar's bookmark and 新建 menus. Each item dispatches the same
|
||||
//! `ExplorerCommand` as the toolbar button with that verb.
|
||||
|
||||
use super::{ExplorerId, FileSizeFormat, NewEntryKind};
|
||||
use crate::app::{CatalogIcon, ExplorerAction, ExplorerCommand, SetFileSizeFormat};
|
||||
use super::{ExplorerId, FileSizeFormat, NewEntryKind, PaneSide, PreviewKind};
|
||||
use crate::app::{
|
||||
CatalogIcon, ExplorerAction, ExplorerCommand, SetFileSizeFormat, ToggleHiddenFiles,
|
||||
};
|
||||
use gpui_kit::component::{Icon, IconName, menu::PopupMenu};
|
||||
use gpui_kit::*;
|
||||
|
||||
@@ -14,8 +16,11 @@ pub(super) struct PaneMenuState {
|
||||
pub explorer: ExplorerId,
|
||||
/// The rows the item commands act on (the selection).
|
||||
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>,
|
||||
/// How that file can be previewed, if it can.
|
||||
pub preview_kind: Option<PreviewKind>,
|
||||
pub can_go_up: bool,
|
||||
pub can_go_home: bool,
|
||||
pub can_go_back: bool,
|
||||
@@ -26,6 +31,8 @@ pub(super) struct PaneMenuState {
|
||||
pub can_modify: bool,
|
||||
/// A transfer can start now.
|
||||
pub can_transfer: bool,
|
||||
/// 显示隐藏文件 is on for this side.
|
||||
pub show_hidden: bool,
|
||||
}
|
||||
|
||||
impl PaneMenuState {
|
||||
@@ -38,13 +45,36 @@ impl PaneMenuState {
|
||||
pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
let remote = state.remote;
|
||||
let none = state.targets.is_empty() || !state.can_modify;
|
||||
let menu = if state.opens_directory && state.targets.len() == 1 {
|
||||
menu.menu_with_icon(
|
||||
"打开",
|
||||
Icon::new(IconName::FolderOpen),
|
||||
state.action(ExplorerCommand::Open { remote }),
|
||||
)
|
||||
.separator()
|
||||
// A directory has no 打开: a double-click goes in.
|
||||
let menu = if let Some(path) = state.edits_file.clone() {
|
||||
// A picture is only previewed: as text it would be refused. An SVG
|
||||
// is text, and is both edited and previewed.
|
||||
let kind = state.preview_kind;
|
||||
let menu = if kind.is_some_and(|kind| kind.preview_only()) {
|
||||
menu
|
||||
} else {
|
||||
menu.menu_with_icon(
|
||||
"编辑",
|
||||
Icon::new(CatalogIcon::FilePenLine),
|
||||
state.action(ExplorerCommand::Edit {
|
||||
remote,
|
||||
path: Some(path.clone()),
|
||||
}),
|
||||
)
|
||||
};
|
||||
let menu = if kind.is_some() {
|
||||
menu.menu_with_icon(
|
||||
"预览",
|
||||
Icon::new(CatalogIcon::Eye),
|
||||
state.action(ExplorerCommand::Preview {
|
||||
remote,
|
||||
path: Some(path),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
menu
|
||||
};
|
||||
menu.separator()
|
||||
} else {
|
||||
menu
|
||||
};
|
||||
@@ -59,6 +89,12 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
state.action(ExplorerCommand::Transfer { remote }),
|
||||
state.targets.is_empty() || !state.can_transfer,
|
||||
)
|
||||
.menu_with_icon_and_disabled(
|
||||
"复制路径",
|
||||
Icon::new(IconName::Copy),
|
||||
state.action(ExplorerCommand::CopySelectedPaths { remote }),
|
||||
state.targets.is_empty(),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon_and_disabled(
|
||||
"删除",
|
||||
@@ -89,7 +125,14 @@ pub(super) fn directory_menu(
|
||||
cx: &mut Context<PopupMenu>,
|
||||
) -> PopupMenu {
|
||||
let create = state.clone();
|
||||
add_bookmark_item(refresh_items(menu, state, window, cx), state)
|
||||
let menu = refresh_items(menu, state, window, cx)
|
||||
.menu_with_check(
|
||||
"显示隐藏文件",
|
||||
state.show_hidden,
|
||||
Box::new(ToggleHiddenFiles(PaneSide::from_remote(state.remote))),
|
||||
)
|
||||
.separator();
|
||||
add_bookmark_item(menu, state)
|
||||
.separator()
|
||||
.submenu("新建", window, cx, move |menu, _, _| {
|
||||
new_menu(menu, &create)
|
||||
@@ -104,7 +147,7 @@ pub(super) fn path_menu(
|
||||
cx: &mut Context<PopupMenu>,
|
||||
) -> PopupMenu {
|
||||
let remote = state.remote;
|
||||
add_bookmark_item(refresh_items(menu, state, window, cx), state)
|
||||
add_bookmark_item(refresh_items(menu, state, window, cx).separator(), state)
|
||||
.menu_with_icon(
|
||||
"复制路径",
|
||||
Icon::new(IconName::Copy),
|
||||
@@ -118,7 +161,7 @@ pub(super) fn path_menu(
|
||||
)
|
||||
}
|
||||
|
||||
/// 前往 ▸ and 刷新, then a separator.
|
||||
/// 前往 ▸ and 刷新.
|
||||
fn refresh_items(
|
||||
menu: PopupMenu,
|
||||
state: &PaneMenuState,
|
||||
@@ -165,7 +208,6 @@ fn refresh_items(
|
||||
Icon::new(CatalogIcon::RefreshCw),
|
||||
state.action(ExplorerCommand::Refresh { remote }),
|
||||
)
|
||||
.separator()
|
||||
}
|
||||
|
||||
fn add_bookmark_item(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Previews of files an SFTP tab shows: images and Markdown, in a large
|
||||
//! dialog in the manner of Quick Look. Nothing stays behind: closing the
|
||||
//! dialog drops what was read.
|
||||
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, WindowExt as _, button::Button, dialog::DialogFooter, text::TextView, v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::format_size;
|
||||
use super::image_preview::ImagePreview;
|
||||
use crate::app::{ExplorerAction, ExplorerDispatch as _};
|
||||
|
||||
/// The largest image a preview reads.
|
||||
pub const IMAGE_LIMIT: u64 = 20 * 1024 * 1024;
|
||||
|
||||
/// How a file can be previewed, by its name.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PreviewKind {
|
||||
Image(ImageFormat),
|
||||
Markdown,
|
||||
}
|
||||
|
||||
impl PreviewKind {
|
||||
pub fn of(file_name: &str) -> Option<Self> {
|
||||
let (_, extension) = file_name.rsplit_once('.')?;
|
||||
Some(match extension.to_ascii_lowercase().as_str() {
|
||||
"png" => PreviewKind::Image(ImageFormat::Png),
|
||||
"jpg" | "jpeg" => PreviewKind::Image(ImageFormat::Jpeg),
|
||||
"gif" => PreviewKind::Image(ImageFormat::Gif),
|
||||
"webp" => PreviewKind::Image(ImageFormat::Webp),
|
||||
"svg" => PreviewKind::Image(ImageFormat::Svg),
|
||||
"bmp" => PreviewKind::Image(ImageFormat::Bmp),
|
||||
"ico" => PreviewKind::Image(ImageFormat::Ico),
|
||||
"tif" | "tiff" => PreviewKind::Image(ImageFormat::Tiff),
|
||||
"md" | "markdown" => PreviewKind::Markdown,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Only previewed, never edited: a picture is not text. An SVG is text
|
||||
/// (XML), so it opens in the editor and is previewed on request, like
|
||||
/// Markdown.
|
||||
pub fn preview_only(&self) -> bool {
|
||||
matches!(self, PreviewKind::Image(format) if *format != ImageFormat::Svg)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum PreviewContent {
|
||||
Image(Image),
|
||||
Markdown(SharedString),
|
||||
}
|
||||
|
||||
/// What a preview dialog shows, and the commands at its foot.
|
||||
pub(super) struct Preview {
|
||||
pub name: String,
|
||||
/// The file's size in bytes.
|
||||
pub size: u64,
|
||||
pub content: PreviewContent,
|
||||
/// 下载…, for a remote file.
|
||||
pub download: Option<ExplorerAction>,
|
||||
/// 编辑, for a Markdown file.
|
||||
pub edit: Option<ExplorerAction>,
|
||||
/// The workspace's focus handle, where those are dispatched.
|
||||
pub dispatch: FocusHandle,
|
||||
}
|
||||
|
||||
/// Show a preview over the window: most of it, the image fitted (and
|
||||
/// zoomable) or the Markdown laid out and scrollable. Esc closes it.
|
||||
pub(super) fn open_preview_dialog(preview: Preview, window: &mut Window, cx: &mut App) {
|
||||
let Preview {
|
||||
name,
|
||||
size,
|
||||
content,
|
||||
download,
|
||||
edit,
|
||||
dispatch,
|
||||
} = preview;
|
||||
let focus = window.focused(cx);
|
||||
let name: SharedString = name.into();
|
||||
let body = match content {
|
||||
PreviewContent::Image(image) => {
|
||||
let view = cx.new(|cx| ImagePreview::new(image, name.clone(), size, cx));
|
||||
PreviewBody::Image(view)
|
||||
}
|
||||
PreviewContent::Markdown(text) => PreviewBody::Markdown(text),
|
||||
};
|
||||
let image = match &body {
|
||||
PreviewBody::Image(view) => Some(view.clone()),
|
||||
PreviewBody::Markdown(_) => None,
|
||||
};
|
||||
window.open_dialog(cx, move |dialog, window, cx| {
|
||||
let viewport = window.viewport_size();
|
||||
let height = viewport.height * 0.7;
|
||||
let content = match &body {
|
||||
PreviewBody::Image(view) => view.clone().into_any_element(),
|
||||
PreviewBody::Markdown(text) => {
|
||||
let info = format!("Markdown · {}", format_size(size));
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id("preview-info")
|
||||
.test_support()
|
||||
.aria_label(info.clone())
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(info),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("preview-markdown")
|
||||
.test_support()
|
||||
.aria_label(name.clone())
|
||||
.h(height)
|
||||
.w_full()
|
||||
.child(
|
||||
TextView::markdown("preview-markdown-text", text.clone())
|
||||
.scrollable(true)
|
||||
.selectable(true),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
// A command at the foot closes the preview first: what it opens
|
||||
// (the download question, the editor) goes in front.
|
||||
let command = |id: &'static str, label: &'static str, action: ExplorerAction| {
|
||||
let dispatch = dispatch.clone();
|
||||
Button::new(id).label(label).on_click(move |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
dispatch.dispatch_explorer_action(&action, window, cx);
|
||||
})
|
||||
};
|
||||
dialog
|
||||
.title(name.clone())
|
||||
.w(viewport.width * 0.8)
|
||||
.margin_top(viewport.height * 0.05)
|
||||
.overlay_closable(false)
|
||||
.child(
|
||||
div()
|
||||
.id("file-preview")
|
||||
.test_support()
|
||||
.aria_label(name.clone())
|
||||
.child(content),
|
||||
)
|
||||
.footer(
|
||||
DialogFooter::new()
|
||||
.when_some(download.clone(), |footer, action| {
|
||||
footer.child(command("preview-download", "下载…", action))
|
||||
})
|
||||
.when_some(edit.clone(), |footer, action| {
|
||||
footer.child(command("preview-edit", "编辑", action))
|
||||
})
|
||||
.child(
|
||||
Button::new("preview-close")
|
||||
.label("关闭")
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
),
|
||||
)
|
||||
.on_close({
|
||||
let focus = focus.clone();
|
||||
move |_, window, cx| {
|
||||
if let Some(focus) = &focus {
|
||||
window.focus(focus, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
// The zoom keys reach the image while it has focus. Only a focus taken
|
||||
// in this same update holds: the dialog took it just now.
|
||||
if let Some(view) = image {
|
||||
let focus = view.read(cx).focus_handle().clone();
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the dialog shows, made once: the image's view keeps its zoom and
|
||||
/// scroll across frames.
|
||||
enum PreviewBody {
|
||||
Image(Entity<ImagePreview>),
|
||||
Markdown(SharedString),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PreviewKind;
|
||||
use gpui_kit::ImageFormat;
|
||||
|
||||
#[test]
|
||||
fn images_and_markdown_are_previewed_by_their_names() {
|
||||
assert_eq!(
|
||||
PreviewKind::of("photo.JPG"),
|
||||
Some(PreviewKind::Image(ImageFormat::Jpeg))
|
||||
);
|
||||
assert_eq!(
|
||||
PreviewKind::of("logo.svg"),
|
||||
Some(PreviewKind::Image(ImageFormat::Svg))
|
||||
);
|
||||
assert_eq!(PreviewKind::of("README.md"), Some(PreviewKind::Markdown));
|
||||
assert_eq!(
|
||||
PreviewKind::of("notes.markdown"),
|
||||
Some(PreviewKind::Markdown)
|
||||
);
|
||||
assert_eq!(PreviewKind::of("archive.tar.gz"), None);
|
||||
assert_eq!(PreviewKind::of("Makefile"), None);
|
||||
assert_eq!(PreviewKind::of("nginx.conf"), None);
|
||||
assert!(PreviewKind::of("a.png").unwrap().preview_only());
|
||||
assert!(!PreviewKind::of("a.svg").unwrap().preview_only());
|
||||
assert!(!PreviewKind::Markdown.preview_only());
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ impl ExplorerPanel {
|
||||
) -> TransferSources {
|
||||
let pane = self.pane(remote).read(cx);
|
||||
let listed: HashMap<String, bool> = pane
|
||||
.entries(cx)
|
||||
.listed_entries(cx)
|
||||
.iter()
|
||||
.filter(|entry| !entry.is_parent())
|
||||
.map(|entry| (pane.child_path_of(&entry.name), entry.is_dir()))
|
||||
@@ -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}并关闭"))
|
||||
|
||||
+6
-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;
|
||||
@@ -37,5 +38,9 @@ mod testing;
|
||||
/// Initialize GPUI Kit, locale, key bindings and global actions. Call once
|
||||
/// inside `Application::run` before creating any view.
|
||||
pub fn init(cx: &mut gpui_kit::App) {
|
||||
// The identity the system shows notifications under, set before any
|
||||
// window opens: on Windows the AppUserModelID, the same as the macOS
|
||||
// bundle's.
|
||||
cx.set_app_identity("com.shellrs.ShellRS", "ShellRS");
|
||||
app::init(cx);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
use gpui_kit::component::{ActiveTheme as _, Theme};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::explorer::FileSizeFormat;
|
||||
use crate::explorer::{FileSizeFormat, ShowHiddenFiles};
|
||||
use crate::terminal::{TerminalFont, is_font_installed};
|
||||
|
||||
use super::AppSettings;
|
||||
|
||||
/// Bring the window in line with the settings: the theme, the locale, the
|
||||
/// terminal font and the SFTP size format. Does nothing where they already agree, so it can run
|
||||
/// on every settings change and every change of the system appearance.
|
||||
/// terminal font, the SFTP size format and whether SFTP shows hidden files.
|
||||
/// Does nothing where they already agree, so it can run on every settings
|
||||
/// change and every change of the system appearance.
|
||||
pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) {
|
||||
let mode = settings.appearance.theme_mode(window.appearance());
|
||||
if cx.theme().mode != mode {
|
||||
@@ -44,4 +45,9 @@ pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) {
|
||||
cx.set_global(settings.file_size_format);
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
// The SFTP lists observe it and list their rows again.
|
||||
if cx.try_global::<ShowHiddenFiles>() != Some(&settings.show_hidden) {
|
||||
cx.set_global(settings.show_hidden);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -7,8 +7,8 @@ mod store;
|
||||
|
||||
pub use apply::apply;
|
||||
pub use model::{
|
||||
AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, TerminalFontSettings,
|
||||
UpdateSettings,
|
||||
AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, NotificationSettings,
|
||||
TerminalFontSettings, UpdateSettings,
|
||||
};
|
||||
pub use settings_panel::{SettingsPanel, SettingsPanelEvent};
|
||||
pub use store::{SettingsStore, SettingsStoreEvent};
|
||||
+45
-1
@@ -2,7 +2,7 @@ use gpui_kit::WindowAppearance;
|
||||
use gpui_kit::component::ThemeMode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::explorer::FileSizeFormat;
|
||||
use crate::explorer::{FileSizeFormat, ShowHiddenFiles};
|
||||
use crate::terminal::{DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE};
|
||||
use crate::update::Channel;
|
||||
|
||||
@@ -20,6 +20,31 @@ pub struct AppSettings {
|
||||
/// The SFTP 大小 column, switched from the column title's menu rather
|
||||
/// than the settings page, as in WinSCP.
|
||||
pub file_size_format: FileSizeFormat,
|
||||
/// 显示隐藏文件 in the SFTP lists, each side on its own, switched from
|
||||
/// their toolbars and menus.
|
||||
pub show_hidden: ShowHiddenFiles,
|
||||
/// 终端 → 通知.
|
||||
pub notifications: NotificationSettings,
|
||||
}
|
||||
|
||||
/// 终端 → 通知: what a terminal may tell the user about while they look
|
||||
/// elsewhere. Both on until the user turns them off.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct NotificationSettings {
|
||||
/// 程序发送的通知: OSC 9 and 777.
|
||||
pub programs: bool,
|
||||
/// 响铃时通知.
|
||||
pub bell: bool,
|
||||
}
|
||||
|
||||
impl Default for NotificationSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
programs: true,
|
||||
bell: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 外部 CLI.
|
||||
@@ -265,6 +290,25 @@ mod tests {
|
||||
let settings: AppSettings =
|
||||
serde_json::from_str(r#"{"file_size_format":"short"}"#).unwrap();
|
||||
assert_eq!(settings.file_size_format, FileSizeFormat::Short);
|
||||
// Hidden files are left out unless the file says otherwise, for
|
||||
// each side on its own.
|
||||
assert_eq!(
|
||||
AppSettings::default().show_hidden,
|
||||
ShowHiddenFiles::default()
|
||||
);
|
||||
let settings: AppSettings =
|
||||
serde_json::from_str(r#"{"show_hidden":{"remote":true}}"#).unwrap();
|
||||
assert!(settings.show_hidden.remote);
|
||||
assert!(!settings.show_hidden.local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_are_on_unless_the_file_says_otherwise() {
|
||||
let settings: AppSettings = serde_json::from_str("{}").unwrap();
|
||||
assert!(settings.notifications.programs && settings.notifications.bell);
|
||||
let settings: AppSettings =
|
||||
serde_json::from_str(r#"{"notifications":{"bell":false}}"#).unwrap();
|
||||
assert!(settings.notifications.programs && !settings.notifications.bell);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::terminal::{
|
||||
};
|
||||
use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform};
|
||||
|
||||
use super::{AppSettings, Choice, SettingsStore};
|
||||
use super::{AppSettings, Choice, NotificationSettings, SettingsStore};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SettingsPanelEvent {
|
||||
@@ -315,9 +315,48 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
|
||||
})
|
||||
.keywords(["预览", "字体", "字号", "行高"]),
|
||||
]),
|
||||
notification_group(store),
|
||||
]
|
||||
}
|
||||
|
||||
/// 终端 → 通知: when the window is not in front, the system's notification;
|
||||
/// when another tab is, one in the window.
|
||||
fn notification_group(store: &Entity<SettingsStore>) -> SettingGroup {
|
||||
let switch = |read: fn(&NotificationSettings) -> bool,
|
||||
write: fn(&mut NotificationSettings, bool)| {
|
||||
let (reader, writer) = (store.clone(), store.clone());
|
||||
SettingField::switch(
|
||||
move |cx| read(&reader.read(cx).settings().notifications),
|
||||
move |on, cx| {
|
||||
writer.update(cx, |store, cx| {
|
||||
store.update(|settings| write(&mut settings.notifications, on), cx)
|
||||
});
|
||||
},
|
||||
)
|
||||
.default_value(true)
|
||||
};
|
||||
SettingGroup::new()
|
||||
.title("通知")
|
||||
.description(
|
||||
"ShellRS 不在前台时发系统通知;在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。",
|
||||
)
|
||||
.items([
|
||||
SettingItem::new(
|
||||
"程序发送的通知",
|
||||
switch(
|
||||
|settings| settings.programs,
|
||||
|settings, on| settings.programs = on,
|
||||
),
|
||||
)
|
||||
.description("远程或本地程序用 OSC 9 / 777 请求的通知,例如 printf '\\e]9;完成\\a'。"),
|
||||
SettingItem::new(
|
||||
"响铃时通知",
|
||||
switch(|settings| settings.bell, |settings, on| settings.bell = on),
|
||||
)
|
||||
.description("终端就在当前标签时不提示。"),
|
||||
])
|
||||
}
|
||||
|
||||
/// 外部 CLI: whether the `shellrs` command may use the saved hosts, the
|
||||
/// command itself, and the skill that teaches agents to use it.
|
||||
fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
|
||||
|
||||
@@ -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,496 @@
|
||||
//! 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:#}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole file read as it is, for a preview: an image, say.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct FileBytes(Vec<u8>);
|
||||
|
||||
impl FileBytes {
|
||||
pub fn new(bytes: Vec<u8>) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
pub fn into_inner(self) -> Vec<u8> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FileBytes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "FileBytes({} bytes)", self.0.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 (bytes, stamp) = read_bytes(fs, path, EDIT_LIMIT, true).await?;
|
||||
Ok(TextFile::decode(bytes, stamp)?)
|
||||
}
|
||||
|
||||
/// Read a remote file whole, following links, binary or not; refused like
|
||||
/// `read_text` when it is not a file or larger than `limit`.
|
||||
pub(crate) async fn read_whole<F: RemoteFs>(
|
||||
fs: &F,
|
||||
path: &RemotePath,
|
||||
limit: u64,
|
||||
) -> Result<FileBytes> {
|
||||
let (bytes, _) = read_bytes(fs, path, limit, false).await?;
|
||||
Ok(FileBytes(bytes))
|
||||
}
|
||||
|
||||
/// `stat`, refuse what is not a file or too large, then read it all. For
|
||||
/// `text`, a binary file is given up on after its first chunk.
|
||||
async fn read_bytes<F: RemoteFs>(
|
||||
fs: &F,
|
||||
path: &RemotePath,
|
||||
limit: u64,
|
||||
text: bool,
|
||||
) -> Result<(Vec<u8>, FileStamp)> {
|
||||
let Some(metadata) = fs.stat(path).await? else {
|
||||
bail!("文件不存在");
|
||||
};
|
||||
if metadata.kind() != EntryKind::File {
|
||||
return Err(ReadFailure::NotFile.into());
|
||||
}
|
||||
if metadata.size() > limit {
|
||||
return Err(ReadFailure::TooLarge(metadata.size()).into());
|
||||
}
|
||||
let handle = fs.open_read(path).await?;
|
||||
let bytes = read_all(fs, &handle, metadata.size(), text).await;
|
||||
let close = fs.close(&handle).await;
|
||||
let bytes = bytes?;
|
||||
close?;
|
||||
Ok((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)
|
||||
}
|
||||
|
||||
/// Read a local file whole through `provider`, binary or not. Blocking.
|
||||
pub fn read_local_bytes(
|
||||
provider: &dyn LocalDirectoryProvider,
|
||||
path: &std::path::Path,
|
||||
limit: u64,
|
||||
) -> Result<FileBytes, ReadFailure> {
|
||||
let (bytes, _) = provider
|
||||
.read_file(path, limit)
|
||||
.map_err(ReadFailure::from_error)?;
|
||||
Ok(FileBytes(bytes))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// For `text`, a NUL early on stops it: a binary file.
|
||||
async fn read_all<F: RemoteFs>(fs: &F, handle: &str, size: u64, text: bool) -> 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 text && 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 {
|
||||
|
||||
+331
-2
@@ -2,6 +2,7 @@ use super::{
|
||||
client::RemoteFs,
|
||||
control::TransferControl,
|
||||
download::DownloadBatch,
|
||||
edit::{read_text, read_whole, 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,288 @@ 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))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_binary_file_is_read_whole_for_a_preview_within_its_limit() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let image: Vec<u8> = (0..300_000u32).map(|i| (i % 256) as u8).collect();
|
||||
remote.file("/srv/photo.png", &image);
|
||||
remote.short_reads.set(true);
|
||||
let path = remote_path("/srv/photo.png");
|
||||
let bytes = read_whole(&remote, &path, 1_000_000).await.unwrap();
|
||||
assert_eq!(bytes.into_inner(), image);
|
||||
let error = read_whole(&remote, &path, 1000).await.unwrap_err();
|
||||
assert_eq!(
|
||||
ReadFailure::from_error(error),
|
||||
ReadFailure::TooLarge(300_000)
|
||||
);
|
||||
});
|
||||
}
|
||||
+36
-2
@@ -1,6 +1,7 @@
|
||||
use super::{
|
||||
DirectoryListing, DownloadRequest, PermissionEdit, RemotePath, TransferAnswer,
|
||||
TransferProgress, TransferQuestion, UploadRequest,
|
||||
DirectoryListing, DownloadRequest, FileBytes, FileStamp, PermissionEdit, ReadFailure,
|
||||
RemotePath, SaveFailure, TextFile, TransferAnswer, TransferProgress, TransferQuestion,
|
||||
UploadRequest,
|
||||
};
|
||||
use crate::{
|
||||
connection::{ConnectionPrompt, ConnectionPromptReply, Latency},
|
||||
@@ -49,6 +50,27 @@ 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,
|
||||
},
|
||||
/// Read a whole file as it is, up to `limit` bytes, for a preview;
|
||||
/// answered by `BytesRead`.
|
||||
ReadBytes {
|
||||
request_id: u64,
|
||||
path: RemotePath,
|
||||
limit: u64,
|
||||
},
|
||||
/// 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 +104,18 @@ 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>,
|
||||
},
|
||||
BytesRead {
|
||||
request_id: u64,
|
||||
result: Result<FileBytes, ReadFailure>,
|
||||
},
|
||||
Progress(TransferProgress),
|
||||
Question(TransferQuestion),
|
||||
Notice(String),
|
||||
|
||||
+92
-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,97 @@ 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::ReadBytes {
|
||||
request_id,
|
||||
path,
|
||||
limit,
|
||||
} => {
|
||||
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_whole(connected.as_ref(), &path, limit)
|
||||
.await
|
||||
{
|
||||
Ok(bytes) => Ok(bytes),
|
||||
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::BytesRead { 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)),
|
||||
}
|
||||
}
|
||||
+230
-10
@@ -12,12 +12,15 @@ use alacritty_terminal::selection::{Selection, SelectionType};
|
||||
use alacritty_terminal::sync::FairMutex;
|
||||
use alacritty_terminal::term::cell::Flags;
|
||||
use alacritty_terminal::term::color::COUNT;
|
||||
use alacritty_terminal::term::{Config, TermMode};
|
||||
use alacritty_terminal::term::search::RegexSearch;
|
||||
use alacritty_terminal::term::{ClipboardType, Config, Osc52, TermMode};
|
||||
use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, Rgb};
|
||||
|
||||
use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency};
|
||||
use crate::host::HostOs;
|
||||
|
||||
use super::links::{LinkMarker, url_search, visible_links};
|
||||
use super::notices::{NoticeScanner, ProgramNotice, TerminalNotice};
|
||||
use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch};
|
||||
use super::{
|
||||
ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize,
|
||||
@@ -34,6 +37,8 @@ pub struct TerminalCell {
|
||||
pub flags: Flags,
|
||||
pub selected: bool,
|
||||
pub search: SearchMark,
|
||||
/// The link the cell belongs to, an index into the snapshot's `links`.
|
||||
pub link: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -47,6 +52,8 @@ pub struct TerminalSnapshot {
|
||||
pub cursor_blinking: bool,
|
||||
pub display_offset: usize,
|
||||
pub colors: [Option<Rgb>; COUNT],
|
||||
/// The addresses of the links on screen, by the cells' `link`.
|
||||
pub links: Vec<gpui_kit::SharedString>,
|
||||
}
|
||||
|
||||
impl TerminalSnapshot {
|
||||
@@ -76,6 +83,8 @@ pub struct TerminalEngine {
|
||||
/// The open find, if any. Rendering reads it through `&self` and still
|
||||
/// has to advance the search engine's cache, hence the `RefCell`.
|
||||
search: RefCell<Option<TerminalSearch>>,
|
||||
/// Finds the web addresses on screen, built once.
|
||||
url_search: RefCell<RegexSearch>,
|
||||
generation: u64,
|
||||
size: TerminalSize,
|
||||
event_sender: mpsc::Sender<TerminalUiEvent>,
|
||||
@@ -99,6 +108,7 @@ impl TerminalEngine {
|
||||
title: None,
|
||||
latency: None,
|
||||
search: RefCell::new(None),
|
||||
url_search: RefCell::new(url_search()),
|
||||
generation,
|
||||
size,
|
||||
event_sender,
|
||||
@@ -122,7 +132,7 @@ impl TerminalEngine {
|
||||
&& this
|
||||
.update(cx, |this, cx| {
|
||||
for event in batch {
|
||||
if let Some(event) = this.handle_event(event) {
|
||||
if let Some(event) = this.handle_event(event, cx) {
|
||||
cx.emit(event);
|
||||
}
|
||||
}
|
||||
@@ -138,7 +148,11 @@ impl TerminalEngine {
|
||||
|
||||
/// Fold one transport event into the engine's state. Anything the view
|
||||
/// must hear about comes back as an event to emit.
|
||||
fn handle_event(&mut self, event: TerminalUiEvent) -> Option<TerminalEvent> {
|
||||
fn handle_event(
|
||||
&mut self,
|
||||
event: TerminalUiEvent,
|
||||
cx: &mut gpui_kit::App,
|
||||
) -> Option<TerminalEvent> {
|
||||
if event.generation != self.generation {
|
||||
return None;
|
||||
}
|
||||
@@ -170,6 +184,16 @@ impl TerminalEngine {
|
||||
.unwrap_or_else(|| default_query_color(index));
|
||||
self.runtime.write(formatter(color).into_bytes());
|
||||
}
|
||||
TerminalUiEventKind::ClipboardStore(text) => {
|
||||
cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(text));
|
||||
}
|
||||
TerminalUiEventKind::Notice(notice) => {
|
||||
return Some(TerminalEvent::Notice(notice.cleaned()));
|
||||
}
|
||||
TerminalUiEventKind::Bell => {
|
||||
let line = cursor_line_text(&self.runtime.term.lock());
|
||||
return Some(TerminalEvent::Notice(TerminalNotice::bell(&line)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -453,6 +477,7 @@ impl TerminalEngine {
|
||||
.map(|search| search.visible(&term))
|
||||
.unwrap_or_default();
|
||||
let mut marker = MatchMarker::new(&matches, focused.as_ref());
|
||||
let mut links = LinkMarker::new(visible_links(&term, &mut self.url_search.borrow_mut()));
|
||||
let mut cells: Vec<_> = content
|
||||
.display_iter
|
||||
.map(|indexed| {
|
||||
@@ -469,6 +494,7 @@ impl TerminalEngine {
|
||||
range.contains_cell(&indexed, cursor_point, cursor_shape)
|
||||
}),
|
||||
search: marker.mark(indexed.point),
|
||||
link: links.mark(indexed.point, indexed.cell.hyperlink()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -485,10 +511,30 @@ impl TerminalEngine {
|
||||
cursor_blinking: term.cursor_style().blinking,
|
||||
display_offset: content.display_offset,
|
||||
colors: std::array::from_fn(|index| content.colors[index]),
|
||||
links: links.into_links(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of the line the cursor is on, or of the nearest line above it
|
||||
/// on screen that has any: where a program that rings for an answer has
|
||||
/// its question.
|
||||
fn cursor_line_text(term: &AlacrittyTerm) -> String {
|
||||
let mut line = term.grid().cursor.point.line;
|
||||
loop {
|
||||
let text: String = term.grid()[line]
|
||||
.into_iter()
|
||||
.filter(|cell| !cell.flags.contains(Flags::WIDE_CHAR_SPACER))
|
||||
.map(|cell| cell.c)
|
||||
.collect();
|
||||
let text = text.trim();
|
||||
if !text.is_empty() || line.0 <= 0 {
|
||||
return text.to_string();
|
||||
}
|
||||
line = Line(line.0 - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the leading cell and spacer of a full-width glyph visually atomic,
|
||||
/// for the selection and for find highlights alike.
|
||||
/// Alacritty stores CJK glyphs in one `WIDE_CHAR` cell followed by a
|
||||
@@ -527,6 +573,8 @@ impl Drop for TerminalEngine {
|
||||
pub enum TerminalEvent {
|
||||
PromptRequested(ConnectionPrompt),
|
||||
HostOsDetected(HostOs),
|
||||
/// Something to show the user while they look elsewhere.
|
||||
Notice(TerminalNotice),
|
||||
}
|
||||
|
||||
struct TerminalRuntime {
|
||||
@@ -563,6 +611,8 @@ impl TerminalRuntime {
|
||||
.name("shellrs-terminal-parser".into())
|
||||
.spawn(move || {
|
||||
let mut processor = Processor::new();
|
||||
// Alacritty drops the notifications programs ask for.
|
||||
let mut notices = NoticeScanner::default();
|
||||
while let Ok(event) = transport_receiver.recv_blocking() {
|
||||
match event {
|
||||
TerminalTransportEvent::Started => {
|
||||
@@ -570,6 +620,9 @@ impl TerminalRuntime {
|
||||
}
|
||||
TerminalTransportEvent::Output(bytes) => {
|
||||
processor.advance(&mut *parser_term.lock(), &bytes);
|
||||
for notice in notices.scan(&bytes) {
|
||||
parser_proxy.send(TerminalUiEventKind::Notice(notice));
|
||||
}
|
||||
parser_proxy.wakeup();
|
||||
}
|
||||
TerminalTransportEvent::HostOsDetected(os) => {
|
||||
@@ -665,7 +718,8 @@ fn terminal_config() -> Config {
|
||||
shape: CursorShape::Block,
|
||||
blinking: true,
|
||||
},
|
||||
osc52: alacritty_terminal::term::Osc52::Disabled,
|
||||
// Programs may copy to the clipboard (vim, tmux), never read it.
|
||||
osc52: Osc52::OnlyCopy,
|
||||
kitty_keyboard: false,
|
||||
..Config::default()
|
||||
}
|
||||
@@ -711,12 +765,20 @@ impl EventListener for TerminalEventProxy {
|
||||
.commands
|
||||
.send(TerminalTransportCommand::Write(response.into_bytes()));
|
||||
}
|
||||
Event::Bell => {
|
||||
self.send(TerminalUiEventKind::Bell);
|
||||
self.wakeup();
|
||||
}
|
||||
Event::Wakeup
|
||||
| Event::MouseCursorDirty
|
||||
| Event::CursorBlinkingChange
|
||||
| Event::Bell
|
||||
| Event::Exit
|
||||
| Event::ChildExit(_) => self.wakeup(),
|
||||
// Only the clipboard: a program setting the primary selection
|
||||
// on every selection must not overwrite it.
|
||||
Event::ClipboardStore(ClipboardType::Clipboard, text) => {
|
||||
self.send(TerminalUiEventKind::ClipboardStore(text));
|
||||
}
|
||||
Event::ClipboardStore(_, _) | Event::ClipboardLoad(_, _) => {}
|
||||
}
|
||||
}
|
||||
@@ -748,12 +810,20 @@ enum TerminalUiEventKind {
|
||||
Wakeup,
|
||||
Title(String),
|
||||
ResetTitle,
|
||||
Exited { code: u32, signal: Option<String> },
|
||||
Exited {
|
||||
code: u32,
|
||||
signal: Option<String>,
|
||||
},
|
||||
Failed(String),
|
||||
Prompt(ConnectionPrompt),
|
||||
HostOs(HostOs),
|
||||
Latency(Latency),
|
||||
ColorRequest(usize, Arc<dyn Fn(Rgb) -> String + Send + Sync>),
|
||||
/// A program's copy, by OSC 52.
|
||||
ClipboardStore(String),
|
||||
/// A notification a program asked for, by OSC 9 or 777.
|
||||
Notice(ProgramNotice),
|
||||
Bell,
|
||||
}
|
||||
|
||||
fn append_message(term: &Arc<FairMutex<AlacrittyTerm>>, message: &str) {
|
||||
@@ -871,11 +941,24 @@ mod tests {
|
||||
use alacritty_terminal::vte::ansi::NamedColor;
|
||||
|
||||
fn test_term(columns: usize, rows: usize) -> AlacrittyTerm {
|
||||
test_term_with_channels(columns, rows).0
|
||||
}
|
||||
|
||||
/// A terminal, with what it writes back to the program and what it
|
||||
/// tells the UI.
|
||||
fn test_term_with_channels(
|
||||
columns: usize,
|
||||
rows: usize,
|
||||
) -> (
|
||||
AlacrittyTerm,
|
||||
mpsc::Receiver<TerminalTransportCommand>,
|
||||
mpsc::Receiver<TerminalUiEvent>,
|
||||
) {
|
||||
let size = TerminalSize::new(columns, rows, 8, 16);
|
||||
let (commands, _command_receiver) = mpsc::channel();
|
||||
let (ui_events, _ui_receiver) = mpsc::channel();
|
||||
let (commands, command_receiver) = mpsc::channel();
|
||||
let (ui_events, ui_receiver) = mpsc::channel();
|
||||
let config = terminal_config();
|
||||
AlacrittyTerm::new(
|
||||
let term = AlacrittyTerm::new(
|
||||
config,
|
||||
&size,
|
||||
TerminalEventProxy {
|
||||
@@ -885,7 +968,143 @@ mod tests {
|
||||
size: Arc::new(Mutex::new(size)),
|
||||
wakeup_pending: Arc::new(AtomicBool::new(false)),
|
||||
},
|
||||
)
|
||||
);
|
||||
(term, command_receiver, ui_receiver)
|
||||
}
|
||||
|
||||
/// Each link on screen: the text of its cells and where it leads.
|
||||
fn screen_links(term: &AlacrittyTerm) -> Vec<(String, String)> {
|
||||
let mut marker = LinkMarker::new(visible_links(term, &mut url_search()));
|
||||
let mut texts: Vec<String> = Vec::new();
|
||||
for indexed in term.renderable_content().display_iter {
|
||||
let Some(link) = marker.mark(indexed.point, indexed.cell.hyperlink()) else {
|
||||
continue;
|
||||
};
|
||||
if texts.len() <= link {
|
||||
texts.resize(link + 1, String::new());
|
||||
}
|
||||
if !indexed.cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
|
||||
texts[link].push(indexed.cell.c);
|
||||
}
|
||||
}
|
||||
let links = marker.into_links();
|
||||
texts
|
||||
.into_iter()
|
||||
.zip(links.iter().map(ToString::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn link(text: &str, uri: &str) -> (String, String) {
|
||||
(text.to_string(), uri.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_addresses_in_the_text_are_links_without_the_sentence_around_them() {
|
||||
let mut term = test_term(80, 4);
|
||||
feed(
|
||||
&mut term,
|
||||
"Docs: https://help.ubuntu.com. (see https://a.b/c)\r\n详见https://a.b/d,谢谢\r\n"
|
||||
.as_bytes(),
|
||||
);
|
||||
feed(
|
||||
&mut term,
|
||||
b"https://en.wikipedia.org/wiki/Rust_(lang) ftp://a.b www.c.d\r\n",
|
||||
);
|
||||
assert_eq!(
|
||||
screen_links(&term),
|
||||
[
|
||||
link("https://help.ubuntu.com", "https://help.ubuntu.com"),
|
||||
link("https://a.b/c", "https://a.b/c"),
|
||||
link("https://a.b/d", "https://a.b/d"),
|
||||
link(
|
||||
"https://en.wikipedia.org/wiki/Rust_(lang)",
|
||||
"https://en.wikipedia.org/wiki/Rust_(lang)"
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_address_is_one_link() {
|
||||
let mut term = test_term(20, 4);
|
||||
feed(&mut term, b"go https://example.com/a/long/path now\r\n");
|
||||
let url = "https://example.com/a/long/path";
|
||||
assert_eq!(screen_links(&term), [link(url, url)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_marked_link_leads_where_it_says_and_only_to_the_web() {
|
||||
let mut term = test_term(80, 4);
|
||||
feed(
|
||||
&mut term,
|
||||
"\x1b]8;;https://example.com/real\x1b\\示例\x1b]8;;\x1b\\ ".as_bytes(),
|
||||
);
|
||||
// The text of a marked link is not looked at: it is the mark that
|
||||
// says where it goes.
|
||||
feed(
|
||||
&mut term,
|
||||
b"\x1b]8;;https://x.y/\x1b\\https://elsewhere.z\x1b]8;;\x1b\\\r\n",
|
||||
);
|
||||
feed(
|
||||
&mut term,
|
||||
b"\x1b]8;;file://host/etc/hosts\x1b\\hosts\x1b]8;;\x1b\\\r\n",
|
||||
);
|
||||
assert_eq!(
|
||||
screen_links(&term),
|
||||
[
|
||||
link("示例", "https://example.com/real"),
|
||||
link("https://elsewhere.z", "https://x.y/"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_and_the_bell_reach_the_ui() {
|
||||
let (mut term, _, ui_events) = test_term_with_channels(40, 4);
|
||||
// The parser thread scans what it feeds the terminal.
|
||||
let output = "构建中\r\n\x1b]777;notify;构建;完成\x07ok?\x07".as_bytes();
|
||||
let mut scanner = NoticeScanner::default();
|
||||
let found = scanner.scan(output);
|
||||
feed(&mut term, output);
|
||||
assert_eq!(
|
||||
found,
|
||||
[ProgramNotice {
|
||||
title: Some("构建".into()),
|
||||
body: "完成".into()
|
||||
}]
|
||||
);
|
||||
let bells = ui_events
|
||||
.try_iter()
|
||||
.filter(|event| matches!(event.kind, TerminalUiEventKind::Bell))
|
||||
.count();
|
||||
assert_eq!(bells, 1, "the BEL ending the OSC does not ring");
|
||||
assert_eq!(cursor_line_text(&term), "ok?");
|
||||
// An empty cursor line gives the nearest line above with text.
|
||||
feed(&mut term, b"\r\n\r\n");
|
||||
assert_eq!(cursor_line_text(&term), "ok?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_may_copy_to_the_clipboard_but_not_read_it() {
|
||||
let (mut term, commands, ui_events) = test_term_with_channels(20, 2);
|
||||
feed(&mut term, b"\x1b]52;c;aGVsbG8=\x07");
|
||||
let copied: Vec<_> = ui_events
|
||||
.try_iter()
|
||||
.filter_map(|event| match event.kind {
|
||||
TerminalUiEventKind::ClipboardStore(text) => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(copied, ["hello"]);
|
||||
// The primary selection is left alone, and asking for the clipboard
|
||||
// gets no answer.
|
||||
feed(&mut term, b"\x1b]52;p;aGVsbG8=\x07\x1b]52;c;?\x07");
|
||||
assert!(
|
||||
ui_events
|
||||
.try_iter()
|
||||
.all(|event| !matches!(event.kind, TerminalUiEventKind::ClipboardStore(_)))
|
||||
);
|
||||
assert!(commands.try_iter().next().is_none());
|
||||
}
|
||||
|
||||
fn feed(term: &mut AlacrittyTerm, bytes: &[u8]) {
|
||||
@@ -1168,6 +1387,7 @@ mod tests {
|
||||
} else {
|
||||
SearchMark::None
|
||||
},
|
||||
link: None,
|
||||
};
|
||||
let mut cells = vec![
|
||||
cell(Flags::WIDE_CHAR, true),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Links in the terminal: web addresses found in the text, and the ones a
|
||||
//! program marks itself with OSC 8 (`ls --hyperlink`, gcc, systemd), whose
|
||||
//! text may differ from where they lead.
|
||||
//!
|
||||
//! Only `http` and `https` count. Any other scheme names something on the
|
||||
//! server (`file://`) or starts a local program, neither of which a click in
|
||||
//! a remote terminal should open.
|
||||
|
||||
use alacritty_terminal::index::{Boundary, Point};
|
||||
use alacritty_terminal::term::Term;
|
||||
use alacritty_terminal::term::cell::Hyperlink;
|
||||
use alacritty_terminal::term::search::{Match, RegexSearch};
|
||||
use gpui_kit::SharedString;
|
||||
|
||||
use super::search::visible_matches;
|
||||
|
||||
/// A web address in the text: the characters RFC 3986 allows after
|
||||
/// `http://` or `https://`. Written in lower case, so the search ignores
|
||||
/// case. Anything else, a space or a Chinese comma, ends it.
|
||||
const URL_PATTERN: &str = r"https?://[a-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+";
|
||||
|
||||
pub fn url_search() -> RegexSearch {
|
||||
RegexSearch::new(URL_PATTERN).expect("the link pattern is valid")
|
||||
}
|
||||
|
||||
pub fn is_web_link(uri: &str) -> bool {
|
||||
let lower = uri.get(..8).unwrap_or(uri).to_ascii_lowercase();
|
||||
lower.starts_with("http://") || lower.starts_with("https://")
|
||||
}
|
||||
|
||||
/// `text` without what ends a sentence rather than the address: a full
|
||||
/// stop, a comma, a quote, or a closing bracket with no opening one inside.
|
||||
/// `None` when nothing but the scheme is left.
|
||||
fn trim_url(text: &str) -> Option<&str> {
|
||||
let mut url = text;
|
||||
while let Some(last) = url.chars().last() {
|
||||
let unbalanced = |open: char, close: char| {
|
||||
last == close && url.matches(open).count() < url.matches(close).count()
|
||||
};
|
||||
if ".,;:!?'\"*".contains(last) || unbalanced('(', ')') || unbalanced('[', ']') {
|
||||
url = &url[..url.len() - last.len_utf8()];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (_, rest) = url.split_once("://")?;
|
||||
(!rest.is_empty()).then_some(url)
|
||||
}
|
||||
|
||||
/// The web addresses on screen, in reading order, each with the cells it
|
||||
/// covers. One that wraps onto the next line is one link.
|
||||
pub fn visible_links<T>(term: &Term<T>, regex: &mut RegexSearch) -> Vec<(Match, SharedString)> {
|
||||
visible_matches(term, regex)
|
||||
.into_iter()
|
||||
.filter_map(|found| {
|
||||
let text = term.bounds_to_string(*found.start(), *found.end());
|
||||
let url = trim_url(&text)?;
|
||||
// What was cut is ASCII punctuation, one cell a character.
|
||||
let cut = text[url.len()..].chars().count();
|
||||
let end = found.end().sub(term, Boundary::Grid, cut);
|
||||
Some((*found.start()..=end, SharedString::from(url.to_string())))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Tells, cell by cell in reading order, which link a cell belongs to, and
|
||||
/// collects the links' addresses. A link marked with OSC 8 takes its cells
|
||||
/// over any address found in their text; the cells of one such link (the
|
||||
/// same id and address) are one link wherever they are, as the spec asks.
|
||||
pub struct LinkMarker {
|
||||
found: Vec<(Match, SharedString)>,
|
||||
next: usize,
|
||||
/// The link the found address `next` became, once a cell of it is marked.
|
||||
next_link: Option<usize>,
|
||||
marked: Vec<(Hyperlink, usize)>,
|
||||
links: Vec<SharedString>,
|
||||
}
|
||||
|
||||
impl LinkMarker {
|
||||
/// `found` must be in reading order, as `visible_links` returns it.
|
||||
pub fn new(found: Vec<(Match, SharedString)>) -> Self {
|
||||
Self {
|
||||
found,
|
||||
next: 0,
|
||||
next_link: None,
|
||||
marked: Vec::new(),
|
||||
links: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The link of the cell at `point`, which carries `hyperlink`. Points
|
||||
/// must come in reading order.
|
||||
pub fn mark(&mut self, point: Point, hyperlink: Option<Hyperlink>) -> Option<usize> {
|
||||
if let Some(hyperlink) = hyperlink.filter(|link| is_web_link(link.uri())) {
|
||||
if let Some((_, link)) = self.marked.iter().find(|(seen, _)| *seen == hyperlink) {
|
||||
return Some(*link);
|
||||
}
|
||||
let link = self.push(hyperlink.uri());
|
||||
self.marked.push((hyperlink, link));
|
||||
return Some(link);
|
||||
}
|
||||
while self
|
||||
.found
|
||||
.get(self.next)
|
||||
.is_some_and(|(found, _)| *found.end() < point)
|
||||
{
|
||||
self.next += 1;
|
||||
self.next_link = None;
|
||||
}
|
||||
let (found, url) = self.found.get(self.next)?;
|
||||
if !found.contains(&point) {
|
||||
return None;
|
||||
}
|
||||
if self.next_link.is_none() {
|
||||
let url = url.clone();
|
||||
self.next_link = Some(self.push(&url));
|
||||
}
|
||||
self.next_link
|
||||
}
|
||||
|
||||
/// The addresses, by the numbers `mark` gave.
|
||||
pub fn into_links(self) -> Vec<SharedString> {
|
||||
self.links
|
||||
}
|
||||
|
||||
fn push(&mut self, uri: &str) -> usize {
|
||||
self.links.push(uri.to_string().into());
|
||||
self.links.len() - 1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sentence_punctuation_and_unmatched_brackets_end_an_address() {
|
||||
assert_eq!(
|
||||
trim_url("https://help.ubuntu.com."),
|
||||
Some("https://help.ubuntu.com")
|
||||
);
|
||||
assert_eq!(
|
||||
trim_url("https://example.com/a?b=1,"),
|
||||
Some("https://example.com/a?b=1")
|
||||
);
|
||||
assert_eq!(trim_url("https://a.b/c)"), Some("https://a.b/c"));
|
||||
assert_eq!(trim_url("https://a.b/c)."), Some("https://a.b/c"));
|
||||
assert_eq!(
|
||||
trim_url("https://en.wikipedia.org/wiki/Rust_(programming_language)"),
|
||||
Some("https://en.wikipedia.org/wiki/Rust_(programming_language)")
|
||||
);
|
||||
assert_eq!(trim_url("https://a.b/[1]"), Some("https://a.b/[1]"));
|
||||
assert_eq!(trim_url("https://a.b/c'"), Some("https://a.b/c"));
|
||||
assert_eq!(trim_url("https://."), None);
|
||||
assert_eq!(trim_url("http://"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_web_addresses_are_links() {
|
||||
assert!(is_web_link("https://example.com"));
|
||||
assert!(is_web_link("HTTP://example.com"));
|
||||
assert!(!is_web_link("file://host/etc/passwd"));
|
||||
assert!(!is_web_link("mailto:a@b.c"));
|
||||
assert!(!is_web_link("http"));
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ use crate::app::{CatalogIcon, CenterTab, CloseLocalTerminal, RestartLocalTermina
|
||||
use crate::shared::{ClosableTabTitle, close_tab_items};
|
||||
|
||||
use super::{
|
||||
LocalTerminalId, SharedTerminalTransportFactory, TerminalLifecycle, TerminalStatus,
|
||||
TerminalView,
|
||||
LocalTerminalId, SharedTerminalTransportFactory, TerminalEvent, TerminalLifecycle,
|
||||
TerminalNotice, TerminalStatus, TerminalView,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -22,6 +22,7 @@ pub enum LocalTerminalPanelEvent {
|
||||
Activated(LocalTerminalId),
|
||||
Closed(LocalTerminalId),
|
||||
StatusChanged(LocalTerminalId),
|
||||
Notice(LocalTerminalId, TerminalNotice),
|
||||
}
|
||||
|
||||
/// A local login-shell Dock tab backed by the shared terminal view.
|
||||
@@ -50,10 +51,17 @@ impl LocalTerminalPanel {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let subscriptions = vec![cx.observe(&terminal, move |_, _, cx| {
|
||||
cx.emit(LocalTerminalPanelEvent::StatusChanged(id));
|
||||
cx.notify();
|
||||
})];
|
||||
let subscriptions = vec![
|
||||
cx.observe(&terminal, move |_, _, cx| {
|
||||
cx.emit(LocalTerminalPanelEvent::StatusChanged(id));
|
||||
cx.notify();
|
||||
}),
|
||||
cx.subscribe(&terminal, move |_, _, event: &TerminalEvent, cx| {
|
||||
if let TerminalEvent::Notice(notice) = event {
|
||||
cx.emit(LocalTerminalPanelEvent::Notice(id, notice.clone()));
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
id,
|
||||
@@ -64,6 +72,12 @@ impl LocalTerminalPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 「本地终端 1」: what a notification calls it, whatever title the
|
||||
/// shell gives the tab.
|
||||
pub fn default_title(&self) -> &str {
|
||||
&self.default_title
|
||||
}
|
||||
|
||||
pub fn tab_group(&self) -> Option<WeakEntity<TabGroup>> {
|
||||
self.tab_group.clone()
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
mod engine;
|
||||
mod exec;
|
||||
mod font;
|
||||
mod links;
|
||||
mod local_pty;
|
||||
mod local_terminal_panel;
|
||||
mod model;
|
||||
mod mouse;
|
||||
mod notices;
|
||||
mod search;
|
||||
mod terminal_panel;
|
||||
mod terminal_view;
|
||||
@@ -22,6 +25,7 @@ pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent};
|
||||
pub use model::{
|
||||
LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus,
|
||||
};
|
||||
pub use notices::TerminalNotice;
|
||||
pub use search::{SearchDirection, SearchMark, SearchPosition};
|
||||
pub use terminal_panel::{TerminalPanel, TerminalPanelEvent};
|
||||
pub(crate) use terminal_view::terminal_key_bindings;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Mouse reporting: what a program that asked for the mouse (vim with
|
||||
//! `mouse=a`, htop, tmux) is told of a click, a drag or the wheel, in the
|
||||
//! encodings xterm defines.
|
||||
|
||||
use alacritty_terminal::term::TermMode;
|
||||
|
||||
/// The button a report names.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ReportButton {
|
||||
Left,
|
||||
Middle,
|
||||
WheelUp,
|
||||
WheelDown,
|
||||
/// Motion with no button held, which only 1003 asks for.
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ReportKind {
|
||||
Press,
|
||||
Release,
|
||||
Motion,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MouseReport {
|
||||
pub button: ReportButton,
|
||||
pub kind: ReportKind,
|
||||
/// The cell, counted from the top left of the screen from 0.
|
||||
pub column: usize,
|
||||
pub row: usize,
|
||||
pub alt: bool,
|
||||
pub control: bool,
|
||||
}
|
||||
|
||||
/// Whether the program asked to hear of `kind`: 1000 reports presses,
|
||||
/// releases and the wheel, 1002 motion while a button is held as well, 1003
|
||||
/// all motion.
|
||||
pub fn wants(mode: TermMode, kind: ReportKind, button_held: bool) -> bool {
|
||||
match kind {
|
||||
ReportKind::Press | ReportKind::Release => mode.intersects(TermMode::MOUSE_MODE),
|
||||
ReportKind::Motion => {
|
||||
mode.contains(TermMode::MOUSE_MOTION)
|
||||
|| (button_held && mode.contains(TermMode::MOUSE_DRAG))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes that tell the program of `report`. `None` where the encoding
|
||||
/// in use cannot say it: the default one stops at column and row 223.
|
||||
pub fn encode(report: MouseReport, mode: TermMode) -> Option<Vec<u8>> {
|
||||
let button = match report.button {
|
||||
ReportButton::Left => 0,
|
||||
ReportButton::Middle => 1,
|
||||
ReportButton::None => 3,
|
||||
ReportButton::WheelUp => 64,
|
||||
ReportButton::WheelDown => 65,
|
||||
};
|
||||
let modifiers = 8 * u32::from(report.alt) + 16 * u32::from(report.control);
|
||||
let motion = if report.kind == ReportKind::Motion {
|
||||
32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let column = report.column as u32 + 1;
|
||||
let row = report.row as u32 + 1;
|
||||
if mode.contains(TermMode::SGR_MOUSE) {
|
||||
let end = if report.kind == ReportKind::Release {
|
||||
'm'
|
||||
} else {
|
||||
'M'
|
||||
};
|
||||
let code = button + modifiers + motion;
|
||||
return Some(format!("\x1b[<{code};{column};{row}{end}").into_bytes());
|
||||
}
|
||||
// The older encodings cannot tell which button went up.
|
||||
let code = if report.kind == ReportKind::Release {
|
||||
3 + modifiers
|
||||
} else {
|
||||
button + modifiers + motion
|
||||
};
|
||||
let mut bytes = b"\x1b[M".to_vec();
|
||||
bytes.push(32 + code as u8);
|
||||
for value in [column, row] {
|
||||
let value = 32 + value;
|
||||
if mode.contains(TermMode::UTF8_MOUSE) {
|
||||
// Two UTF-8 bytes at most, as in xterm.
|
||||
let character = char::from_u32(value).filter(|_| value < 0x800)?;
|
||||
let mut buffer = [0; 4];
|
||||
bytes.extend_from_slice(character.encode_utf8(&mut buffer).as_bytes());
|
||||
} else {
|
||||
bytes.push(u8::try_from(value).ok()?);
|
||||
}
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn report(button: ReportButton, kind: ReportKind, column: usize, row: usize) -> MouseReport {
|
||||
MouseReport {
|
||||
button,
|
||||
kind,
|
||||
column,
|
||||
row,
|
||||
alt: false,
|
||||
control: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sgr() -> TermMode {
|
||||
TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_reports_name_the_button_both_ways_and_count_from_one() {
|
||||
let press = report(ReportButton::Left, ReportKind::Press, 2, 0);
|
||||
assert_eq!(encode(press, sgr()).unwrap(), b"\x1b[<0;3;1M");
|
||||
let release = report(ReportButton::Left, ReportKind::Release, 2, 0);
|
||||
assert_eq!(encode(release, sgr()).unwrap(), b"\x1b[<0;3;1m");
|
||||
let middle = report(ReportButton::Middle, ReportKind::Press, 0, 4);
|
||||
assert_eq!(encode(middle, sgr()).unwrap(), b"\x1b[<1;1;5M");
|
||||
let drag = report(ReportButton::Left, ReportKind::Motion, 9, 9);
|
||||
assert_eq!(encode(drag, sgr()).unwrap(), b"\x1b[<32;10;10M");
|
||||
let wheel = report(ReportButton::WheelDown, ReportKind::Press, 0, 0);
|
||||
assert_eq!(encode(wheel, sgr()).unwrap(), b"\x1b[<65;1;1M");
|
||||
let held = MouseReport {
|
||||
alt: true,
|
||||
control: true,
|
||||
..report(ReportButton::WheelUp, ReportKind::Press, 0, 0)
|
||||
};
|
||||
assert_eq!(encode(held, sgr()).unwrap(), b"\x1b[<88;1;1M");
|
||||
// SGR has no limit.
|
||||
let far = report(ReportButton::Left, ReportKind::Press, 499, 299);
|
||||
assert_eq!(encode(far, sgr()).unwrap(), b"\x1b[<0;500;300M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_encoding_adds_32_and_stops_at_223() {
|
||||
let mode = TermMode::MOUSE_REPORT_CLICK;
|
||||
let press = report(ReportButton::Left, ReportKind::Press, 2, 0);
|
||||
assert_eq!(encode(press, mode).unwrap(), b"\x1b[M #!");
|
||||
// A release does not say which button.
|
||||
let release = MouseReport {
|
||||
control: true,
|
||||
..report(ReportButton::Middle, ReportKind::Release, 2, 0)
|
||||
};
|
||||
assert_eq!(encode(release, mode).unwrap(), b"\x1b[M3#!");
|
||||
let edge = report(ReportButton::Left, ReportKind::Press, 222, 0);
|
||||
assert_eq!(encode(edge, mode).unwrap(), b"\x1b[M \xff!");
|
||||
let beyond = report(ReportButton::Left, ReportKind::Press, 223, 0);
|
||||
assert_eq!(encode(beyond, mode), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_utf8_encoding_writes_large_coordinates_as_characters() {
|
||||
let mode = TermMode::MOUSE_REPORT_CLICK | TermMode::UTF8_MOUSE;
|
||||
let near = report(ReportButton::Left, ReportKind::Press, 2, 0);
|
||||
assert_eq!(encode(near, mode).unwrap(), b"\x1b[M #!");
|
||||
let far = report(ReportButton::Left, ReportKind::Press, 299, 0);
|
||||
let mut expected = b"\x1b[M ".to_vec();
|
||||
expected.extend_from_slice("\u{14c}!".as_bytes());
|
||||
assert_eq!(encode(far, mode).unwrap(), expected);
|
||||
let beyond = report(ReportButton::Left, ReportKind::Press, 2015, 0);
|
||||
assert_eq!(encode(beyond, mode), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_mode_asks_for_its_own_events() {
|
||||
let click = TermMode::MOUSE_REPORT_CLICK;
|
||||
let drag = TermMode::MOUSE_DRAG;
|
||||
let motion = TermMode::MOUSE_MOTION;
|
||||
for mode in [click, drag, motion] {
|
||||
assert!(wants(mode, ReportKind::Press, false));
|
||||
assert!(wants(mode, ReportKind::Release, true));
|
||||
}
|
||||
assert!(!wants(click, ReportKind::Motion, true));
|
||||
assert!(wants(drag, ReportKind::Motion, true));
|
||||
assert!(!wants(drag, ReportKind::Motion, false));
|
||||
assert!(wants(motion, ReportKind::Motion, false));
|
||||
assert!(!wants(TermMode::empty(), ReportKind::Press, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! What a terminal has to tell the user while they look elsewhere: a
|
||||
//! notification a program asks for (OSC 9 as iTerm2 has it, OSC 777 as urxvt
|
||||
//! and VTE have it), or the bell.
|
||||
//!
|
||||
//! Alacritty drops both OSC sequences, so the parser thread runs the output
|
||||
//! through `NoticeScanner` too.
|
||||
|
||||
use alacritty_terminal::vte::{Parser, Perform};
|
||||
|
||||
/// The longest title and body a notification shows, in characters.
|
||||
const TITLE_LIMIT: usize = 80;
|
||||
const BODY_LIMIT: usize = 240;
|
||||
|
||||
/// Something a terminal asks the user to look at.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TerminalNotice {
|
||||
/// A program asked for a notification. Its title, when it gave one.
|
||||
Program { title: Option<String>, body: String },
|
||||
/// The bell rang. The line the cursor is on, which a program waiting
|
||||
/// for an answer usually has its question on.
|
||||
Bell { line: String },
|
||||
}
|
||||
|
||||
impl TerminalNotice {
|
||||
/// The bell, with the line the cursor is on.
|
||||
pub fn bell(line: &str) -> Self {
|
||||
TerminalNotice::Bell {
|
||||
line: clean_text(line, BODY_LIMIT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A notification a program asked for, as it asked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ProgramNotice {
|
||||
pub title: Option<String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl ProgramNotice {
|
||||
/// The notice to show: control characters out, long text cut.
|
||||
pub fn cleaned(self) -> TerminalNotice {
|
||||
TerminalNotice::Program {
|
||||
title: self
|
||||
.title
|
||||
.map(|title| clean_text(&title, TITLE_LIMIT))
|
||||
.filter(|title| !title.is_empty()),
|
||||
body: clean_text(&self.body, BODY_LIMIT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the notifications programs ask for in terminal output, however the
|
||||
/// output is split into chunks.
|
||||
pub struct NoticeScanner {
|
||||
parser: Parser,
|
||||
found: Vec<ProgramNotice>,
|
||||
}
|
||||
|
||||
impl Default for NoticeScanner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
parser: Parser::new(),
|
||||
found: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NoticeScanner {
|
||||
/// The notifications that end in `bytes`.
|
||||
pub fn scan(&mut self, bytes: &[u8]) -> Vec<ProgramNotice> {
|
||||
let mut found = Found(&mut self.found);
|
||||
self.parser.advance(&mut found, bytes);
|
||||
std::mem::take(&mut self.found)
|
||||
}
|
||||
}
|
||||
|
||||
struct Found<'a>(&'a mut Vec<ProgramNotice>);
|
||||
|
||||
impl Perform for Found<'_> {
|
||||
fn osc_dispatch(&mut self, params: &[&[u8]], _: bool) {
|
||||
if let Some(notice) = program_notice(params) {
|
||||
self.0.push(notice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The notification an OSC sequence asks for, if it is one:
|
||||
/// - `9;body`, unless the body is a ConEmu command (`9;4;50` is a progress
|
||||
/// bar, `9;9;/tmp` the working directory);
|
||||
/// - `777;notify;title;body`.
|
||||
fn program_notice(params: &[&[u8]]) -> Option<ProgramNotice> {
|
||||
// The text after the first parameters, `;` and all: a body may hold one.
|
||||
let rest = |from: usize| -> String {
|
||||
params
|
||||
.get(from..)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|param| String::from_utf8_lossy(param))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
};
|
||||
let notice = match *params.first()? {
|
||||
b"9" => {
|
||||
let body = rest(1);
|
||||
let conemu = params.len() > 1
|
||||
&& !params[1].is_empty()
|
||||
&& params[1].iter().all(u8::is_ascii_digit);
|
||||
ProgramNotice {
|
||||
title: None,
|
||||
body: (!conemu).then_some(body)?,
|
||||
}
|
||||
}
|
||||
b"777" if params.get(1) == Some(&&b"notify"[..]) => ProgramNotice {
|
||||
title: Some(String::from_utf8_lossy(params.get(2)?).into_owned()),
|
||||
body: rest(3),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
(!notice.body.trim().is_empty()).then_some(notice)
|
||||
}
|
||||
|
||||
/// `text` on one line, as a notification shows it: control characters and
|
||||
/// runs of white space become one space, and what is longer than `max`
|
||||
/// characters is cut with an ellipsis.
|
||||
pub fn clean_text(text: &str, max: usize) -> String {
|
||||
let words: Vec<&str> = text
|
||||
.split(|character: char| character.is_whitespace() || character.is_control())
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect();
|
||||
let line = words.join(" ");
|
||||
if line.chars().count() <= max {
|
||||
return line;
|
||||
}
|
||||
let mut cut: String = line.chars().take(max.saturating_sub(1)).collect();
|
||||
cut.push('…');
|
||||
cut
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<ProgramNotice> {
|
||||
let mut scanner = NoticeScanner::default();
|
||||
chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| scanner.scan(chunk))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notice(title: Option<&str>, body: &str) -> ProgramNotice {
|
||||
ProgramNotice {
|
||||
title: title.map(str::to_string),
|
||||
body: body.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_is_a_notification_unless_it_is_a_conemu_command() {
|
||||
assert_eq!(
|
||||
scan(&[b"x\x1b]9;\xe9\x83\xa8\xe7\xbd\xb2\xe5\xae\x8c\xe6\x88\x90\x07y"]),
|
||||
[notice(None, "部署完成")]
|
||||
);
|
||||
// A body may hold a `;`.
|
||||
assert_eq!(scan(&[b"\x1b]9;a;b\x1b\\"]), [notice(None, "a;b")]);
|
||||
assert!(scan(&[b"\x1b]9;4;1;50\x07\x1b]9;9;/tmp\x07\x1b]9;\x07\x1b]9; \x07"]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_777_notify_has_a_title_and_a_body() {
|
||||
assert_eq!(
|
||||
scan(&["\x1b]777;notify;构建;完成\x07".as_bytes()]),
|
||||
[notice(Some("构建"), "完成")]
|
||||
);
|
||||
assert!(scan(&[b"\x1b]777;preexec\x07\x1b]777;notify;t\x07"]).is_empty());
|
||||
// Other sequences are not notifications.
|
||||
assert!(scan(&[b"\x1b]0;title\x07\x1b]52;c;aGk=\x07\x07"]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_split_between_chunks_is_found_once_it_ends() {
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]777;noti", b"fy;t;", b"done\x1b", b"\\"]),
|
||||
[notice(Some("t"), "done")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_text_is_one_line_and_cut_when_long() {
|
||||
assert_eq!(clean_text(" a\tb\n\x1bc ", 10), "a b c");
|
||||
assert_eq!(clean_text("一二三四五", 4), "一二三…");
|
||||
assert_eq!(clean_text("一二三四", 4), "一二三四");
|
||||
assert_eq!(
|
||||
notice(Some(" "), "x").cleaned(),
|
||||
TerminalNotice::Program {
|
||||
title: None,
|
||||
body: "x".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use crate::shared::{ClosableTabTitle, HostMark, LatencyLabel, RenamableTab, clos
|
||||
|
||||
use super::{
|
||||
RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalEvent, TerminalLifecycle,
|
||||
TerminalMenuItems, TerminalStatus, TerminalView,
|
||||
TerminalMenuItems, TerminalNotice, TerminalStatus, TerminalView,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -28,6 +28,7 @@ pub enum TerminalPanelEvent {
|
||||
StatusChanged(RemoteTerminalId, HostId),
|
||||
PromptRequested(RemoteTerminalId, HostId, ConnectionPrompt),
|
||||
HostOsDetected(HostId, HostOs),
|
||||
Notice(RemoteTerminalId, TerminalNotice),
|
||||
}
|
||||
|
||||
/// A remote-host Dock panel backed by the shared terminal engine. The
|
||||
@@ -93,6 +94,9 @@ impl TerminalPanel {
|
||||
TerminalEvent::HostOsDetected(os) => {
|
||||
cx.emit(TerminalPanelEvent::HostOsDetected(this.host_id, *os));
|
||||
}
|
||||
TerminalEvent::Notice(notice) => {
|
||||
cx.emit(TerminalPanelEvent::Notice(this.id, notice.clone()));
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
+408
-27
@@ -14,6 +14,8 @@ use gpui_kit::component::{
|
||||
h_flex,
|
||||
input::{Input, InputEvent, InputState},
|
||||
menu::PopupMenu,
|
||||
tooltip::Tooltip,
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
@@ -25,6 +27,7 @@ use crate::app::{
|
||||
};
|
||||
use crate::connection::{ConnectionPromptReply, Latency};
|
||||
|
||||
use super::mouse::{self, MouseReport, ReportButton, ReportKind};
|
||||
use super::search::SearchMark;
|
||||
use super::{
|
||||
SearchDirection, SharedTerminalTransportFactory, TerminalEngine, TerminalEvent, TerminalFont,
|
||||
@@ -40,6 +43,25 @@ pub const TERMINAL_FIND_KEY_CONTEXT: &str = "TerminalFind";
|
||||
/// the query to settle rather than running on every keystroke.
|
||||
const FIND_DEBOUNCE: Duration = Duration::from_millis(80);
|
||||
|
||||
/// How long the pointer rests on a link before its address shows.
|
||||
const LINK_TOOLTIP_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The key held to open a link with a click, as the tooltip names it.
|
||||
#[cfg(target_os = "macos")]
|
||||
const OPEN_LINK_KEY: &str = "⌘";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const OPEN_LINK_KEY: &str = "Ctrl";
|
||||
|
||||
/// Whether `modifiers` hold the key that opens a link: ⌘, or Ctrl where
|
||||
/// there is no ⌘. A plain click selects text, as it always has.
|
||||
fn opens_links(modifiers: &Modifiers) -> bool {
|
||||
if cfg!(target_os = "macos") {
|
||||
modifiers.platform
|
||||
} else {
|
||||
modifiers.control
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands a terminal's owner adds to the bottom of its context menu,
|
||||
/// built from the terminal's lifecycle when the menu opens.
|
||||
pub type TerminalMenuItems = Rc<dyn Fn(PopupMenu, &TerminalLifecycle) -> PopupMenu>;
|
||||
@@ -65,6 +87,8 @@ pub(crate) fn terminal_key_bindings() -> [KeyBinding; 2] {
|
||||
enum ScrollTarget {
|
||||
History,
|
||||
AlternateScreen,
|
||||
/// The program asked for the mouse: the wheel is reported to it.
|
||||
Report,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -130,6 +154,21 @@ struct TerminalViewport {
|
||||
}
|
||||
|
||||
impl TerminalViewport {
|
||||
/// The cell on screen under `position`, if it is over the grid.
|
||||
fn cell_at(&self, position: gpui_kit::Point<Pixels>) -> Option<(usize, usize)> {
|
||||
screen_cell(&self.geometry, self.columns, self.rows, position)
|
||||
}
|
||||
|
||||
/// The cell on screen nearest `position`, which may be outside: where a
|
||||
/// drag that left the terminal is reported.
|
||||
fn nearest_cell(&self, position: gpui_kit::Point<Pixels>) -> (usize, usize) {
|
||||
let (point, _) = self.grid_point(position);
|
||||
(
|
||||
point.column.0,
|
||||
(point.line.0 + self.display_offset as i32).max(0) as usize,
|
||||
)
|
||||
}
|
||||
|
||||
fn grid_point(&self, position: gpui_kit::Point<Pixels>) -> (TerminalPoint, Side) {
|
||||
grid_point(
|
||||
position,
|
||||
@@ -143,6 +182,15 @@ impl TerminalViewport {
|
||||
}
|
||||
}
|
||||
|
||||
/// The link under the pointer, and where the pointer is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct HoveredLink {
|
||||
/// Which link of the snapshot, as the cells number them.
|
||||
index: usize,
|
||||
uri: SharedString,
|
||||
position: gpui_kit::Point<Pixels>,
|
||||
}
|
||||
|
||||
pub struct TerminalView {
|
||||
engine: Entity<TerminalEngine>,
|
||||
element_id: ElementId,
|
||||
@@ -162,6 +210,14 @@ pub struct TerminalView {
|
||||
find: Option<FindBar>,
|
||||
cursor_visible: bool,
|
||||
focused: bool,
|
||||
hovered_link: Option<HoveredLink>,
|
||||
/// The hovered link's address shows, the pointer having rested on it.
|
||||
link_tooltip: bool,
|
||||
link_tooltip_task: Option<Task<()>>,
|
||||
/// The button the program was told went down, until it hears it go up.
|
||||
reported_button: Option<ReportButton>,
|
||||
/// The cell the program last heard of: motion is reported once a cell.
|
||||
reported_cell: Option<(usize, usize)>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
_blink_task: Task<()>,
|
||||
}
|
||||
@@ -236,6 +292,11 @@ impl TerminalView {
|
||||
find: None,
|
||||
cursor_visible: true,
|
||||
focused: false,
|
||||
hovered_link: None,
|
||||
link_tooltip: false,
|
||||
link_tooltip_task: None,
|
||||
reported_button: None,
|
||||
reported_cell: None,
|
||||
_subscriptions: subscriptions,
|
||||
_blink_task: blink_task,
|
||||
}
|
||||
@@ -280,6 +341,11 @@ impl TerminalView {
|
||||
self.context_menu_subscription = None;
|
||||
self.find = None;
|
||||
self.cursor_visible = true;
|
||||
self.hovered_link = None;
|
||||
self.link_tooltip = false;
|
||||
self.link_tooltip_task = None;
|
||||
self.reported_button = None;
|
||||
self.reported_cell = None;
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -496,9 +562,19 @@ impl TerminalView {
|
||||
self.engine.update(cx, |engine, cx| engine.resize(size, cx));
|
||||
}
|
||||
|
||||
fn scroll(&mut self, delta: ScrollDelta, line_height: Pixels, cx: &mut Context<Self>) {
|
||||
fn scroll(
|
||||
&mut self,
|
||||
delta: ScrollDelta,
|
||||
line_height: Pixels,
|
||||
cell: (usize, usize),
|
||||
modifiers: Modifiers,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mode = self.engine.read(cx).mode();
|
||||
let target = if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) {
|
||||
// Shift scrolls the history even while the program has the mouse.
|
||||
let target = if mode.intersects(TermMode::MOUSE_MODE) && !modifiers.shift {
|
||||
ScrollTarget::Report
|
||||
} else if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) {
|
||||
ScrollTarget::AlternateScreen
|
||||
} else {
|
||||
ScrollTarget::History
|
||||
@@ -507,7 +583,18 @@ impl TerminalView {
|
||||
if lines == 0 {
|
||||
return;
|
||||
}
|
||||
if target == ScrollTarget::AlternateScreen {
|
||||
// The text under the pointer moves on.
|
||||
self.hover_link(None, cx);
|
||||
if target == ScrollTarget::Report {
|
||||
let button = if lines > 0 {
|
||||
ReportButton::WheelUp
|
||||
} else {
|
||||
ReportButton::WheelDown
|
||||
};
|
||||
for _ in 0..lines.unsigned_abs().min(12) {
|
||||
self.report_mouse(button, ReportKind::Press, cell, modifiers, cx);
|
||||
}
|
||||
} else if target == ScrollTarget::AlternateScreen {
|
||||
let sequence = if lines > 0 { b"\x1bOA" } else { b"\x1bOB" };
|
||||
for _ in 0..lines.unsigned_abs().min(12) {
|
||||
self.engine.read(cx).write(sequence.to_vec());
|
||||
@@ -536,7 +623,87 @@ impl TerminalView {
|
||||
.update(cx, |engine, cx| engine.update_selection(point, side, cx));
|
||||
}
|
||||
|
||||
/// Tell the program of the mouse, if it asked to hear of it. Shift keeps
|
||||
/// the mouse for selecting text, unless a reported button is down.
|
||||
/// Whether the program was told.
|
||||
fn report_mouse(
|
||||
&mut self,
|
||||
button: ReportButton,
|
||||
kind: ReportKind,
|
||||
cell: (usize, usize),
|
||||
modifiers: Modifiers,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let mode = self.engine.read(cx).mode();
|
||||
let held = self.reported_button.is_some();
|
||||
if (modifiers.shift && !held) || !mouse::wants(mode, kind, held) {
|
||||
return false;
|
||||
}
|
||||
let button = match kind {
|
||||
ReportKind::Motion if self.reported_cell == Some(cell) => return true,
|
||||
ReportKind::Motion => self.reported_button.unwrap_or(ReportButton::None),
|
||||
_ => button,
|
||||
};
|
||||
let report = MouseReport {
|
||||
button,
|
||||
kind,
|
||||
column: cell.0,
|
||||
row: cell.1,
|
||||
alt: modifiers.alt,
|
||||
control: modifiers.control,
|
||||
};
|
||||
if let Some(bytes) = mouse::encode(report, mode) {
|
||||
self.engine.read(cx).write(bytes);
|
||||
}
|
||||
self.reported_cell = Some(cell);
|
||||
if kind == ReportKind::Press && matches!(button, ReportButton::Left | ReportButton::Middle)
|
||||
{
|
||||
self.reported_button = Some(button);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A button went up: the program hears of it if it heard it go down.
|
||||
fn release_mouse(
|
||||
&mut self,
|
||||
button: ReportButton,
|
||||
cell: (usize, usize),
|
||||
modifiers: Modifiers,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.reported_button == Some(button) {
|
||||
self.report_mouse(button, ReportKind::Release, cell, modifiers, cx);
|
||||
self.reported_button = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer moved onto a link or off one. Its address shows once the
|
||||
/// pointer rests there.
|
||||
fn hover_link(&mut self, link: Option<HoveredLink>, cx: &mut Context<Self>) {
|
||||
let same = match (&self.hovered_link, &link) {
|
||||
(Some(hovered), Some(link)) => hovered.index == link.index && hovered.uri == link.uri,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if same {
|
||||
return;
|
||||
}
|
||||
self.link_tooltip = false;
|
||||
self.link_tooltip_task = link.as_ref().map(|_| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(LINK_TOOLTIP_DELAY).await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.link_tooltip = true;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
});
|
||||
self.hovered_link = link;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn send_user_input(&mut self, bytes: impl Into<Vec<u8>>, cx: &mut Context<Self>) {
|
||||
self.hover_link(None, cx);
|
||||
self.selecting = false;
|
||||
self.scroll_accumulator.reset();
|
||||
self.cursor_visible = true;
|
||||
@@ -730,7 +897,7 @@ impl EntityInputHandler for TerminalView {
|
||||
}
|
||||
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let snapshot = self.engine.read(cx).snapshot();
|
||||
let view = cx.entity();
|
||||
let context_menu = self.context_menu.clone();
|
||||
@@ -773,7 +940,22 @@ impl Render for TerminalView {
|
||||
)
|
||||
.with_priority(gpui_kit::base::POPUP_PRIORITY),
|
||||
)
|
||||
});
|
||||
})
|
||||
// Below the pointer, so the pointer stays on the link.
|
||||
.when_some(
|
||||
self.hovered_link.clone().filter(|_| self.link_tooltip),
|
||||
|terminal, link| {
|
||||
terminal.child(
|
||||
deferred(
|
||||
anchored()
|
||||
.position(link.position + point(px(0.), px(20.)))
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.child(link_tooltip(link.uri, window, cx)),
|
||||
)
|
||||
.with_priority(1),
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
// The find bar is the terminal's sibling, not its child: keys typed
|
||||
// into it must not bubble to the terminal's key handler, which would
|
||||
@@ -789,6 +971,24 @@ impl Render for TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a link leads and how to open it: the address may not be its text.
|
||||
fn link_tooltip(uri: SharedString, window: &mut Window, cx: &mut App) -> AnyView {
|
||||
Tooltip::element(move |_, cx| {
|
||||
v_flex()
|
||||
.id("terminal-link-tooltip")
|
||||
.test_support()
|
||||
.aria_label(uri.clone())
|
||||
.max_w(rems(32.))
|
||||
.child(uri.clone())
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("{OPEN_LINK_KEY} 单击打开")),
|
||||
)
|
||||
})
|
||||
.build(window, cx)
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
fn render_find_bar(&self, find: &FindBar, cx: &App) -> impl IntoElement {
|
||||
let has_query = !find.input.read(cx).value().is_empty();
|
||||
@@ -975,6 +1175,19 @@ impl Element for TerminalElement {
|
||||
cell_width,
|
||||
line_height,
|
||||
};
|
||||
// The link under the pointer, lit a little brighter.
|
||||
let hovered_link = screen_cell(
|
||||
&geometry,
|
||||
self.snapshot.columns,
|
||||
self.snapshot.rows,
|
||||
window.mouse_position(),
|
||||
)
|
||||
.and_then(|(column, row)| {
|
||||
self.snapshot
|
||||
.cells
|
||||
.get(row * self.snapshot.columns + column)?
|
||||
.link
|
||||
});
|
||||
let mut lines = Vec::with_capacity(self.snapshot.rows);
|
||||
let mut backgrounds = Vec::new();
|
||||
for (row_index, row) in self
|
||||
@@ -1022,6 +1235,16 @@ impl Element for TerminalElement {
|
||||
let font = terminal_font(text_style.font(), cell.flags);
|
||||
let len = character.len();
|
||||
text.push_str(character);
|
||||
// Links always show as links, blue whatever color the
|
||||
// program gave them. Not the theme's `link`: that is the
|
||||
// color of text.
|
||||
let link = cell.link.map(|link| {
|
||||
if hovered_link == Some(link) {
|
||||
cx.theme().blue_light
|
||||
} else {
|
||||
cx.theme().blue
|
||||
}
|
||||
});
|
||||
runs.push(TextRun {
|
||||
len,
|
||||
font,
|
||||
@@ -1030,16 +1253,25 @@ impl Element for TerminalElement {
|
||||
} else if cell.search == SearchMark::Focused {
|
||||
cx.theme().warning_foreground
|
||||
} else {
|
||||
foreground
|
||||
link.unwrap_or(foreground)
|
||||
},
|
||||
background_color: None,
|
||||
underline: cell.flags.intersects(Flags::ALL_UNDERLINES).then_some(
|
||||
UnderlineStyle {
|
||||
underline: match link {
|
||||
Some(color) => Some(UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
color: Some(foreground),
|
||||
wavy: cell.flags.contains(Flags::UNDERCURL),
|
||||
},
|
||||
),
|
||||
color: Some(color),
|
||||
wavy: false,
|
||||
}),
|
||||
None => {
|
||||
cell.flags
|
||||
.intersects(Flags::ALL_UNDERLINES)
|
||||
.then_some(UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
color: Some(foreground),
|
||||
wavy: cell.flags.contains(Flags::UNDERCURL),
|
||||
})
|
||||
}
|
||||
},
|
||||
strikethrough: cell.flags.contains(Flags::STRIKEOUT).then_some(
|
||||
StrikethroughStyle {
|
||||
thickness: px(1.),
|
||||
@@ -1176,21 +1408,125 @@ impl Element for TerminalElement {
|
||||
let hitbox = state.hitbox.clone();
|
||||
let view = self.view.clone();
|
||||
let viewport = state.viewport;
|
||||
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
|
||||
if event.button != MouseButton::Left || !phase.bubble() || !hitbox.is_hovered(window) {
|
||||
return;
|
||||
let links = Rc::new(ScreenLinks::new(&self.snapshot));
|
||||
|
||||
// A hand over a link while the key that opens it is held; pressing
|
||||
// or letting go of the key changes it at once.
|
||||
let pointer_on_link = hitbox.is_hovered(window)
|
||||
&& viewport
|
||||
.cell_at(window.mouse_position())
|
||||
.and_then(|cell| links.at(cell))
|
||||
.is_some();
|
||||
if pointer_on_link {
|
||||
if opens_links(&window.modifiers()) {
|
||||
window.set_cursor_style(CursorStyle::PointingHand, &hitbox);
|
||||
}
|
||||
let view = view.clone();
|
||||
window.on_modifiers_changed(move |_, _, cx| view.update(cx, |_, cx| cx.notify()));
|
||||
}
|
||||
|
||||
// A click opens a link with ⌘ held; otherwise it goes to the program
|
||||
// that asked for the mouse, or selects text.
|
||||
window.on_mouse_event({
|
||||
let (hitbox, view, links) = (hitbox.clone(), view.clone(), links.clone());
|
||||
move |event: &MouseDownEvent, phase, window, cx| {
|
||||
if !phase.bubble() || !hitbox.is_hovered(window) {
|
||||
return;
|
||||
}
|
||||
let Some(cell) = viewport.cell_at(event.position) else {
|
||||
return;
|
||||
};
|
||||
let button = match event.button {
|
||||
MouseButton::Left => ReportButton::Left,
|
||||
MouseButton::Middle => ReportButton::Middle,
|
||||
_ => return,
|
||||
};
|
||||
if button == ReportButton::Left
|
||||
&& opens_links(&event.modifiers)
|
||||
&& let Some((_, uri)) = links.at(cell)
|
||||
{
|
||||
cx.open_url(uri);
|
||||
view.update(cx, |view, cx| view.hover_link(None, cx));
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
let reported = view.update(cx, |view, cx| {
|
||||
view.focus(window, cx);
|
||||
view.report_mouse(button, ReportKind::Press, cell, event.modifiers, cx)
|
||||
});
|
||||
if reported {
|
||||
cx.stop_propagation();
|
||||
} else if button == ReportButton::Left {
|
||||
let (point, side) = viewport.grid_point(event.position);
|
||||
let selection_type = match event.click_count {
|
||||
2 => SelectionType::Semantic,
|
||||
count if count >= 3 => SelectionType::Lines,
|
||||
_ => SelectionType::Simple,
|
||||
};
|
||||
view.update(cx, |view, cx| {
|
||||
view.start_selection(point, side, selection_type, cx);
|
||||
});
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The link under the pointer, for its tooltip; and motion, for a
|
||||
// program that asked for it. A drag that leaves the terminal is
|
||||
// reported at its edge.
|
||||
window.on_mouse_event({
|
||||
let (hitbox, view, links) = (hitbox.clone(), view.clone(), links.clone());
|
||||
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||
if !phase.bubble() {
|
||||
return;
|
||||
}
|
||||
let cell = hitbox
|
||||
.is_hovered(window)
|
||||
.then(|| viewport.cell_at(event.position))
|
||||
.flatten();
|
||||
let link = cell
|
||||
.and_then(|cell| links.at(cell))
|
||||
.map(|(index, uri)| HoveredLink {
|
||||
index,
|
||||
uri: uri.clone(),
|
||||
position: event.position,
|
||||
});
|
||||
view.update(cx, |view, cx| {
|
||||
view.hover_link(link, cx);
|
||||
let cell = match (cell, view.reported_button) {
|
||||
(_, Some(_)) => Some(viewport.nearest_cell(event.position)),
|
||||
(cell, None) => cell,
|
||||
};
|
||||
if let Some(cell) = cell {
|
||||
view.report_mouse(
|
||||
ReportButton::None,
|
||||
ReportKind::Motion,
|
||||
cell,
|
||||
event.modifiers,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// A reported button going up is reported wherever it is let go.
|
||||
window.on_mouse_event({
|
||||
let view = view.clone();
|
||||
move |event: &MouseUpEvent, phase, _, cx| {
|
||||
if !phase.capture() {
|
||||
return;
|
||||
}
|
||||
let button = match event.button {
|
||||
MouseButton::Left => ReportButton::Left,
|
||||
MouseButton::Middle => ReportButton::Middle,
|
||||
_ => return,
|
||||
};
|
||||
let cell = viewport.nearest_cell(event.position);
|
||||
view.update(cx, |view, cx| {
|
||||
view.release_mouse(button, cell, event.modifiers, cx)
|
||||
});
|
||||
}
|
||||
let (point, side) = viewport.grid_point(event.position);
|
||||
let selection_type = match event.click_count {
|
||||
2 => SelectionType::Semantic,
|
||||
count if count >= 3 => SelectionType::Lines,
|
||||
_ => SelectionType::Simple,
|
||||
};
|
||||
view.update(cx, |view, cx| {
|
||||
view.focus(window, cx);
|
||||
view.start_selection(point, side, selection_type, cx);
|
||||
});
|
||||
cx.stop_propagation();
|
||||
});
|
||||
|
||||
let view = self.view.clone();
|
||||
@@ -1224,8 +1560,15 @@ impl Element for TerminalElement {
|
||||
if !phase.bubble() || !hitbox.should_handle_scroll(window) {
|
||||
return;
|
||||
}
|
||||
let cell = viewport.nearest_cell(event.position);
|
||||
view.update(cx, |view, cx| {
|
||||
view.scroll(event.delta, viewport.geometry.line_height, cx)
|
||||
view.scroll(
|
||||
event.delta,
|
||||
viewport.geometry.line_height,
|
||||
cell,
|
||||
event.modifiers,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.stop_propagation();
|
||||
});
|
||||
@@ -1443,6 +1786,44 @@ fn cursor_quad(
|
||||
Some(fill(cursor, palette.cursor.opacity(0.75)))
|
||||
}
|
||||
|
||||
/// The cell on screen under `position`, if it is over the grid.
|
||||
fn screen_cell(
|
||||
geometry: &TerminalGeometry,
|
||||
columns: usize,
|
||||
rows: usize,
|
||||
position: gpui_kit::Point<Pixels>,
|
||||
) -> Option<(usize, usize)> {
|
||||
let local = position - geometry.bounds.origin;
|
||||
if local.x < px(0.) || local.y < px(0.) || columns == 0 || rows == 0 {
|
||||
return None;
|
||||
}
|
||||
let column = (local.x / geometry.cell_width).floor() as usize;
|
||||
let row = (local.y / geometry.line_height).floor() as usize;
|
||||
(column < columns && row < rows).then_some((column, row))
|
||||
}
|
||||
|
||||
/// Which link each cell on screen belongs to, for the pointer's handlers.
|
||||
struct ScreenLinks {
|
||||
columns: usize,
|
||||
cells: Vec<Option<usize>>,
|
||||
links: Vec<SharedString>,
|
||||
}
|
||||
|
||||
impl ScreenLinks {
|
||||
fn new(snapshot: &TerminalSnapshot) -> Self {
|
||||
Self {
|
||||
columns: snapshot.columns,
|
||||
cells: snapshot.cells.iter().map(|cell| cell.link).collect(),
|
||||
links: snapshot.links.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn at(&self, (column, row): (usize, usize)) -> Option<(usize, &SharedString)> {
|
||||
let link = (*self.cells.get(row * self.columns + column)?)?;
|
||||
Some((link, self.links.get(link)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_point(
|
||||
position: gpui_kit::Point<Pixels>,
|
||||
bounds: Bounds<Pixels>,
|
||||
|
||||
+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,8 +4,10 @@
|
||||
|
||||
mod credentials;
|
||||
mod dock_skin;
|
||||
mod editors;
|
||||
mod forwards;
|
||||
mod links;
|
||||
mod notices;
|
||||
mod recent_hosts;
|
||||
mod sidebar;
|
||||
mod snippets;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! What terminals tell the user while they look elsewhere: notifications
|
||||
//! programs ask for and the bell. The system's notification when the window
|
||||
//! is not in front, one in the window when another tab is.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui_kit::component::{WindowExt as _, notification::Notification};
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::Workspace;
|
||||
use crate::app::CenterTab;
|
||||
use crate::settings::NotificationSettings;
|
||||
use crate::shared::RenamableTab as _;
|
||||
use crate::terminal::{LocalTerminalId, RemoteTerminalId, TerminalNotice};
|
||||
|
||||
/// How often one terminal may notify: a program at most every 2 seconds,
|
||||
/// the bell every 10. More in between are dropped.
|
||||
const PROGRAM_INTERVAL: Duration = Duration::from_secs(2);
|
||||
const BELL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Where a notice goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Delivery {
|
||||
/// The system's notification center: the window is not in front.
|
||||
System,
|
||||
/// A notification in the window.
|
||||
InApp,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Where `notice` goes. A program asked to notify, so it does so even over
|
||||
/// its own terminal; the bell rings for typing too (a Tab with nothing to
|
||||
/// complete), so it only tells of a terminal out of sight.
|
||||
pub(super) fn delivery(
|
||||
notice: &TerminalNotice,
|
||||
settings: NotificationSettings,
|
||||
window_active: bool,
|
||||
in_front: bool,
|
||||
) -> Delivery {
|
||||
let (allowed, quiet_in_front) = match notice {
|
||||
TerminalNotice::Program { .. } => (settings.programs, false),
|
||||
TerminalNotice::Bell { .. } => (settings.bell, true),
|
||||
};
|
||||
if !allowed {
|
||||
Delivery::None
|
||||
} else if !window_active {
|
||||
Delivery::System
|
||||
} else if in_front && quiet_in_front {
|
||||
Delivery::None
|
||||
} else {
|
||||
Delivery::InApp
|
||||
}
|
||||
}
|
||||
|
||||
/// The title and body: the tab's name first, so a notification says which
|
||||
/// terminal it is from, and a program cannot pass for another application.
|
||||
pub(super) fn notice_text(tab: &str, notice: &TerminalNotice) -> (String, String) {
|
||||
match notice {
|
||||
TerminalNotice::Program {
|
||||
title: Some(title),
|
||||
body,
|
||||
} => (format!("{tab}:{title}"), body.clone()),
|
||||
TerminalNotice::Program { title: None, body } => (tab.to_string(), body.clone()),
|
||||
TerminalNotice::Bell { line } => (format!("{tab}:响铃"), line.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a notice may go out now, the last of its kind having gone at
|
||||
/// `last`.
|
||||
pub(super) fn due(last: Option<Instant>, now: Instant, interval: Duration) -> bool {
|
||||
last.is_none_or(|last| now.saturating_duration_since(last) >= interval)
|
||||
}
|
||||
|
||||
/// The system notification's tag: one terminal's newest replaces its last,
|
||||
/// and a click on it says which terminal to show.
|
||||
pub(super) fn notice_tag(tab: CenterTab) -> Option<String> {
|
||||
match tab {
|
||||
CenterTab::Terminal(id) => Some(format!("terminal:remote:{}", id.0)),
|
||||
CenterTab::LocalTerminal(id) => Some(format!("terminal:local:{}", id.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tab_of_tag(tag: &str) -> Option<CenterTab> {
|
||||
let rest = tag.strip_prefix("terminal:")?;
|
||||
let (kind, id) = rest.split_once(':')?;
|
||||
let id = id.parse().ok()?;
|
||||
match kind {
|
||||
"remote" => Some(CenterTab::Terminal(RemoteTerminalId(id))),
|
||||
"local" => Some(CenterTab::LocalTerminal(LocalTerminalId(id))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// A terminal has something to tell: say it where the user will see it.
|
||||
pub(super) fn on_terminal_notice(
|
||||
&mut self,
|
||||
tab: CenterTab,
|
||||
notice: &TerminalNotice,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let settings = self.settings().read(cx).settings().notifications;
|
||||
let in_front = self.active_tab == Some(tab);
|
||||
let delivery = delivery(notice, settings, window.is_window_active(), in_front);
|
||||
let (Some(tag), Some(tab_title)) = (notice_tag(tab), self.notice_tab_title(tab, cx)) else {
|
||||
return;
|
||||
};
|
||||
if delivery == Delivery::None {
|
||||
return;
|
||||
}
|
||||
let (kind, interval) = match notice {
|
||||
TerminalNotice::Program { .. } => ("program", PROGRAM_INTERVAL),
|
||||
TerminalNotice::Bell { .. } => ("bell", BELL_INTERVAL),
|
||||
};
|
||||
let key = format!("{tag}:{kind}");
|
||||
// The executor's clock, which tests can move on.
|
||||
let now = cx.background_executor().now();
|
||||
if !due(self.notice_times.get(&key).copied(), now, interval) {
|
||||
return;
|
||||
}
|
||||
self.notice_times.insert(key, now);
|
||||
let (title, body) = notice_text(&tab_title, notice);
|
||||
match delivery {
|
||||
Delivery::System => cx.show_system_notification(SystemNotification {
|
||||
tag: tag.into(),
|
||||
title: title.into(),
|
||||
body: body.into(),
|
||||
actions: Vec::new(),
|
||||
}),
|
||||
Delivery::InApp => {
|
||||
let workspace = cx.weak_entity();
|
||||
let notification = Notification::new()
|
||||
.id1::<TerminalNoticeTag>(SharedString::from(tag))
|
||||
.title(title)
|
||||
.message(body)
|
||||
.on_click(move |_, window, cx| {
|
||||
_ = workspace.update(cx, |workspace, cx| {
|
||||
workspace.show_terminal_tab(tab, window, cx)
|
||||
});
|
||||
});
|
||||
window.push_notification(notification, cx);
|
||||
}
|
||||
Delivery::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A system notification was clicked: bring the window forward on the
|
||||
/// terminal it came from, if that is still open.
|
||||
pub(super) fn on_notice_clicked(
|
||||
&mut self,
|
||||
tag: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
crate::app::bring_forward(window, cx);
|
||||
if let Some(tab) = tab_of_tag(tag) {
|
||||
self.show_terminal_tab(tab, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_terminal_tab(&self, tab: CenterTab, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let target = match tab {
|
||||
CenterTab::Terminal(id) => self
|
||||
.terminals
|
||||
.get(&id)
|
||||
.map(|panel| (panel.read(cx).tab_group(), panel.entity_id())),
|
||||
CenterTab::LocalTerminal(id) => self
|
||||
.local_terminals
|
||||
.get(&id)
|
||||
.map(|panel| (panel.read(cx).tab_group(), panel.entity_id())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((group, panel)) = target {
|
||||
self.activate_tab(group, panel, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_tab_title(&self, tab: CenterTab, cx: &App) -> Option<String> {
|
||||
match tab {
|
||||
CenterTab::Terminal(id) => Some(self.terminals.get(&id)?.read(cx).tab_title(cx).into()),
|
||||
CenterTab::LocalTerminal(id) => Some(
|
||||
self.local_terminals
|
||||
.get(&id)?
|
||||
.read(cx)
|
||||
.default_title()
|
||||
.to_string(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-window notifications of terminals, one a terminal at a time.
|
||||
struct TerminalNoticeTag;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BELL_INTERVAL, CenterTab, Delivery, Duration, Instant, LocalTerminalId,
|
||||
NotificationSettings, RemoteTerminalId, TerminalNotice, delivery, due, notice_tag,
|
||||
notice_text, tab_of_tag,
|
||||
};
|
||||
|
||||
fn program(title: Option<&str>) -> TerminalNotice {
|
||||
TerminalNotice::Program {
|
||||
title: title.map(str::to_string),
|
||||
body: "完成".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bell() -> TerminalNotice {
|
||||
TerminalNotice::Bell {
|
||||
line: "y/N?".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_tells_of_a_window_away_and_the_window_of_another_tab() {
|
||||
let on = NotificationSettings::default();
|
||||
for notice in [program(None), bell()] {
|
||||
assert_eq!(delivery(¬ice, on, false, true), Delivery::System);
|
||||
assert_eq!(delivery(¬ice, on, true, false), Delivery::InApp);
|
||||
}
|
||||
// In sight, a program still notifies; the bell does not.
|
||||
assert_eq!(delivery(&program(None), on, true, true), Delivery::InApp);
|
||||
assert_eq!(delivery(&bell(), on, true, true), Delivery::None);
|
||||
let off = NotificationSettings {
|
||||
programs: false,
|
||||
bell: false,
|
||||
};
|
||||
assert_eq!(delivery(&program(None), off, false, false), Delivery::None);
|
||||
assert_eq!(delivery(&bell(), off, false, false), Delivery::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notice_is_titled_with_its_tab() {
|
||||
assert_eq!(
|
||||
notice_text("web-01", &program(Some("构建"))),
|
||||
("web-01:构建".to_string(), "完成".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
notice_text("web-01", &program(None)),
|
||||
("web-01".to_string(), "完成".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
notice_text("本地终端 1", &bell()),
|
||||
("本地终端 1:响铃".to_string(), "y/N?".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notices_are_spaced_out() {
|
||||
let start = Instant::now();
|
||||
assert!(due(None, start, BELL_INTERVAL));
|
||||
assert!(!due(
|
||||
Some(start),
|
||||
start + Duration::from_secs(9),
|
||||
BELL_INTERVAL
|
||||
));
|
||||
assert!(due(
|
||||
Some(start),
|
||||
start + Duration::from_secs(10),
|
||||
BELL_INTERVAL
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_names_its_terminal() {
|
||||
for tab in [
|
||||
CenterTab::Terminal(RemoteTerminalId(3)),
|
||||
CenterTab::LocalTerminal(LocalTerminalId(12)),
|
||||
] {
|
||||
assert_eq!(tab_of_tag(¬ice_tag(tab).unwrap()), Some(tab));
|
||||
}
|
||||
assert_eq!(notice_tag(CenterTab::Settings), None);
|
||||
assert_eq!(tab_of_tag("terminal:local:x"), None);
|
||||
assert_eq!(tab_of_tag("other:local:1"), None);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
-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, ToggleHiddenFiles,
|
||||
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,13 @@ 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,
|
||||
/// When each terminal last notified, of each kind, keyed by
|
||||
/// `notices::notice_tag` and the kind.
|
||||
pub(super) notice_times: HashMap<String, std::time::Instant>,
|
||||
pub(super) _subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
@@ -433,6 +442,27 @@ impl Workspace {
|
||||
.filter(|host| host.state.is_connected())
|
||||
.map(|host| host.id)
|
||||
.collect();
|
||||
// A click on a terminal's system notification shows that terminal.
|
||||
let this = cx.weak_entity();
|
||||
let handle = window.window_handle();
|
||||
cx.on_system_notification_response(move |response, cx| {
|
||||
_ = handle.update(cx, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.on_notice_clicked(&response.tag, window, cx)
|
||||
});
|
||||
});
|
||||
});
|
||||
// ⌘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,10 +545,18 @@ impl Workspace {
|
||||
prompt_queue: VecDeque::new(),
|
||||
active_prompt: None,
|
||||
focus_handle,
|
||||
editors: HashMap::new(),
|
||||
next_editor_id: 1,
|
||||
notice_times: HashMap::new(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The center tab in front.
|
||||
pub fn active_tab(&self) -> Option<CenterTab> {
|
||||
self.active_tab
|
||||
}
|
||||
|
||||
/// The host store, for tests and for panels created later.
|
||||
pub fn store(&self) -> &Entity<HostStore> {
|
||||
&self.store
|
||||
@@ -627,6 +665,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 +1291,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 +1334,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 +1428,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 +1486,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 +1499,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 +1522,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 +1555,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 +1582,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 +1661,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1959,6 +2067,20 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
/// Saved like any setting; applying it lists that side of every SFTP
|
||||
/// tab again.
|
||||
fn on_toggle_hidden_files(
|
||||
&mut self,
|
||||
action: &ToggleHiddenFiles,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let side = action.0;
|
||||
self.settings.update(cx, |settings, cx| {
|
||||
settings.update(|settings| settings.show_hidden.toggle(side), cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn on_focus_search(&mut self, _: &FocusSearch, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.dock_area.read(cx).is_dock_open(DockPlacement::Left) {
|
||||
self.dock_area.update(cx, |area, cx| {
|
||||
@@ -2048,6 +2170,9 @@ fn new_terminal_panel(
|
||||
this.store
|
||||
.update(cx, |store, cx| store.set_host_os(host_id, Some(os), cx));
|
||||
}
|
||||
TerminalPanelEvent::Notice(terminal_id, notice) => {
|
||||
this.on_terminal_notice(CenterTab::Terminal(*terminal_id), notice, window, cx)
|
||||
}
|
||||
},
|
||||
);
|
||||
(panel, subscription)
|
||||
@@ -2134,6 +2259,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 +2293,9 @@ fn new_explorer_panel(
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
ExplorerPanelEvent::EditFile(id, location, file) => {
|
||||
this.open_editor(*id, location.clone(), file.clone(), window, cx)
|
||||
}
|
||||
},
|
||||
);
|
||||
(panel, subscription)
|
||||
@@ -2193,6 +2326,9 @@ fn new_local_terminal_panel(
|
||||
cx.notify();
|
||||
}
|
||||
LocalTerminalPanelEvent::StatusChanged(_) => cx.notify(),
|
||||
LocalTerminalPanelEvent::Notice(id, notice) => {
|
||||
this.on_terminal_notice(CenterTab::LocalTerminal(*id), notice, window, cx)
|
||||
}
|
||||
},
|
||||
);
|
||||
(panel, subscription)
|
||||
@@ -2224,6 +2360,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,12 +2421,16 @@ 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))
|
||||
.on_action(cx.listener(Self::on_remove_cli_command))
|
||||
.on_action(cx.listener(Self::on_install_agent_skill))
|
||||
.on_action(cx.listener(Self::on_set_file_size_format))
|
||||
.on_action(cx.listener(Self::on_toggle_hidden_files))
|
||||
.on_action(cx.listener(Self::on_remove_agent_skill))
|
||||
.on_action(cx.listener(Self::on_refresh_cli_integration))
|
||||
.on_action(cx.listener(Self::on_check_for_updates))
|
||||
|
||||
@@ -133,6 +133,21 @@ fn the_command_finds_the_app_through_its_data_directory() {
|
||||
"cd /srv\nls\n"
|
||||
);
|
||||
|
||||
// JSON in, JSON out, in ASCII alone.
|
||||
let request = serde_json::json!({ "host": id, "command": "ls" }).to_string();
|
||||
let output = shellrs(data_dir.path(), &["exec", "--json"], request.as_bytes());
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert!(output.stdout.is_ascii());
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap(),
|
||||
serde_json::json!({ "exit_code": 7, "stdout": "输出\n", "stderr": "warning\n" })
|
||||
);
|
||||
assert!(output.stderr.is_empty());
|
||||
let output = shellrs(data_dir.path(), &["exec", "--json"], b"ls");
|
||||
assert_eq!(output.status.code(), Some(255));
|
||||
let error: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(error["error"]["code"], "bad_request");
|
||||
|
||||
// Everything arrives, though the app is done long before.
|
||||
let output = shellrs(data_dir.path(), &["exec", &id, "large"], b"");
|
||||
assert_eq!(output.status.code(), Some(0));
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
//! The built-in editor and previews: opening a file from an SFTP tab,
|
||||
//! saving it back in place, what is asked before changes would be lost, and
|
||||
//! images and Markdown shown in a preview.
|
||||
|
||||
use crate::support::*;
|
||||
use shellrs::app::{
|
||||
CloseActiveTab, CloseEditor, CloseExplorer, ExplorerAction, ExplorerCommand, Quit,
|
||||
};
|
||||
use shellrs::editor::EditorId;
|
||||
use shellrs::explorer::{IMAGE_LIMIT, 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());
|
||||
}
|
||||
|
||||
/// A 1×1 PNG.
|
||||
const PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0d\x49\x44\x41\x54\x78\x9c\x63\xf8\xcf\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99\x3d\x1d\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82";
|
||||
|
||||
/// An SFTP tab whose remote side lists `/pictures`, where these files are.
|
||||
async fn open_pictures(
|
||||
cx: &mut TestAppContext,
|
||||
files: &[(&str, &[u8])],
|
||||
) -> (WindowHandle<Root>, Entity<Workspace>, Arc<FakeSftpProvider>) {
|
||||
let (handle, workspace, provider) = open_server(cx, files).await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ExplorerAction::new(
|
||||
ExplorerId(SFTP_TAB),
|
||||
ExplorerCommand::Navigate {
|
||||
remote: true,
|
||||
path: "/pictures".into(),
|
||||
},
|
||||
)),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.find("remote-path").value() == Some("/pictures")
|
||||
})
|
||||
.await;
|
||||
(handle, workspace, provider)
|
||||
}
|
||||
|
||||
fn preview(cx: &mut TestAppContext, handle: WindowHandle<Root>, remote: bool, path: &str) {
|
||||
let path = path.to_string();
|
||||
in_frame(cx, handle, move |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ExplorerAction::new(
|
||||
ExplorerId(SFTP_TAB),
|
||||
ExplorerCommand::Preview {
|
||||
remote,
|
||||
path: Some(path),
|
||||
},
|
||||
)),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn double_clicking_an_image_previews_it(cx: &mut TestAppContext) {
|
||||
let (handle, workspace, provider) = open_pictures(cx, &[("/pictures/图.png", PNG)]).await;
|
||||
open_file(cx, handle, "remote-pane", "图.png");
|
||||
wait_for(cx, handle, "file-preview").await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(window.find("file-preview").label(), Some("图.png"));
|
||||
assert!(window.try_find("preview-image").is_some());
|
||||
assert!(
|
||||
window
|
||||
.find("preview-info")
|
||||
.label()
|
||||
.is_some_and(|info| info.ends_with("70 B"))
|
||||
);
|
||||
// A remote image can be downloaded; it cannot be edited.
|
||||
assert!(window.try_find("preview-download").is_some());
|
||||
assert!(window.try_find("preview-edit").is_none());
|
||||
});
|
||||
assert!(!is_open(&workspace, FIRST, cx));
|
||||
assert_eq!(*provider.reads.lock().unwrap(), vec!["/pictures/图.png"]);
|
||||
|
||||
in_frame(cx, handle, |window, cx| window.press("escape", cx));
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("file-preview").is_none())
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn markdown_is_edited_on_a_double_click_and_previewed_from_the_menu(cx: &mut TestAppContext) {
|
||||
let (handle, workspace, _) = open_pictures(
|
||||
cx,
|
||||
&[("/pictures/README.md", "# 标题\n\n正文\n".as_bytes())],
|
||||
)
|
||||
.await;
|
||||
open_file(cx, handle, "remote-pane", "README.md");
|
||||
wait_for_editor(cx, handle, FIRST).await;
|
||||
|
||||
preview(cx, handle, true, "/pictures/README.md");
|
||||
wait_for(cx, handle, "preview-markdown").await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(
|
||||
window
|
||||
.find("preview-info")
|
||||
.label()
|
||||
.is_some_and(|info| info.starts_with("Markdown"))
|
||||
);
|
||||
});
|
||||
// 编辑 goes to the editor already open, not a second one.
|
||||
in_frame(cx, handle, |window, cx| window.click("preview-edit", cx));
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("file-preview").is_none());
|
||||
assert!(window.try_find(("editor", FIRST.0)).is_some());
|
||||
});
|
||||
assert!(!is_open(&workspace, EditorId(2), cx));
|
||||
|
||||
// 下载… asks where to, as a download does.
|
||||
preview(cx, handle, true, "/pictures/README.md");
|
||||
wait_for(cx, handle, "preview-download").await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("preview-download", cx)
|
||||
});
|
||||
wait_for(cx, handle, "download-confirm").await;
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn an_image_too_large_to_preview_offers_the_download(cx: &mut TestAppContext) {
|
||||
let large = vec![0; IMAGE_LIMIT as usize + 1];
|
||||
let (handle, _, _) = open_pictures(cx, &[("/pictures/图.png", &large)]).await;
|
||||
open_file(cx, handle, "remote-pane", "图.png");
|
||||
wait_for(cx, handle, "ok").await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("file-preview").is_none())
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.click("ok", cx));
|
||||
wait_for(cx, handle, "download-confirm").await;
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_local_image_is_previewed_without_a_download(cx: &mut TestAppContext) {
|
||||
let local = FakeLocalDirectory::default();
|
||||
local
|
||||
.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/local/tester/图.png".into(), PNG.to_vec());
|
||||
let (handle, _) =
|
||||
open_workspace_with_services(cx, Arc::new(FakeSftpProvider::default()), local);
|
||||
open_test_explorer(cx, handle).await;
|
||||
preview(cx, handle, false, "/local/tester/图.png");
|
||||
wait_for(cx, handle, "file-preview").await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("preview-image").is_some());
|
||||
assert!(window.try_find("preview-download").is_none());
|
||||
});
|
||||
}
|
||||
|
||||
/// A 4×2000 PNG: a long screenshot, in small, taller than any frame.
|
||||
const LONG_PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x04\x00\x00\x07\xd0\x08\x06\x00\x00\x00\xe5\xf9\xfe\xc9\x00\x00\x00\x6e\x49\x44\x41\x54\x78\xda\xed\xc8\xb1\x0d\x00\x00\x08\xc0\xa0\xfe\xff\xb4\xee\x7e\x60\xc2\xc0\x42\xd5\x1c\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x88\x9f\xb1\xc4\x5f\x22\x92\xa1\xbb\x97\x39\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82";
|
||||
|
||||
/// The preview's zoom, once the image is decoded and the frame measured.
|
||||
async fn wait_for_zoom(cx: &mut TestAppContext, handle: WindowHandle<Root>, zoom: &'static str) {
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), move |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window
|
||||
.try_find("preview-zoom")
|
||||
.is_some_and(|label| label.label() == Some(zoom))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
fn bounds_of(
|
||||
cx: &mut TestAppContext,
|
||||
handle: WindowHandle<Root>,
|
||||
id: &'static str,
|
||||
) -> gpui_kit::Bounds<gpui_kit::Pixels> {
|
||||
in_frame(cx, handle, |window, _| window.find(id).bounds())
|
||||
}
|
||||
|
||||
fn scroll_preview(cx: &mut TestAppContext, handle: WindowHandle<Root>, by: f32, zoom: bool) {
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let position = window.find("preview-image").bounds().center();
|
||||
window.dispatch_event(
|
||||
gpui_kit::ScrollWheelEvent {
|
||||
position,
|
||||
delta: gpui_kit::ScrollDelta::Pixels(point(px(0.), px(by))),
|
||||
modifiers: if zoom {
|
||||
gpui_kit::Modifiers::secondary_key()
|
||||
} else {
|
||||
gpui_kit::Modifiers::default()
|
||||
},
|
||||
touch_phase: gpui_kit::TouchPhase::Moved,
|
||||
}
|
||||
.to_platform_input(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_previewed_image_fits_its_frame_in_the_middle(cx: &mut TestAppContext) {
|
||||
let (handle, _, _) = open_pictures(
|
||||
cx,
|
||||
&[("/pictures/长图.png", LONG_PNG), ("/pictures/点.png", PNG)],
|
||||
)
|
||||
.await;
|
||||
// A long image: scaled down to the frame's height, in its middle.
|
||||
open_file(cx, handle, "remote-pane", "长图.png");
|
||||
wait_for_zoom(cx, handle, "28%").await;
|
||||
let frame = bounds_of(cx, handle, "preview-image");
|
||||
let picture = bounds_of(cx, handle, "preview-picture");
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(
|
||||
window
|
||||
.find("preview-info")
|
||||
.label()
|
||||
.is_some_and(|info| info.starts_with("4 × 2000 像素"))
|
||||
);
|
||||
});
|
||||
assert!(
|
||||
(picture.size.height - frame.size.height).abs() < px(1.),
|
||||
"{picture:?} in {frame:?}"
|
||||
);
|
||||
assert!(
|
||||
(picture.center().x - frame.center().x).abs() < px(1.),
|
||||
"{picture:?} in {frame:?}"
|
||||
);
|
||||
in_frame(cx, handle, |window, cx| window.press("escape", cx));
|
||||
|
||||
// A small one keeps its size, in the middle both ways.
|
||||
open_file(cx, handle, "remote-pane", "点.png");
|
||||
wait_for_zoom(cx, handle, "100%").await;
|
||||
let frame = bounds_of(cx, handle, "preview-image");
|
||||
let picture = bounds_of(cx, handle, "preview-picture");
|
||||
assert_eq!(picture.size, size(px(1.), px(1.)));
|
||||
assert!((picture.center().x - frame.center().x).abs() < px(1.));
|
||||
assert!((picture.center().y - frame.center().y).abs() < px(1.));
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_previewed_image_zooms_and_scrolls(cx: &mut TestAppContext) {
|
||||
let (handle, _, _) = open_pictures(cx, &[("/pictures/长图.png", LONG_PNG)]).await;
|
||||
open_file(cx, handle, "remote-pane", "长图.png");
|
||||
wait_for_zoom(cx, handle, "28%").await;
|
||||
|
||||
// 原图: its own size, larger than the frame, from its middle.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("preview-actual-size", cx)
|
||||
});
|
||||
wait_for_zoom(cx, handle, "100%").await;
|
||||
let frame = bounds_of(cx, handle, "preview-image");
|
||||
let picture = bounds_of(cx, handle, "preview-picture");
|
||||
assert_eq!(picture.size.height, px(2000.));
|
||||
assert!((picture.center().y - frame.center().y).abs() < px(1.));
|
||||
|
||||
// It scrolls.
|
||||
scroll_preview(cx, handle, -300., false);
|
||||
let scrolled = bounds_of(cx, handle, "preview-picture");
|
||||
assert_eq!(scrolled.top(), picture.top() - px(300.));
|
||||
|
||||
// The buttons and the keys step the zoom.
|
||||
in_frame(cx, handle, |window, cx| window.click("preview-zoom-in", cx));
|
||||
wait_for_zoom(cx, handle, "125%").await;
|
||||
in_frame(cx, handle, |window, cx| window.press("cmd--", cx));
|
||||
wait_for_zoom(cx, handle, "100%").await;
|
||||
in_frame(cx, handle, |window, cx| window.press("cmd-9", cx));
|
||||
wait_for_zoom(cx, handle, "28%").await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("preview-zoom-out", cx)
|
||||
});
|
||||
wait_for_zoom(cx, handle, "25%").await;
|
||||
in_frame(cx, handle, |window, cx| window.press("cmd-0", cx));
|
||||
wait_for_zoom(cx, handle, "100%").await;
|
||||
in_frame(cx, handle, |window, cx| window.click("preview-fit", cx));
|
||||
wait_for_zoom(cx, handle, "28%").await;
|
||||
|
||||
// A double click goes to 100% and back.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.double_click("preview-picture", cx)
|
||||
});
|
||||
wait_for_zoom(cx, handle, "100%").await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.double_click("preview-picture", cx)
|
||||
});
|
||||
wait_for_zoom(cx, handle, "28%").await;
|
||||
|
||||
// ⌘ with the wheel zooms instead of scrolling.
|
||||
scroll_preview(cx, handle, 100., true);
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(window.find("preview-zoom").label(), Some("42%"));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn an_svg_is_edited_on_a_double_click_and_previewed_from_the_menu(cx: &mut TestAppContext) {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="20"><rect width="10" height="20" fill="red"/></svg>"#;
|
||||
let (handle, workspace, _) = open_pictures(cx, &[("/pictures/logo.svg", svg.as_bytes())]).await;
|
||||
// An SVG is text: a double click edits it.
|
||||
open_file(cx, handle, "remote-pane", "logo.svg");
|
||||
wait_for_editor(cx, handle, FIRST).await;
|
||||
assert_eq!(text_of(&workspace, FIRST, cx), svg);
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("file-preview").is_none())
|
||||
});
|
||||
|
||||
preview(cx, handle, true, "/pictures/logo.svg");
|
||||
wait_for(cx, handle, "file-preview").await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("preview-image").is_some());
|
||||
// The editor is open already; the preview offers no second way in.
|
||||
assert!(window.try_find("preview-edit").is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn an_image_that_cannot_be_decoded_says_so(cx: &mut TestAppContext) {
|
||||
let (handle, _, _) = open_pictures(cx, &[("/pictures/坏.png", b"not a png at all")]).await;
|
||||
open_file(cx, handle, "remote-pane", "坏.png");
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window
|
||||
.try_find("preview-note")
|
||||
.is_some_and(|note| note.label() == Some("无法显示这张图片"))
|
||||
})
|
||||
.await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("preview-picture").is_none())
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod support;
|
||||
mod connection;
|
||||
mod credential;
|
||||
mod docker;
|
||||
mod editor;
|
||||
mod forward;
|
||||
mod history;
|
||||
mod host_dialog;
|
||||
|
||||
@@ -285,6 +285,144 @@ async fn sftp_size_column_shows_kilobytes_until_another_format_is_chosen(cx: &mu
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示隐藏文件 is a switch for each side on its own, saved like the size
|
||||
/// format and off at first: a side's toolbar button and the key turn it on
|
||||
/// and off, its icon and tooltip follow, and a hidden file leaves the
|
||||
/// selection.
|
||||
#[gpui_kit::test]
|
||||
async fn sftp_hidden_files_show_and_hide_from_the_toolbar_and_key(cx: &mut TestAppContext) {
|
||||
use shellrs::app::{ExplorerAction, ExplorerCommand};
|
||||
use shellrs::explorer::ShowHiddenFiles;
|
||||
let provider = Arc::new(FakeSftpProvider::default());
|
||||
let (handle, workspace) = open_workspace_with_sftp(cx, provider);
|
||||
open_test_explorer(cx, handle).await;
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ExplorerAction::new(
|
||||
ExplorerId(SFTP_TAB),
|
||||
ExplorerCommand::Navigate {
|
||||
remote: true,
|
||||
path: "/dotfiles".into(),
|
||||
},
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window
|
||||
.within(("remote-pane", SFTP_TAB))
|
||||
.try_find("file:notes.txt")
|
||||
.is_some()
|
||||
})
|
||||
.await;
|
||||
let saved = |cx: &mut TestAppContext| {
|
||||
cx.update(|cx| {
|
||||
workspace
|
||||
.read(cx)
|
||||
.settings()
|
||||
.read(cx)
|
||||
.settings()
|
||||
.show_hidden
|
||||
})
|
||||
};
|
||||
let shown = |local, remote| ShowHiddenFiles { local, remote };
|
||||
// The button's label is what a click does.
|
||||
let button = |window: &mut gpui_kit::Window, pane: &'static str| {
|
||||
window
|
||||
.within((pane, SFTP_TAB))
|
||||
.find("hidden-files")
|
||||
.label()
|
||||
.map(str::to_string)
|
||||
};
|
||||
assert_eq!(saved(cx), shown(false, false), "left out until turned on");
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
let remote = window.within(("remote-pane", SFTP_TAB));
|
||||
assert!(remote.try_find("file:.bashrc").is_none());
|
||||
assert!(remote.try_find("file:.ssh").is_none());
|
||||
assert_eq!(
|
||||
remote.find("pane-status").label(),
|
||||
Some("1 个项目 · 隐藏 2 项")
|
||||
);
|
||||
assert_eq!(
|
||||
button(window, "remote-pane").as_deref(),
|
||||
Some("显示隐藏文件")
|
||||
);
|
||||
assert_eq!(
|
||||
button(window, "local-pane").as_deref(),
|
||||
Some("显示隐藏文件")
|
||||
);
|
||||
window
|
||||
.within(("remote-pane", SFTP_TAB))
|
||||
.click("hidden-files", cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
// The other side keeps its own.
|
||||
assert_eq!(saved(cx), shown(false, true));
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
let remote = window.within(("remote-pane", SFTP_TAB));
|
||||
assert!(remote.try_find("file:.bashrc").is_some());
|
||||
assert!(remote.try_find("file:.ssh").is_some());
|
||||
assert_eq!(remote.find("pane-status").label(), Some("3 个项目"));
|
||||
assert_eq!(
|
||||
button(window, "remote-pane").as_deref(),
|
||||
Some("不显示隐藏文件")
|
||||
);
|
||||
assert_eq!(
|
||||
button(window, "local-pane").as_deref(),
|
||||
Some("显示隐藏文件")
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// The key, from a list, switches that list's side.
|
||||
#[cfg(target_os = "macos")]
|
||||
let key = "cmd->";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let key = "ctrl-alt-h";
|
||||
press_on_row(cx, handle, "local-pane", "目录", key);
|
||||
assert_eq!(saved(cx), shown(true, true));
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert_eq!(
|
||||
button(window, "local-pane").as_deref(),
|
||||
Some("不显示隐藏文件")
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
// Leaving them out again takes the selected one with them.
|
||||
press_on_row(cx, handle, "remote-pane", ".bashrc", key);
|
||||
assert_eq!(saved(cx), shown(true, false));
|
||||
cx.update(|cx| assert!(pane_selection(&workspace, true, cx).is_empty()));
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
let remote = window.within(("remote-pane", SFTP_TAB));
|
||||
assert!(remote.try_find("file:.bashrc").is_none());
|
||||
assert_eq!(
|
||||
remote.find("pane-status").label(),
|
||||
Some("1 个项目 · 隐藏 2 项")
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
// The pane still knows them: a new name is checked against them.
|
||||
cx.update(|cx| {
|
||||
let explorer = workspace.read(cx).explorer(ExplorerId(SFTP_TAB)).unwrap();
|
||||
let names: Vec<_> = explorer
|
||||
.read(cx)
|
||||
.remote()
|
||||
.read(cx)
|
||||
.listed_entries(cx)
|
||||
.iter()
|
||||
.map(|entry| entry.name.to_string())
|
||||
.collect();
|
||||
assert!(names.contains(&".bashrc".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
/// Click a toolbar button of the remote pane and wait for the path it lands on.
|
||||
async fn click_remote_tool(
|
||||
cx: &mut TestAppContext,
|
||||
@@ -573,6 +711,34 @@ async fn sftp_path_label_opens_ancestors_and_the_open_directory_dialog(cx: &mut
|
||||
let explorer = workspace.read(cx).explorer(ExplorerId(SFTP_TAB)).unwrap();
|
||||
assert!(explorer.read(cx).local().read(cx).is_current());
|
||||
});
|
||||
|
||||
// 复制路径 from a row's menu: the selected rows' full paths, a line each.
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
window
|
||||
.within(("local-pane", SFTP_TAB))
|
||||
.click("name:文件 甲.txt", cx);
|
||||
modified_click(
|
||||
window,
|
||||
("local-pane", SFTP_TAB),
|
||||
"name:目录",
|
||||
gpui_kit::Modifiers::secondary_key(),
|
||||
cx,
|
||||
);
|
||||
window.dispatch_action(
|
||||
Box::new(ExplorerAction::new(
|
||||
ExplorerId(SFTP_TAB),
|
||||
ExplorerCommand::CopySelectedPaths { remote: false },
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.read_from_clipboard().and_then(|item| item.text()),
|
||||
Some("/local/tester/目录\n/local/tester/文件 甲.txt".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
|
||||
@@ -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, FileBytes, 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,
|
||||
}
|
||||
|
||||
@@ -71,6 +119,9 @@ impl SftpTransport for FakeSftpTransport {
|
||||
let result = match path.as_str() {
|
||||
"/denied" => Err("权限不足".into()),
|
||||
"/empty" => Ok(DirectoryListing::new("/empty", vec![])),
|
||||
"/dotfiles" => Ok(dotfiles_listing()),
|
||||
// The files put there, as `with_files` gave them.
|
||||
"/pictures" => Ok(files_listing(&self.files, "/pictures")),
|
||||
_ => Ok(fake_listing(path.as_str())),
|
||||
};
|
||||
events.send_blocking(SftpEvent::Listed { request_id, result })?;
|
||||
@@ -80,6 +131,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 +147,64 @@ 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::ReadBytes {
|
||||
request_id,
|
||||
path,
|
||||
limit,
|
||||
} => {
|
||||
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 > limit => {
|
||||
Err(ReadFailure::TooLarge(bytes.len() as u64))
|
||||
}
|
||||
Some((bytes, _)) => Ok(FileBytes::new(bytes.clone())),
|
||||
None => Err(ReadFailure::Failed("文件不存在".into())),
|
||||
};
|
||||
events.send_blocking(SftpEvent::BytesRead { 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 +269,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 +297,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],
|
||||
@@ -197,6 +351,50 @@ impl LocalDirectoryProvider for FakeLocalDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory of the fake server's files, as they are now.
|
||||
pub fn files_listing(files: &FakeFiles, path: &str) -> DirectoryListing {
|
||||
let prefix = format!("{path}/");
|
||||
let entries = files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|(file, (bytes, modified))| {
|
||||
let name = file.strip_prefix(&prefix)?;
|
||||
Some(DirectoryEntry::new(
|
||||
name,
|
||||
FileMetadata::new(
|
||||
EntryKind::File,
|
||||
bytes.len() as u64,
|
||||
Some(*modified),
|
||||
Some(0o644),
|
||||
),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
DirectoryListing::new(path, entries)
|
||||
}
|
||||
|
||||
/// A home of mostly hidden files: `.bashrc`, `.ssh` and one `notes.txt`.
|
||||
fn dotfiles_listing() -> DirectoryListing {
|
||||
DirectoryListing::new(
|
||||
"/dotfiles",
|
||||
vec![
|
||||
DirectoryEntry::new(
|
||||
".bashrc",
|
||||
FileMetadata::new(EntryKind::File, 10, Some(100), Some(0o644)),
|
||||
),
|
||||
DirectoryEntry::new(
|
||||
".ssh",
|
||||
FileMetadata::new(EntryKind::Directory, 0, None, Some(0o700)),
|
||||
),
|
||||
DirectoryEntry::new(
|
||||
"notes.txt",
|
||||
FileMetadata::new(EntryKind::File, 20, Some(200), Some(0o644)),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fake_listing(path: &str) -> DirectoryListing {
|
||||
DirectoryListing::new(
|
||||
path,
|
||||
|
||||
@@ -10,6 +10,8 @@ pub enum FakeBehavior {
|
||||
FailFirst,
|
||||
ReportsOs(HostOs),
|
||||
ReportsLatency(Latency),
|
||||
/// Prints this after its prompt line, as a program would.
|
||||
Prints(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -68,6 +70,13 @@ impl FakeTerminalFactory {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn printing(text: &'static str) -> Self {
|
||||
Self {
|
||||
behavior: FakeBehavior::Prints(text),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reports_latency(latency: Latency) -> Self {
|
||||
Self {
|
||||
behavior: FakeBehavior::ReportsLatency(latency),
|
||||
@@ -139,6 +148,9 @@ impl TerminalTransport for FakeTerminalTransport {
|
||||
)
|
||||
.into_bytes(),
|
||||
))?;
|
||||
if let FakeBehavior::Prints(text) = self.behavior {
|
||||
events.send_blocking(TerminalTransportEvent::Output(text.as_bytes().to_vec()))?;
|
||||
}
|
||||
if matches!(self.behavior, FakeBehavior::ExitFirst) && self.run == 1 {
|
||||
events.send_blocking(TerminalTransportEvent::Exited {
|
||||
code: 9,
|
||||
|
||||
+242
-1
@@ -1,5 +1,5 @@
|
||||
//! Terminals: local terminals, keys and input, selection, find, clearing,
|
||||
//! the font and the size.
|
||||
//! the font and the size, links, the mouse for programs and their copies.
|
||||
|
||||
use crate::support::*;
|
||||
|
||||
@@ -759,3 +759,244 @@ async fn the_status_bar_shows_the_size_of_the_terminal_in_front(cx: &mut TestApp
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Where the cell `(column, row)` of the first local terminal is, by the cell
|
||||
/// size the fake shell was told of.
|
||||
fn cell_center(
|
||||
window: &mut gpui_kit::Window,
|
||||
factory: &FakeTerminalFactory,
|
||||
column: usize,
|
||||
row: usize,
|
||||
) -> gpui_kit::Point<gpui_kit::Pixels> {
|
||||
let size = *factory
|
||||
.resizes
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.last()
|
||||
.expect("the terminal has been laid out");
|
||||
let bounds = window.find(("local-terminal", 1_u64)).bounds();
|
||||
point(
|
||||
bounds.left() + px(size.cell_width() as f32 * (column as f32 + 0.5)),
|
||||
bounds.top() + px(size.cell_height() as f32 * (row as f32 + 0.5)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A local terminal whose shell has printed `text`, which ends in `marker`,
|
||||
/// laid out.
|
||||
async fn terminal_printing(
|
||||
cx: &mut TestAppContext,
|
||||
text: &'static str,
|
||||
marker: &'static str,
|
||||
) -> (
|
||||
WindowHandle<Root>,
|
||||
Entity<Workspace>,
|
||||
Arc<FakeTerminalFactory>,
|
||||
) {
|
||||
let factory = Arc::new(FakeTerminalFactory::printing(text));
|
||||
let (handle, workspace) = open_running_local_terminal(cx, factory.clone()).await;
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
local_screen(&workspace, cx).contains(marker) && last_cell_height(&factory).is_some()
|
||||
})
|
||||
.await;
|
||||
(handle, workspace, factory)
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn links_open_with_the_command_key_and_show_where_they_lead(cx: &mut TestAppContext) {
|
||||
// The prompt line is row 0; the link starts row 1.
|
||||
let (handle, _, factory) =
|
||||
terminal_printing(cx, "https://example.com/docs. 文档\r\n", "文档").await;
|
||||
|
||||
// A plain click selects, as it always has, and opens nothing.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let link = cell_center(window, &factory, 2, 1);
|
||||
click_at(window, link, gpui_kit::Modifiers::none(), cx);
|
||||
});
|
||||
assert_eq!(cx.opened_url(), None);
|
||||
|
||||
// Resting on it shows where it leads, without the full stop.
|
||||
cx.background_executor
|
||||
.advance_clock(Duration::from_millis(600));
|
||||
cx.run_until_parked();
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
assert_eq!(
|
||||
window.find("terminal-link-tooltip").label(),
|
||||
Some("https://example.com/docs")
|
||||
);
|
||||
let link = cell_center(window, &factory, 2, 1);
|
||||
click_at(window, link, gpui_kit::Modifiers::secondary_key(), cx);
|
||||
});
|
||||
assert_eq!(cx.opened_url().as_deref(), Some("https://example.com/docs"));
|
||||
}
|
||||
|
||||
/// Wait until the fake shell has been sent `expected`, which it records on
|
||||
/// its own thread, and forget it.
|
||||
async fn take_written(
|
||||
cx: &mut TestAppContext,
|
||||
handle: WindowHandle<Root>,
|
||||
factory: &FakeTerminalFactory,
|
||||
expected: &str,
|
||||
) {
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| {
|
||||
factory.written_text() == expected
|
||||
})
|
||||
.await;
|
||||
factory
|
||||
.writes
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.clear();
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn programs_that_ask_for_the_mouse_hear_clicks_and_the_wheel(cx: &mut TestAppContext) {
|
||||
// 1000: clicks, 1006: SGR encoding.
|
||||
let (handle, _, factory) =
|
||||
terminal_printing(cx, "\x1b[?1000h\x1b[?1006hmouse on", "mouse on").await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let cell = cell_center(window, &factory, 2, 0);
|
||||
click_at(window, cell, gpui_kit::Modifiers::none(), cx);
|
||||
});
|
||||
take_written(cx, handle, &factory, "\x1b[<0;3;1M\x1b[<0;3;1m").await;
|
||||
|
||||
// Shift keeps the mouse for selecting text: the program hears only the
|
||||
// wheel after it.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let cell = cell_center(window, &factory, 2, 0);
|
||||
click_at(window, cell, gpui_kit::Modifiers::shift(), cx);
|
||||
let position = cell_center(window, &factory, 4, 1);
|
||||
window.dispatch_event(
|
||||
gpui_kit::ScrollWheelEvent {
|
||||
position,
|
||||
delta: gpui_kit::ScrollDelta::Lines(point(0., 1.)),
|
||||
modifiers: gpui_kit::Modifiers::default(),
|
||||
touch_phase: gpui_kit::TouchPhase::Moved,
|
||||
}
|
||||
.to_platform_input(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
take_written(cx, handle, &factory, "\x1b[<64;5;2M").await;
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) {
|
||||
// 「你好」 in base64.
|
||||
let (handle, _, _) = terminal_printing(cx, "\x1b]52;c;5L2g5aW9\x07copied", "copied").await;
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
|
||||
cx.read_from_clipboard().and_then(|item| item.text()) == Some("你好".into())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Have local terminal `id` print `text`: the fake shell prints back what it
|
||||
/// is sent, and a paste outside bracketed paste mode sends it as it is.
|
||||
fn print_into(cx: &mut TestAppContext, workspace: &Entity<Workspace>, id: u64, text: &str) {
|
||||
cx.update(|cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
|
||||
let terminal = workspace
|
||||
.read(cx)
|
||||
.local_terminal(LocalTerminalId(id))
|
||||
.expect("the terminal is open")
|
||||
.read(cx)
|
||||
.terminal()
|
||||
.clone();
|
||||
terminal.update(cx, |terminal, cx| terminal.paste_clipboard(cx));
|
||||
});
|
||||
}
|
||||
|
||||
/// The system notifications shown once there are `count` of them.
|
||||
async fn system_notifications(
|
||||
cx: &mut TestAppContext,
|
||||
count: usize,
|
||||
) -> Vec<gpui_kit::SystemNotification> {
|
||||
for _ in 0..200 {
|
||||
let shown = cx.shown_system_notifications();
|
||||
if shown.len() >= count {
|
||||
return shown;
|
||||
}
|
||||
cx.executor().timer(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!(
|
||||
"expected {count} system notifications, got {:?}",
|
||||
cx.shown_system_notifications()
|
||||
);
|
||||
}
|
||||
|
||||
fn in_window(cx: &mut TestAppContext, handle: WindowHandle<Root>) -> usize {
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.notifications(cx).len()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn terminals_notify_where_the_user_will_see_it(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::default());
|
||||
let (handle, workspace) = open_running_local_terminal(cx, factory).await;
|
||||
// Test windows start out not active.
|
||||
in_frame(cx, handle, |window, _| window.activate_window());
|
||||
assert!(cx.shown_system_notifications().is_empty());
|
||||
|
||||
// In sight, a program's notice shows in the window; the bell says nothing.
|
||||
print_into(cx, &workspace, 1, "\x1b]777;notify;构建;完成\x07\x07");
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.notifications(cx).len() == 1
|
||||
})
|
||||
.await;
|
||||
cx.background_executor
|
||||
.advance_clock(Duration::from_millis(200));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(in_window(cx, handle), 1);
|
||||
assert!(cx.shown_system_notifications().is_empty());
|
||||
|
||||
// Its notice gone, the first terminal rings behind a second one.
|
||||
cx.background_executor
|
||||
.advance_clock(Duration::from_secs(10));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(in_window(cx, handle), 0);
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("new-local-terminal", cx)
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
|
||||
workspace
|
||||
.read(cx)
|
||||
.local_terminal(LocalTerminalId(2))
|
||||
.is_some_and(|terminal| {
|
||||
terminal.read(cx).status(cx).lifecycle() == &TerminalLifecycle::Running
|
||||
})
|
||||
})
|
||||
.await;
|
||||
print_into(cx, &workspace, 1, "y/N\x07");
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.notifications(cx).len() == 1
|
||||
})
|
||||
.await;
|
||||
|
||||
// With the window away, a notice goes to the system, under the tab's
|
||||
// name, and clicking it shows that terminal.
|
||||
cx.update(|cx| {
|
||||
assert_eq!(
|
||||
workspace.read(cx).active_tab(),
|
||||
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(2)))
|
||||
);
|
||||
});
|
||||
gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window();
|
||||
print_into(cx, &workspace, 1, "\x1b]9;部署完成\x07");
|
||||
let shown = system_notifications(cx, 1).await;
|
||||
assert_eq!(shown[0].tag.as_ref(), "terminal:local:1");
|
||||
assert_eq!(shown[0].title.as_ref(), "本地终端 1");
|
||||
assert_eq!(shown[0].body.as_ref(), "部署完成");
|
||||
cx.simulate_system_notification_response(gpui_kit::SystemNotificationResponse {
|
||||
tag: shown[0].tag.clone(),
|
||||
action_id: None,
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| {
|
||||
assert_eq!(
|
||||
workspace.read(cx).active_tab(),
|
||||
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(1)))
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user