diff --git a/CLAUDE.md b/CLAUDE.md index ae44f7a..4709ed5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,9 +8,9 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP **保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。 -主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;标题栏右上角「临时连接…」打开的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」(见下面「临时连接不保存,功能齐全」)。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 +主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);SFTP 里的文本文件(远程和本地)可以在内置编辑器里改,⌘S 原地写回(见下面「编辑器」);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;标题栏右上角「临时连接…」打开的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」(见下面「临时连接不保存,功能齐全」)。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 -已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份,也是清单里每个包的第二个下载地址),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标(`success` 色的浅色按钮,出错时 `warning` 色;原来是普通图标加小绿点,用户嫌不显眼),点开是更新对话框(不列更新内容,用户要的:页脚两个按钮「查看更新内容」打开官网的 `https://shellrs.com/changelog`(`build_info::CHANGELOG_PAGE`,用户要的:整页,不带版本锚点,也不用清单的 `notes_url`)和「重启并安装」,没有「稍后」,用关闭按钮或 Esc 关;正文是一句状态、当前版本和发布日期、重启会中断什么,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 `v版本号` 和一个按钮:平时「检查更新」,有事要做时换成「重启并安装…」「下载更新」或「前往下载页」,不并排两个)、「更新渠道」(稳定版 / Beta,运行时切换,默认是这个构建自己的渠道;切换就丢掉已找到、已下好的版本,开着自动升级时立刻查新渠道)、「自动升级」开关;构建号和平台只在版本号的 tooltip 里;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。右侧栏:窗口最右边(标题栏和状态栏之间)一列竖排的工具切换,点一个就在右侧 Dock 里显示这个工具、再点一次收起,⌘⌥B / Ctrl+Alt+B 显示或隐藏(显示上次的工具);**只跟着 SSH 远程终端出现**:当前标签是 SFTP、本地终端、设置或开始页时,右侧栏连同按钮列都不显示(用户要的:看的是每个终端里的信息;本地终端试过显示「本机」,用户让先隐藏、专注远程),⌘⌥B 也不起作用;回到任一远程终端时按原样恢复(开着就还开着、同一个工具),内容对应这个终端的主机;它是**工作区级**的一份,不是每个终端标签各带一份(中间区可以分栏,每个标签一份会重复、挤占终端),开合和所选工具在所有终端间共用;分栏时按「当前标签」(最近显示的那个)判断;默认收起、宽 320 px,拖动只能往宽里拉、不能比 320 px 窄(用户定的,系统监控的卡片按这个宽度排),宽度和 Dock 布局一样不持久化;工具有命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控(按钮列从上到下就是这个顺序,同用户给的参考图),七个工具都做好了。系统监控:系统(主机名、架构、发行版、运行时长;运行时长写成「32 天 23:40:26」,不足一天「23:40:26」,随每次读数刷新,不在两次读数之间自己走(用户定的,别过度设计)、CPU(型号、核数、平均使用率的环和条;多于 1 核时平均使用率下面是一排小竖条,每核一根,鼠标停上去提示「核 12:35.0%」,不管多少核都这一种画法(用户定的;试过两列每核一行编号、条和百分比,64 核要 32 行);默认只显示放得下的第一行(大多数主机一行就够,默认宽度一行 23 根),一行放不下时标题行右端才有「N 核 ⌄」按钮,提示「显示更多核的占用」/「收起更多核的占用」,展开后换行显示全部,默认宽度下 64 核排 3 行)、内存(物理内存,有交换空间时再加一行)、网络(每块网卡的上传 / 下载速率,不列 `lo` 和从没收发过的网卡;默认只显示主网卡(默认路由走的那块,读 `/proc/net/route`,没有就取收发最多的),多于 1 块时标题行右端的「N 个网卡 ⌄」展开全部;两个展开状态在所有终端间共用,不保存)、磁盘(每个挂载点的占用,按 `df` 的算法不含 root 保留空间,不列 tmpfs、devtmpfs、overlay、snap 的 loop 设备和 `/dev` `/run` `/sys` `/proc` 下的挂载)五张卡片;只支持 Linux(读 `/proc` 和 `df -kPl`,只看本地文件系统,失效的网络挂载不会卡住):已探测到不是 Linux 的主机(macOS、Windows、FreeBSD、OpenBSD、NetBSD)**不显示系统监控、网络连接、进程管理和系统服务的图标**(用户要的,比点进去看「暂不支持」好),这几个工具正开着时切到这样的主机,右侧栏收起、别的工具图标还在,切回 Linux 主机原样恢复,在这样的主机上按 ⌘⌥B 显示第一个可用的工具;还没探测出系统的主机照样显示,读不了时写明「暂不支持」兜底;Docker 和历史命令只在已知是 Windows 的主机上不显示(`sh` 能跑 `docker`、读得到 bash 历史文件的地方都行,Mac 上的 Docker Desktop 也是);CPU、内存、网络和运行时长每 2 秒读一次(用户定的,试过 3 秒;第二次在 1 秒后,CPU 和网速要两次读数才有),主机名、架构、发行版和 CPU 型号每次连接只读一次,磁盘每 30 秒一次,其余每次只用 shell 内建命令读几个 `/proc` 文件,除了跑它的 `sh` 不在主机上起别的进程;只在它显示时读,隐藏、换工具、切到别的标签就停;用量 ≥ 70% 黄、≥ 90% 红,其余绿。网络连接是右侧栏里单独的一个工具,不是系统监控里的一张卡片(试过卡片,用户要独立工具,并给了参考图):顶上一行「共 96 条 · 15 个监听端口 · 21 条已连接」加刷新按钮,下面是搜索框(地址、端口、状态、PID、进程、用户,不分大小写)和「全部协议 / TCP / UDP」「全部状态 / 监听 / 已连接 / TIME_WAIT / CLOSE_WAIT / 其他状态」两个下拉;每条一张卡片:角色图标、协议和状态两个标签、右端写「监听端口」「入站连接」(本地端口是本机同协议在监听的)或「出站连接」,然后是本地、远端地址(照 `ss` 的写法),分隔线下是「进程 名称 (PID n)」(几个进程共用时「等 N 个」)和右边的用户;状态里监听、已连接用中文,其余用 TCP 的名字(TIME_WAIT 等),UDP 没连对端的算「监听」;绿是监听、蓝是已连接、黄是 CLOSE_WAIT,其余灰;监听的在前,然后已连接、CLOSE_WAIT、握手中、关闭中,TIME_WAIT 最后,同类按协议、本地端口排;同一地址被几个进程一起监听(nginx 的 worker、SO_REUSEPORT)合成一张卡片。**列表不自己刷新**:打开网络连接、换到别的终端时各读一次,之后只有点刷新才读(列表在读的时候自己挪动没法看;系统监控是仪表,才每 2 秒读);切回来先显示上次的列表、同时重读;终端没连接时每 2 秒看一眼,连上就读。读的是 `ss -tuanpe`,不是 root 时只看得到自己的进程,没有 `ss` 的主机读 `/proc/net`、没有进程,这两种情况顶上都写明;最多列 3000 条,超过时顶上写「连接太多,只列出了前 3000 条。」,计数仍是主机上的全部。进程管理(同样是单独的工具,用户给了参考图):顶上一行「共 137 个进程 · 每 15 秒刷新」加刷新按钮,下面是搜索框(进程名、PID、用户)和「内存」「CPU」两个排序按钮,右端是当前列出的个数;每个进程一张卡片:状态圆点(绿运行中、灰休眠 / 空闲、黄等待 I/O / 已暂停、红僵尸,tooltip 是状态)、进程名(`/proc//stat` 的名字,内核截到 15 字节)、右端「PID n」,第二行「用户 · 启动时间」(本机时区,「2026-09-15 17:53:54」),第三行「内存 474.74 MB · 28.1%」(蓝)和右边「CPU 0.5%」(橙),最下面一条内存占比的进度条;不列内核线程(`PF_KTHREAD`);默认按内存从多到少,点另一个按钮按它从多到少,再点同一个按钮反过来,箭头表示方向;CPU 按 `top` 的算法(一个核 100%,两次读数之间的 CPU 节拍差),所以第一次读数是「—」,2 秒后读第二次,之后每 15 秒一次(参考图上写的),只在显示时读,换终端、刷新都从头来;**点进程**打开详情对话框(用户给了参考图):标题是进程名加状态标签,下面一行「PID 2202 · root」;「进程信息」两列 16 项(PID、父进程「systemd (1)」、用户、状态「休眠 (Ssl)」、启动时间、运行时长、终端(没有写「无」)、优先级(`top` 的 PR,nice 0 是 20)、Nice、CPU 使用率、累计 CPU 时间、常驻内存「468.75 MB (24.0%)」、虚拟内存、线程(参考图没有,补上填满两列)、直接子进程、全部后代进程),运行时长和累计 CPU 时间用和系统监控一样的「N 天 HH:MM:SS」;「完整命令」是打开时才读的 `/proc//cmdline`(列表不带它),右边「复制命令」;底部左边「复制 PID」,右边「结束进程」「强制结束」(参考图写「强制杀死」,统一成右键菜单和确认框的叫法),点了先关掉详情再弹确认框(不叠两层);详情是打开那一刻的列表,不跟着刷新。**右键进程**有「查看详情」「结束进程…」(SIGTERM)和「强制结束进程…」(SIGKILL),都先弹确认框(写明 PID、用户和后果,危险按钮「结束进程」/「强制结束」),结果是通知(「已向 java(PID 2202)发送结束信号」「已强制结束…」或「无法结束…:没有权限…」),1 秒后重读列表;最多列 2500 个进程,超过时顶上写明。系统服务(用户给了参考图):顶上「systemd 249 · 降级运行」(`is-system-running` 是 degraded 时这行是黄字)加刷新按钮,搜索框(服务名、描述),gpui-kit 下划线 `TabBar` 的四个标签「全部 / 运行中 / 已停止 / 失败」,数量是标签的子元素、淡色紧跟文字(数量随搜索变;用户定的:试过和 Docker 一样均分整行的分段控件,「已停止 202」在均分的一段里被缩略成「…」,下划线标签按文字宽度排、放不下时可以左右拖动);列表分「自定义服务」(unit 文件在 `/etc/systemd/system/`)和「系统服务」两组,各带数量,组内按名字(systemd 的顺序,大写在前);每个服务一张卡片:状态圆点(绿运行中、红失败、黄启动或停止中、灰已停止)、名字、描述、运行状态标签和开机启动标签(已启用绿,其余灰),右边是按钮:运行中「停止」(浅红)「重启」,没运行「启动」(浅绿),再一个「…」打开详情(参考图的「…」,我们没做每张卡片的下拉菜单,那些命令在右键菜单和详情里);点卡片也打开详情。「运行中」包括 active / reloading / activating / deactivating,一次性服务的 active (exited) 也算运行中(参考图上 apparmor 就是)。详情对话框:标题是名字加状态标签、下面描述;`TabBar` 的「状态」「日志」两页,状态页两列(加载状态、运行状态「运行中 / 已退出」、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动时间、停止时间(运行中时写「—」,参考图写 n/a)),最后一整行 Unit 文件;日志页是 `journalctl -u` 最近 200 行,第一次切过去才读,滚到最底;底部靠左是按钮:运行中「停止」(danger)「重启」,否则「启动」(primary),再加「禁用开机启动」或「启用开机启动」(只有 enabled / disabled 的 unit 有,static、generated 之类没有)。停止和重启先弹确认框(`ssh.service` / `sshd.service` 的停止特别写明新的连接会连不上),启动和开机启动直接执行(参考图上没有确认,这两样不拿走什么);结果是通知,之后重读列表。**不是 root 时用 `sudo -n`**:云主机常以 ecs-user、ubuntu 之类登录、配了免密码 sudo;sudo 要密码就报「需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo」。列表不自己刷新(服务很少变,一次读数起好几个 `systemctl`):打开、换终端、刷新、执行命令之后各读一次。Docker(用户给了参考图):顶上「Docker 26.0.0 · Compose v2.25.0」加刷新按钮,下面 `count_tabs` 的「容器 / 卷 / 镜像 / 网络」带数量;容器页按 compose 项目(`com.docker.compose.project` 标签)成卡片:折叠箭头、圆点(全在跑绿、部分黄、都停灰)、项目名、「运行中/总数」标签、项目目录(`…project.working_dir`),右边停止或启动、重启(对项目里全部容器,用容器 ID 直接 `docker stop/start/restart`,不靠 compose 文件);只有一个容器且在跑的项目默认展开,多个容器的(用户要的,展开会把下面的挤下去,卡片上已写着「运行中/总数」)和都停了的默认折叠,点项目那一行切换(记在面板里,不保存);展开后每个容器一行:圆点、名字、端口(`0.0.0.0:` 省掉,`->` 写成 `→`)或没有端口时的镜像,右边停止或启动、重启、「…」(详情);没有项目的容器在「独立容器」小标题下,同样的行。卷 / 镜像 / 网络页每项一张卡片:名字、一行说明(卷:驱动 · 挂载点;镜像:ID · 大小 · 创建时间,没名字的写「未命名镜像」排最后;网络:驱动 · 范围)、标签(使用中 / 未使用、N 个容器、内置),右边删除按钮,有容器在用或是 bridge / host / none 时禁用(tooltip 说为什么);点卡片打开它的详情(用户要的):标题是名字加卡片上那个标签、下面「种类 · 那行说明」,正文是 `docker volume|image|network inspect` 排成的区块(卷:基础信息、使用它的容器(来自列表,inspect 不说)、选项、标签;镜像:基础信息(完整 ID、标签、摘要、创建时间、大小、平台、层数、作者)、使用它的容器、运行配置、环境变量、标签;网络:基础信息、子网(「网关 …」)、容器(inspect 只列运行中的,带地址)、选项、标签),没有的写「—」,底部只有「删除」(不能删时禁用、tooltip 说为什么),点了先关详情再确认。点容器打开详情对话框:标题是容器名加状态标签、下面镜像(参考图标题写「容器详情」,统一成进程、服务详情的样子),「详情」「日志」两页;详情按参考图分「基础信息(名称、ID、镜像、创建时间、入口、命令)」「端口映射」「挂载」「环境变量(等宽、可滚动)」「标签」,没有的写「—」;日志是 `docker logs --tail 200 --timestamps`,第一次切过去才读;底部停止或启动、重启,已停止的再加「删除」。停止、重启、删除先确认,启动直接执行。**不是 root 又不在 docker 组时用 `sudo -n docker`**。列表不自己刷新,同系统服务。历史命令(用户定的:从主机上 bash 的历史文件 `~/.bash_history` 读,不是 ShellRS 自己记录终端里敲的命令;点一条是「输入到终端、不执行」,另有执行按钮):顶上「共 401 条 · ~/.bash_history」加刷新按钮,下面一行淡色的「这个终端里执行的命令,bash 退出后才会出现在这里。」(bash 退出时才写文件,不说明的话用户会以为刚敲的命令丢了),搜索框(几个词都要有,不分大小写);每条命令一张卡片:等宽的命令(一行,多行命令的换行写成「↵」,长的截断、鼠标停上去看全文)、下面「3 分钟前 · 执行 2 次」(文件里没有时间就只有「执行 2 次」;一个月以前写日期),右边执行按钮;最近的在前,同一条只列一次(排在最近一次的位置,计次数)。点卡片把命令放进终端的输入行、替换已经输入的内容、不执行,终端拿到键盘可以接着改;执行按钮直接执行;右键「输入到终端」「执行」「复制」;终端没连接、正在跑全屏程序时不输入,弹通知说明。列表不自己刷新,同网络连接;文件太长时只读最后 512 KB,顶上写明。命令片段(用户定的:所有主机共用,不绑定分组或主机,但库里预留了「适用范围」的列;一层分类加搜索):顶上「共 12 个片段 · 3 个分类」和「新建分类」「新建片段」两个按钮,搜索框(名称和命令,几个词都要有,不分大小写);按分类分组,分类按名称、组内按名称(不分大小写),没分类的在最后的「未分类」下,一个分类都没有时不画分类标题;新建的空分类也显示(「部署 0」);点分类标题折叠 / 展开(记在面板里,不保存),搜索时只列有匹配的分类、全部展开;每个片段一张卡片:名称、下面一行等宽的命令(多行命令的换行写成「↵」,长的鼠标停上去看全文),右边的执行按钮平时不显示、鼠标移到卡片上才出现(用户要的:一看就知道点了会怎样):点击时自动执行的是黄色闪电(提示「点击时自动执行」),只输入的是 ▶(提示「执行」),两种点了都执行;按钮的位置一直留着(`invisible` 加卡片的 `group_hover`),出现时别的不挪;点卡片输入到终端、执行按钮直接执行,和历史命令一样,勾了「点击时自动执行」的片段点卡片也直接执行(用户要的设置);右键片段「输入到终端」「执行」「复制命令」「编辑…」「删除…」,分类标题「新建片段…」(预选这个分类)「重命名分类…」「删除分类…」,空白处「新建片段…」「新建分类…」;一个片段都没有时是「还没有命令片段」和「新建片段」按钮。片段对话框是名称、分类(下拉,「未分类」在最前)、命令(多行、等宽)和勾选框「点击时自动执行」(下面的说明「启用后将自动执行命令,否则只将内容填入终端」,新建时默认勾上,用户定的),打开时名称就有键盘;分类对话框只有名称,不许重名;删除分类连同里面的片段一起删,确认框写明个数。不读主机,所以 Windows 主机上也有;Windows 的 shell 不认 Ctrl-E / Ctrl-U,那里点片段不替换已经输入的内容,接在后面输入。 +已确认的产品决定: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`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份,也是清单里每个包的第二个下载地址),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标(`success` 色的浅色按钮,出错时 `warning` 色;原来是普通图标加小绿点,用户嫌不显眼),点开是更新对话框(不列更新内容,用户要的:页脚两个按钮「查看更新内容」打开官网的 `https://shellrs.com/changelog`(`build_info::CHANGELOG_PAGE`,用户要的:整页,不带版本锚点,也不用清单的 `notes_url`)和「重启并安装」,没有「稍后」,用关闭按钮或 Esc 关;正文是一句状态、当前版本和发布日期、重启会中断什么,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 `v版本号` 和一个按钮:平时「检查更新」,有事要做时换成「重启并安装…」「下载更新」或「前往下载页」,不并排两个)、「更新渠道」(稳定版 / Beta,运行时切换,默认是这个构建自己的渠道;切换就丢掉已找到、已下好的版本,开着自动升级时立刻查新渠道)、「自动升级」开关;构建号和平台只在版本号的 tooltip 里;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。右侧栏:窗口最右边(标题栏和状态栏之间)一列竖排的工具切换,点一个就在右侧 Dock 里显示这个工具、再点一次收起,⌘⌥B / Ctrl+Alt+B 显示或隐藏(显示上次的工具);**只跟着 SSH 远程终端出现**:当前标签是 SFTP、本地终端、设置或开始页时,右侧栏连同按钮列都不显示(用户要的:看的是每个终端里的信息;本地终端试过显示「本机」,用户让先隐藏、专注远程),⌘⌥B 也不起作用;回到任一远程终端时按原样恢复(开着就还开着、同一个工具),内容对应这个终端的主机;它是**工作区级**的一份,不是每个终端标签各带一份(中间区可以分栏,每个标签一份会重复、挤占终端),开合和所选工具在所有终端间共用;分栏时按「当前标签」(最近显示的那个)判断;默认收起、宽 320 px,拖动只能往宽里拉、不能比 320 px 窄(用户定的,系统监控的卡片按这个宽度排),宽度和 Dock 布局一样不持久化;工具有命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控(按钮列从上到下就是这个顺序,同用户给的参考图),七个工具都做好了。系统监控:系统(主机名、架构、发行版、运行时长;运行时长写成「32 天 23:40:26」,不足一天「23:40:26」,随每次读数刷新,不在两次读数之间自己走(用户定的,别过度设计)、CPU(型号、核数、平均使用率的环和条;多于 1 核时平均使用率下面是一排小竖条,每核一根,鼠标停上去提示「核 12:35.0%」,不管多少核都这一种画法(用户定的;试过两列每核一行编号、条和百分比,64 核要 32 行);默认只显示放得下的第一行(大多数主机一行就够,默认宽度一行 23 根),一行放不下时标题行右端才有「N 核 ⌄」按钮,提示「显示更多核的占用」/「收起更多核的占用」,展开后换行显示全部,默认宽度下 64 核排 3 行)、内存(物理内存,有交换空间时再加一行)、网络(每块网卡的上传 / 下载速率,不列 `lo` 和从没收发过的网卡;默认只显示主网卡(默认路由走的那块,读 `/proc/net/route`,没有就取收发最多的),多于 1 块时标题行右端的「N 个网卡 ⌄」展开全部;两个展开状态在所有终端间共用,不保存)、磁盘(每个挂载点的占用,按 `df` 的算法不含 root 保留空间,不列 tmpfs、devtmpfs、overlay、snap 的 loop 设备和 `/dev` `/run` `/sys` `/proc` 下的挂载)五张卡片;只支持 Linux(读 `/proc` 和 `df -kPl`,只看本地文件系统,失效的网络挂载不会卡住):已探测到不是 Linux 的主机(macOS、Windows、FreeBSD、OpenBSD、NetBSD)**不显示系统监控、网络连接、进程管理和系统服务的图标**(用户要的,比点进去看「暂不支持」好),这几个工具正开着时切到这样的主机,右侧栏收起、别的工具图标还在,切回 Linux 主机原样恢复,在这样的主机上按 ⌘⌥B 显示第一个可用的工具;还没探测出系统的主机照样显示,读不了时写明「暂不支持」兜底;Docker 和历史命令只在已知是 Windows 的主机上不显示(`sh` 能跑 `docker`、读得到 bash 历史文件的地方都行,Mac 上的 Docker Desktop 也是);CPU、内存、网络和运行时长每 2 秒读一次(用户定的,试过 3 秒;第二次在 1 秒后,CPU 和网速要两次读数才有),主机名、架构、发行版和 CPU 型号每次连接只读一次,磁盘每 30 秒一次,其余每次只用 shell 内建命令读几个 `/proc` 文件,除了跑它的 `sh` 不在主机上起别的进程;只在它显示时读,隐藏、换工具、切到别的标签就停;用量 ≥ 70% 黄、≥ 90% 红,其余绿。网络连接是右侧栏里单独的一个工具,不是系统监控里的一张卡片(试过卡片,用户要独立工具,并给了参考图):顶上一行「共 96 条 · 15 个监听端口 · 21 条已连接」加刷新按钮,下面是搜索框(地址、端口、状态、PID、进程、用户,不分大小写)和「全部协议 / TCP / UDP」「全部状态 / 监听 / 已连接 / TIME_WAIT / CLOSE_WAIT / 其他状态」两个下拉;每条一张卡片:角色图标、协议和状态两个标签、右端写「监听端口」「入站连接」(本地端口是本机同协议在监听的)或「出站连接」,然后是本地、远端地址(照 `ss` 的写法),分隔线下是「进程 名称 (PID n)」(几个进程共用时「等 N 个」)和右边的用户;状态里监听、已连接用中文,其余用 TCP 的名字(TIME_WAIT 等),UDP 没连对端的算「监听」;绿是监听、蓝是已连接、黄是 CLOSE_WAIT,其余灰;监听的在前,然后已连接、CLOSE_WAIT、握手中、关闭中,TIME_WAIT 最后,同类按协议、本地端口排;同一地址被几个进程一起监听(nginx 的 worker、SO_REUSEPORT)合成一张卡片。**列表不自己刷新**:打开网络连接、换到别的终端时各读一次,之后只有点刷新才读(列表在读的时候自己挪动没法看;系统监控是仪表,才每 2 秒读);切回来先显示上次的列表、同时重读;终端没连接时每 2 秒看一眼,连上就读。读的是 `ss -tuanpe`,不是 root 时只看得到自己的进程,没有 `ss` 的主机读 `/proc/net`、没有进程,这两种情况顶上都写明;最多列 3000 条,超过时顶上写「连接太多,只列出了前 3000 条。」,计数仍是主机上的全部。进程管理(同样是单独的工具,用户给了参考图):顶上一行「共 137 个进程 · 每 15 秒刷新」加刷新按钮,下面是搜索框(进程名、PID、用户)和「内存」「CPU」两个排序按钮,右端是当前列出的个数;每个进程一张卡片:状态圆点(绿运行中、灰休眠 / 空闲、黄等待 I/O / 已暂停、红僵尸,tooltip 是状态)、进程名(`/proc//stat` 的名字,内核截到 15 字节)、右端「PID n」,第二行「用户 · 启动时间」(本机时区,「2026-09-15 17:53:54」),第三行「内存 474.74 MB · 28.1%」(蓝)和右边「CPU 0.5%」(橙),最下面一条内存占比的进度条;不列内核线程(`PF_KTHREAD`);默认按内存从多到少,点另一个按钮按它从多到少,再点同一个按钮反过来,箭头表示方向;CPU 按 `top` 的算法(一个核 100%,两次读数之间的 CPU 节拍差),所以第一次读数是「—」,2 秒后读第二次,之后每 15 秒一次(参考图上写的),只在显示时读,换终端、刷新都从头来;**点进程**打开详情对话框(用户给了参考图):标题是进程名加状态标签,下面一行「PID 2202 · root」;「进程信息」两列 16 项(PID、父进程「systemd (1)」、用户、状态「休眠 (Ssl)」、启动时间、运行时长、终端(没有写「无」)、优先级(`top` 的 PR,nice 0 是 20)、Nice、CPU 使用率、累计 CPU 时间、常驻内存「468.75 MB (24.0%)」、虚拟内存、线程(参考图没有,补上填满两列)、直接子进程、全部后代进程),运行时长和累计 CPU 时间用和系统监控一样的「N 天 HH:MM:SS」;「完整命令」是打开时才读的 `/proc//cmdline`(列表不带它),右边「复制命令」;底部左边「复制 PID」,右边「结束进程」「强制结束」(参考图写「强制杀死」,统一成右键菜单和确认框的叫法),点了先关掉详情再弹确认框(不叠两层);详情是打开那一刻的列表,不跟着刷新。**右键进程**有「查看详情」「结束进程…」(SIGTERM)和「强制结束进程…」(SIGKILL),都先弹确认框(写明 PID、用户和后果,危险按钮「结束进程」/「强制结束」),结果是通知(「已向 java(PID 2202)发送结束信号」「已强制结束…」或「无法结束…:没有权限…」),1 秒后重读列表;最多列 2500 个进程,超过时顶上写明。系统服务(用户给了参考图):顶上「systemd 249 · 降级运行」(`is-system-running` 是 degraded 时这行是黄字)加刷新按钮,搜索框(服务名、描述),gpui-kit 下划线 `TabBar` 的四个标签「全部 / 运行中 / 已停止 / 失败」,数量是标签的子元素、淡色紧跟文字(数量随搜索变;用户定的:试过和 Docker 一样均分整行的分段控件,「已停止 202」在均分的一段里被缩略成「…」,下划线标签按文字宽度排、放不下时可以左右拖动);列表分「自定义服务」(unit 文件在 `/etc/systemd/system/`)和「系统服务」两组,各带数量,组内按名字(systemd 的顺序,大写在前);每个服务一张卡片:状态圆点(绿运行中、红失败、黄启动或停止中、灰已停止)、名字、描述、运行状态标签和开机启动标签(已启用绿,其余灰),右边是按钮:运行中「停止」(浅红)「重启」,没运行「启动」(浅绿),再一个「…」打开详情(参考图的「…」,我们没做每张卡片的下拉菜单,那些命令在右键菜单和详情里);点卡片也打开详情。「运行中」包括 active / reloading / activating / deactivating,一次性服务的 active (exited) 也算运行中(参考图上 apparmor 就是)。详情对话框:标题是名字加状态标签、下面描述;`TabBar` 的「状态」「日志」两页,状态页两列(加载状态、运行状态「运行中 / 已退出」、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动时间、停止时间(运行中时写「—」,参考图写 n/a)),最后一整行 Unit 文件;日志页是 `journalctl -u` 最近 200 行,第一次切过去才读,滚到最底;底部靠左是按钮:运行中「停止」(danger)「重启」,否则「启动」(primary),再加「禁用开机启动」或「启用开机启动」(只有 enabled / disabled 的 unit 有,static、generated 之类没有)。停止和重启先弹确认框(`ssh.service` / `sshd.service` 的停止特别写明新的连接会连不上),启动和开机启动直接执行(参考图上没有确认,这两样不拿走什么);结果是通知,之后重读列表。**不是 root 时用 `sudo -n`**:云主机常以 ecs-user、ubuntu 之类登录、配了免密码 sudo;sudo 要密码就报「需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo」。列表不自己刷新(服务很少变,一次读数起好几个 `systemctl`):打开、换终端、刷新、执行命令之后各读一次。Docker(用户给了参考图):顶上「Docker 26.0.0 · Compose v2.25.0」加刷新按钮,下面 `count_tabs` 的「容器 / 卷 / 镜像 / 网络」带数量;容器页按 compose 项目(`com.docker.compose.project` 标签)成卡片:折叠箭头、圆点(全在跑绿、部分黄、都停灰)、项目名、「运行中/总数」标签、项目目录(`…project.working_dir`),右边停止或启动、重启(对项目里全部容器,用容器 ID 直接 `docker stop/start/restart`,不靠 compose 文件);只有一个容器且在跑的项目默认展开,多个容器的(用户要的,展开会把下面的挤下去,卡片上已写着「运行中/总数」)和都停了的默认折叠,点项目那一行切换(记在面板里,不保存);展开后每个容器一行:圆点、名字、端口(`0.0.0.0:` 省掉,`->` 写成 `→`)或没有端口时的镜像,右边停止或启动、重启、「…」(详情);没有项目的容器在「独立容器」小标题下,同样的行。卷 / 镜像 / 网络页每项一张卡片:名字、一行说明(卷:驱动 · 挂载点;镜像:ID · 大小 · 创建时间,没名字的写「未命名镜像」排最后;网络:驱动 · 范围)、标签(使用中 / 未使用、N 个容器、内置),右边删除按钮,有容器在用或是 bridge / host / none 时禁用(tooltip 说为什么);点卡片打开它的详情(用户要的):标题是名字加卡片上那个标签、下面「种类 · 那行说明」,正文是 `docker volume|image|network inspect` 排成的区块(卷:基础信息、使用它的容器(来自列表,inspect 不说)、选项、标签;镜像:基础信息(完整 ID、标签、摘要、创建时间、大小、平台、层数、作者)、使用它的容器、运行配置、环境变量、标签;网络:基础信息、子网(「网关 …」)、容器(inspect 只列运行中的,带地址)、选项、标签),没有的写「—」,底部只有「删除」(不能删时禁用、tooltip 说为什么),点了先关详情再确认。点容器打开详情对话框:标题是容器名加状态标签、下面镜像(参考图标题写「容器详情」,统一成进程、服务详情的样子),「详情」「日志」两页;详情按参考图分「基础信息(名称、ID、镜像、创建时间、入口、命令)」「端口映射」「挂载」「环境变量(等宽、可滚动)」「标签」,没有的写「—」;日志是 `docker logs --tail 200 --timestamps`,第一次切过去才读;底部停止或启动、重启,已停止的再加「删除」。停止、重启、删除先确认,启动直接执行。**不是 root 又不在 docker 组时用 `sudo -n docker`**。列表不自己刷新,同系统服务。历史命令(用户定的:从主机上 bash 的历史文件 `~/.bash_history` 读,不是 ShellRS 自己记录终端里敲的命令;点一条是「输入到终端、不执行」,另有执行按钮):顶上「共 401 条 · ~/.bash_history」加刷新按钮,下面一行淡色的「这个终端里执行的命令,bash 退出后才会出现在这里。」(bash 退出时才写文件,不说明的话用户会以为刚敲的命令丢了),搜索框(几个词都要有,不分大小写);每条命令一张卡片:等宽的命令(一行,多行命令的换行写成「↵」,长的截断、鼠标停上去看全文)、下面「3 分钟前 · 执行 2 次」(文件里没有时间就只有「执行 2 次」;一个月以前写日期),右边执行按钮;最近的在前,同一条只列一次(排在最近一次的位置,计次数)。点卡片把命令放进终端的输入行、替换已经输入的内容、不执行,终端拿到键盘可以接着改;执行按钮直接执行;右键「输入到终端」「执行」「复制」;终端没连接、正在跑全屏程序时不输入,弹通知说明。列表不自己刷新,同网络连接;文件太长时只读最后 512 KB,顶上写明。命令片段(用户定的:所有主机共用,不绑定分组或主机,但库里预留了「适用范围」的列;一层分类加搜索):顶上「共 12 个片段 · 3 个分类」和「新建分类」「新建片段」两个按钮,搜索框(名称和命令,几个词都要有,不分大小写);按分类分组,分类按名称、组内按名称(不分大小写),没分类的在最后的「未分类」下,一个分类都没有时不画分类标题;新建的空分类也显示(「部署 0」);点分类标题折叠 / 展开(记在面板里,不保存),搜索时只列有匹配的分类、全部展开;每个片段一张卡片:名称、下面一行等宽的命令(多行命令的换行写成「↵」,长的鼠标停上去看全文),右边的执行按钮平时不显示、鼠标移到卡片上才出现(用户要的:一看就知道点了会怎样):点击时自动执行的是黄色闪电(提示「点击时自动执行」),只输入的是 ▶(提示「执行」),两种点了都执行;按钮的位置一直留着(`invisible` 加卡片的 `group_hover`),出现时别的不挪;点卡片输入到终端、执行按钮直接执行,和历史命令一样,勾了「点击时自动执行」的片段点卡片也直接执行(用户要的设置);右键片段「输入到终端」「执行」「复制命令」「编辑…」「删除…」,分类标题「新建片段…」(预选这个分类)「重命名分类…」「删除分类…」,空白处「新建片段…」「新建分类…」;一个片段都没有时是「还没有命令片段」和「新建片段」按钮。片段对话框是名称、分类(下拉,「未分类」在最前)、命令(多行、等宽)和勾选框「点击时自动执行」(下面的说明「启用后将自动执行命令,否则只将内容填入终端」,新建时默认勾上,用户定的),打开时名称就有键盘;分类对话框只有名称,不许重名;删除分类连同里面的片段一起删,确认框写明个数。不读主机,所以 Windows 主机上也有;Windows 的 shell 不认 Ctrl-E / Ctrl-U,那里点片段不替换已经输入的内容,接在后面输入。内置编辑器(用户定的:WinSCP 的「编辑」,做成中间区的「编辑器」Dock 标签,不用系统默认程序打开):SFTP 里双击 / 回车文件、文件右键「编辑」、F4(WinSCP 的键)都在编辑器里打开,本地侧的文件也一样;新建 › 文件… 成功后直接打开它(WinSCP 同);同一个文件只开一个标签(远程按主机 + 路径,本地按路径),再打开就切过去;先读后开:太大(超过 5 MB)、二进制、不是 UTF-8(用户定的:只支持 UTF-8,带不带 BOM 都行,不做 GBK)的文件弹说明、不开标签,远程的给「下载…」;语法高亮只带服务器常见的约 15 种语言(用户定的:gpui-kit 的 tree-sitter 特性,每种语言是一个要编译的 C 语法库),按文件名选,认不出是纯文本;保存远程文件是原地改写(用户定的:截断后写原文件,和 vim、nano、VS Code Remote 一样,所有者、权限、符号链接、硬链接都不变;不走上传的 `.filepart` 加改名),保存前核对大小和修改时间,被别人改过就问「覆盖 / 取消」;关闭有修改的编辑器问「保存 / 放弃修改 / 取消」,批量关闭、关 SFTP 标签、⌘Q(Windows / Linux 还有窗口的关闭按钮)有未保存的文件时问一次;远程文件的编辑器属于打开它的 SFTP 标签(读写走它的连接,不另起登录),SFTP 标签关掉就一起关,本地文件的编辑器留着;编辑器标签的状态栏左边是那个 SFTP 标签的连接(本地写「本机文件」),右边是「行 12,列 5」和「UTF-8 · LF」。 做 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/.md` 为准。 @@ -41,14 +41,14 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。 -- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。 +- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`quit.rs`(退出的守卫 `set_quit_guard` / `quit_held_back`,见下面「编辑器」);`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。 - `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), 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:` / `s:`;`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 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`(`Latency` / `LatencyLevel` 在 `connection.rs`,画法是 `shared::LatencyLabel`)。SFTP 标签同样显示:工作线程另起一个 `measure_latency` 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 `SftpEvent::Latency`;`ExplorerPanel` 在 `Connecting` / `Disconnected` 时清掉,只在已连接时由 `title_suffix` 显示。这个任务只在一次 ping 期间攥着 `Arc`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「SFTP 空闲时也要发现断网」的约束)。 - **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。选「密码」又没填密码时不拦着(用户定的:不强制填密码,连不上就连不上):`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 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。`TemporarySecretStore`(`temporary.rs`)是 `HostStore` 包在注入的存储外面的一层:`SecretRef::Temporary { host }`(不保存的连接的密码)只记在它的内存里,其余原样转给钥匙串,`remember` / `forget` 只碰内存、可以在 UI 线程上调。服务名是 `SERVICE` = `shellrs`。 -- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 -- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 +- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`file_edit.rs`(给编辑器读写文件:`FileLocation`、`edit_target`、`open_for_edit`(先读后开,发 `ExplorerPanelEvent::EditFile`,读不了的在 `refuse_edit` 说明)、`read_file` / `write_remote_file`(远程的挂在 `files` 里,`on_event` 收到 `FileRead` / `FileWritten` 时完成,断开时丢掉)、`reload_if_showing`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 +- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`edit.rs`(编辑器的整文件读写:`TextFile::decode` / `TextFormat::encode`(BOM、纯 CRLF、二进制和 UTF-8 的判断)、`read_text`(`stat` 后按上限拒绝,流水线读进预分配的缓冲区,第一块有 NUL 就放弃)、`write_in_place`(核对 `FileStamp`、`RemoteFs::open_replace` 截断写、`sync`、再 `stat`),本地的 `read_local_text` / `write_local_text` 经 `LocalDirectoryProvider::read_file` / `write_file`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 - `forward/` — 端口转发。规则的模型在 `host/forward.rs`,这里是运行和界面。 - `transport.rs`:可注入的接口 `ForwardTransport` / `ForwardTransportProvider`,命令 `ForwardCommand`(`PromptReply`、`Stop`)和事件 `ForwardEvent`(`Connecting`、`Prompt`、`Listening`、`Connections(n)`、`ConnectionFailed`、`Reconnecting`,以及两个终结事件 `Failed` / `Stopped`)。 - `worker.rs`:真实实现 `SshForwardTransportProvider`。每次启动一条规则就是一个线程(`shellrs-forward`)加一个 current-thread runtime,结构照 `sftp/worker.rs`;本地转发是 `channel_open_direct_tcpip` 加 `copy_bidirectional`,远程转发是 `tcpip_forward` 加处理器交出来的通道,动态转发先过 SOCKS 握手。`describe_*` 把错误映射成中文。 @@ -58,13 +58,14 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。 - `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。 - `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`、`ExecTarget`),不依赖 `workspace`。 +- `editor/` — 内置编辑器。`model.rs`(纯函数:`language_for` 按文件名选高亮语言、`indentation`(Makefile 和多数行用 Tab 缩进的文件用真 Tab,`EditorState` 默认是两个空格)、状态栏的 `cursor_label` / `format_label`,`EditorId` 和查重用的 `EditorKey`)、`editor_panel.rs`(`EditorPanel`:中间区标签,`EditorState` + gpui-kit 的 `Editor`,细工具栏(主机徽章、路径、状态标签、「重新加载」「保存」),`EditorSource::{Remote, Local}` 决定读写走 SFTP 标签还是本机,`execute(EditorCommand)` 是保存、覆盖、重新加载、复制路径,`confirm_close` 是三按钮的关闭问题)。依赖 `explorer` 的公开接口(`ExplorerPanel::read_file` / `write_remote_file`、`FileLocation`)和 `sftp`,不依赖 `workspace`;开标签、查重、关闭和确认在 `workspace/editors.rs`。 - `snippets/` — 命令片段,右侧栏的第七个真工具,片段本身在 `HostStore` 里(`host::Snippet`),这里是列表和对话框:`model.rs`(`rows`:分类标题和片段排成列表的行,纯函数)、`snippet_panel.rs`(`SnippetPanel`:搜索框、gpui `list` 画的分类标题和卡片、挂在列表上的右键菜单,菜单在打开时读 store)、`snippet_dialog.rs`(`open_snippet_dialog(SnippetDialog::{New(分类), Edit(id)}, ..)` 和 `open_category_dialog(CategoryDialog::{New, Rename(id)}, ..)`)。依赖 `host` 和 `shared`,不依赖 `terminal`(输入到终端是工作区处理 `EnterCommand`)。 - `history/` — 历史命令,右侧栏的第六个真工具:`bash.rs`(读 `~/.bash_history` 的命令 `command()` 和解析 `parse`)、`model.rs`(`History::new` 去重、按最近排、计次数,`matching` 搜索,`format_when` 写「3 分钟前」,都是纯函数)、`history_panel.rs`(`HistoryPanel`:读取、搜索框、`uniform_list` 画的卡片和挂在列表上的右键菜单)。依赖 `terminal`(读取用 `TerminalView::exec`,把命令放进输入行是工作区经 `TerminalView::enter_command` 做的),不依赖 `workspace`。 - `docker/` — Docker,右侧栏的第五个真工具:`linux.rs`(读数的命令 `command()` 和解析 `parse`(每行一个 JSON,`serde_json`),`valid_id`,容器命令 `control_command`、删除的 `remove_command`、详情的 `inspect_command` / `parse_details`、日志的 `logs_command`,结果的 `done`)、`model.rs`(`Container` / `Volume` / `Image` / `Network`,`DockerTable::new` 按项目分组、标出哪些卷 / 镜像 / 网络有容器在用,`rows` 按标签页和折叠排出行,都是纯函数)、`docker_panel.rs`(`DockerPanel`:读取、`count_tabs`、gpui `list` 画的项目卡片、容器行和卷 / 镜像 / 网络卡片)、`container_details.rs`(`open_container_dialog`:详情对话框,正文 `ContainerDetailsView` 打开时 `docker inspect`、切到日志页时读日志)、`object_details.rs`(`open_object_dialog`:卷、镜像、网络的详情对话框,正文 `ObjectDetailsView`)、`details.rs`(两种对话框共用的 `Read`、在终端连接上执行的 `run`,以及把 `DetailSection` 画成标题加内容的 `render_sections`)。卷、镜像、网络的卡片和详情都从 `ObjectSummary`(`Volume::summary` 等:名字、一行说明、标签及其 `Tone`、能不能删、被哪些容器用着)画,`DockerTable::new` 记下的是用着它们的容器名(`used_by`),不只是有没有。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区。 - `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、下划线 `TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。 - `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。 - `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。 -- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉;标题右边是小号的「快速连接」(`QuickConnect`,primary)和「新建主机…」两个按钮)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 +- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`editors.rs`(`impl Workspace` 的编辑器部分:`open_editor`、查重的 `editor_for_location`、关闭和三种确认、退出守卫 `ask_before_quit`、批量关闭 `close_center_tabs`)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉;标题右边是小号的「快速连接」(`QuickConnect`,primary)和「新建主机…」两个按钮)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 - `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。 - `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`、WinSCP 的开关(`/sessionname=` 当标签名,`/newinstance`、`/ini=` 等列在 `WINSCP_SWITCHES` 里的忽略;只认列出的名字,所以 `/tmp/x` 这种路径不算),或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。 @@ -87,6 +88,16 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **传输队列在标签这一层,引擎一次只跑一批。** `ExplorerPanel` 持有 `TransferQueue`:确认框派发的 `ExplorerCommand::Enqueue(TransferJob)` 只是入队(请求在入队时就校验,失败直接报错不入队),`start_next_if_idle` 在引擎空闲(`engine_busy` 为假,它由发出 Upload / Download / Resume / Discard 置真、由引擎的 `Idle` 清掉)且已连接时把第一个排队中的批次交出去。引擎处理完每个命令都会发 `Idle`(完成、停止、丢弃、准备失败都一样),标签收到后才推进,所以前一批的事件不会串到下一批上。进度事件只归队首(运行中或已停止的那一批),没有队首时丢弃(例如丢弃续传进度后引擎回的空 `Completed`)。**已停止的批次挡在队首**:引擎还留着它等「继续」,开始新批次会把它换掉,所以要等用户继续、丢弃续传进度或「移出队列」(续传记录留在磁盘上)之后队列才往下走。确认框和询问框分开记录(`question_shown`):批次停止或结束时只关询问框,不碰用户正在填的下一批的确认框。`TransferProgress` 为队列补了当前文件(`current_source` / `current_bytes` / `current_total`)、只在传输阶段累计的用时 `elapsed`、最近 3 秒的速度(`sftp/speed.rs` 的 `TransferClock`,等待回答和重连时归零)、跳过和失败文件的字节 `settled_bytes`(`fraction()` 因此能到 100%,`remaining()` 按剩余字节除以速度)和重连说明 `note`(`current` 始终是路径)。队列面板和双栏在同一个 `v_resizable` 里,队列为空时那一块 `visible(false)`,双栏在元素树里的位置不变,分栏比例不丢。 - **SFTP 独立于终端。** 主机状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除主机会停止传输并保留续传数据。每个 SFTP 标签的传输引擎一次跑一个批次,其余在标签的传输队列里等,上传和下载共用(`TransferBatch`,类型统一叫 `Transfer*`,方向在 `TransferProgress::direction`),来源、端点和目标在确认时固定。SFTP 标签按 `ExplorerId` 登记(工作区的 `explorers`,`explorers_of(host)` 列出一个主机的全部 SFTP 标签),`ExplorerAction`、`CloseExplorer`、`RenameExplorer`、`CenterTab::Explorer` 和 SFTP 认证问答的 `PromptOwner::Sftp` 都按标签 id 寻址;按主机做的事(连接状态汇总、断开、删除主机 / 分组、统计要停止的传输)要遍历 `explorers_of`。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。 +- **编辑器:先读后开,原地写回,读写走 SFTP 标签自己的连接。** + - **打开**:`ExplorerCommand::Edit { remote, path }`(`path` 为空时是光标所在的文件;`Open` 遇到文件就派发带路径的 `Edit`)。工作区在 `on_explorer_action` 里先查重(`edit_target` → `editor_for_location`),已开就 `show_editor`,不再读;否则 `ExplorerPanel::open_for_edit` 读文件(`opening` 防重复,面板状态栏 0.3 秒后写「正在打开 x…」),读成功才发 `ExplorerPanelEvent::EditFile`,工作区 `open_editor` 建标签;读的期间又打开同一个文件,`open_editor` 还会再查一次。拒绝(`ReadFailure::{NotFile, TooLarge, NotText}`)是 `refuse_edit` 的对话框,远程的确定按钮是「下载…」(派发 `DownloadPaths`),`Failed` 是错误通知。 + - **读写**:远程的经 `SftpCommand::ReadFile` / `WriteFile`,worker 照 `List` / `Operate` 另起任务、和传输并行,回答是 `SftpEvent::FileRead` / `FileWritten`;`ExplorerPanel` 把等回答的 `oneshot` 挂在 `files` 里、在 `on_event`(UI 线程)上完成,断开时 `abandon_remote_operations` 一起丢掉(读当作失败,写当作 `Interrupted`)。没连接时 `read_file` / `write_remote_file` 返回 `None` 并已经弹了「SFTP 连接已断开 / 重新连接」,编辑器不再另报错。本地的经 `LocalDirectoryProvider::read_file` / `write_file`(测试的假实现只在内存里)。 + - **原地写回**(用户定的):`RemoteFs::open_replace` 是 `CREATE | TRUNCATE | WRITE`,跟随链接、同一个 inode,所以所有者、权限、硬链接和指向它的符号链接都不变;代价是写到一半断网时服务器上是半个文件,这时 `write_in_place` 把错误包成 `SaveFailure::Interrupted`(作为 context,原因留在链上,`is_network_error` 照常认出断网),编辑器作废 `stamp`(下一次保存不再核对)并说明「可能只写了一部分」。不要改成上传那套 `.filepart` 加改名:root 改别人的文件会变成 root 所有、硬链接会断、目录不可写时存不了。 + - **保存前核对**:`FileStamp`(大小加修改时间;SFTP v3 只有秒,同一秒里同样大小的改动发现不了,WinSCP 也一样)。被改过或删掉是 `SaveFailure::Changed`,什么也没写;「覆盖」派发 `EditorCommand::Overwrite { close }`,`expected` 为空,文件不在了就重新建。保存成功后编辑器发 `Saved`,工作区让正显示那个目录的 SFTP 面板重读(远程只问同一台主机的 SFTP 标签,本地问所有标签)。 + - **文本**:只认 UTF-8(`TextFile::decode`:前 8 KB 有 NUL 是二进制;BOM 去掉、保存时加回;所有换行都是 CRLF 才换成 LF 给编辑器、保存时换回,混合换行原样留着,`EditorState` 本身不改 `\r\n`)。上限 `EDIT_LIMIT`(5 MB),远程的在 `stat` 之后、读之前就拒绝。是否有修改按文本比较(`Rope` 先比长度再比内容),撤回到原样就不算修改;保存时取的是那一刻的文本快照,保存期间接着打的字仍算修改。 + - **关闭**:`CloseEditor` 有修改时 `confirm_close`(「取消」「放弃修改」「保存」,「保存」= `SaveAndClose`,写成功才发 `CloseRequested`);`EditorCommand::CloseConfirmed` 由工作区直接移除标签。关 SFTP 标签时它的远程编辑器有修改就问一次(有传输时并进 `confirm_close_transfer` 的说明),确认后 `ExplorerPanelEvent::Closed` 把它的编辑器一起移除;删除主机 / 分组关掉的 SFTP 标签同样带走编辑器,不另问。批量关闭(`on_close_tabs` → `close_center_tabs`)对所有会丢的修改只问一次,不逐个弹三按钮框。 + - **退出守卫**:`app::set_quit_guard` 是全局量,工作区构造时装上,全局的 `Quit` 处理器 `cx.defer` 之后经守卫记下的窗口句柄问 `Workspace::ask_before_quit`(派发 `Quit` 时窗口正被借用,直接 `update` 会失败),有未保存的文件就弹「退出 ShellRS?」并拦下,确认后 `cx.quit()`。不用工作区元素上的 `on_action`:对话框关掉后焦点为空,动作只到窗口根,到不了工作区。Windows / Linux 关窗口即退出,`hide_when_closed` 在那里注册 `on_window_should_close` 调 `quit_held_back`;macOS 关窗口只是隐藏,不动。macOS 程序坞右键「退出」、注销关机走 `applicationShouldTerminate`,GPUI 不给否决的机会,拦不住;在线升级的「重启并安装」也拦不住,`RestartImpact::unsaved_files` 只在对话框里写明会丢。 + - **⌘S** 绑在编辑器的 key context `FileEditor` 上(`EditorShortcut(EditorCommand::Save)`,工作区交给焦点所在的编辑器);gpui-base 的 `Input` 上下文不绑 ⌘S / ⌘W,所以在编辑器的输入区和它的查找框里都有效,升级 gpui-kit 时要再核对。`set_active(true)` 把焦点交给 `EditorState`,否则「×」、⌘W、⌘S 都会失效。 + - **高亮**:`Cargo.toml` 给 gpui-kit 开的 `tree-sitter-*` 特性就是能高亮的语言(`tree-sitter` 本身带 JSON),`language_for` 只能给出这些名字(`every_language_named_is_built_in` 查 `LanguageRegistry`),加语言要同时加特性和 `[profile.dev.package]` 的优化项。`tree-sitter-sequel`(SQL)要求 `cc ~1.2`,所以 `Cargo.lock` 把 `cc` 停在 1.2.x,别随手升到 1.3 以上。 - **下载严格按顺序写盘。** 读请求并发且可能乱序、短读返回,`download_file` 用重排缓冲区按偏移顺序写入 `.filepart`,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。 - **文件操作从不跟随符号链接。** 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走 `trash` crate 的 NSFileManager 方式(不弹自动化权限、不放声音)。文件操作与传输批次可以并行;同一侧面板有操作在跑时(`FilePane::is_busy`)第二行工具栏和对应命令都不接受新操作,结果回来后面板重新读目录。 - **选择归面板,不归表格。** `DataTable` 只支持单行选择,所以表格设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`(`..` 永远不进选择集),通过 `ListingContext` 快照交给委托;同 WinSCP 关掉「整行选择」,**名称单元格才是项目**:`render_td` 给出外层 `name-cell:<名称>`(`size_full`,画悬停灰底、选中底色和光标框,接单击、双击打开和右键,`FileListing::name_events`)和里面只有内容宽的 `name:<名称>`(图标加文件名,只接文件拖动)。所以在名称单元格里任何地方单击都选中这一行,但只有拖图标和文件名才是拖文件,从名称右边的空白拖是框选。行上不画任何状态:`DataTable` 在 `render_tr` 的样式之后给整行加 hover 底色、我们改不掉,所以行的第一个子元素是一块铺满的 `tokens.table` 底色(表格自己的不透明背景)把它盖住,单元格画在它上面;右键时表格给整行画的边框也在容器的右键处理里立刻清掉(`press_empty` 每次都 `clear_menu_row`)。为了连单元格的内边距一起铺满,名称列用 `name_column` 把表格的单元格内边距设成 0,名称单元格和它的列标题各自 `px_1p5()`(等于 `.small()` 表格的 6 px,文字因此和标题对齐);`Size::table_cell_padding` 不是公开 API,拿不到表格自己的值。单击、双击打开、右键都挂在名称上(`FileListing::name_events`),行上只剩拖放目标和记几何的 canvas。选中的名称一律只有底色,光标框只画在未选中的光标项上(`..`、刚用 ⌘ 取消选择的项),否则多选的最后一项会多出一圈边框。名称单元格的 `on_click` 读 `ClickEvent` 的修饰键和点击次数,直接更新面板(这是事件时刻,不是渲染时刻)。名称单元格和列标题以外都是空白处:按下落在哪里记在 `ListGeometry::pressed`(`Pressed::Empty` / `Name` / `Title`,容器在捕获阶段复位成 `Empty`,名称和标题在冒泡时改写),容器的 `on_click` 和右键按下在 `Empty` 且没按 ⌘ / Shift 时清空选择(`FilePane::press_empty`,连光标一起清)。表格底部的横向滚动条会在按下时停止冒泡,点在它上面不算空白处。键盘上的方向键 / Home / End / 翻页绑在 `"LocalFileList > DataTable"` 这一层,盖过表格自带的单行选择键;←、→ 绑 `NoAction`,因为表格在行模式下也会把它们切成列选择。 @@ -208,4 +219,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `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-` 与 `skill-status-`(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-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`recent-quick-connect`(快速连接:列表容器 `quick-connect`,每行 `("quick-connect-host", 主机 id)`(`aria_label` 形如「db-01 · 数据库 · root@10.0.0.2:22」,`aria_selected` 是被 Tab / ⌘A 选中,多选关着时总是假),搜不到或没有主机时 `quick-connect-empty`;打开后搜索框已有焦点,直接 `input`、`press`)、`host-search`、`new-host-panel`(主机列表标题栏的「+」,打开新建主机对话框;标题栏右上角的按钮是临时连接 `temporary-connection`,它的对话框字段 id 同主机对话框,另有说明 `temporary-note`)、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-services` / `tool-processes` / `tool-connections` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在);当前标签不是远程终端时 `tool-switch` 也不存在。命令片段的有:面板 `snippets`、汇总 `snippets-summary`(「共 4 个片段 · 2 个分类」,没有分类时只有前半)、按钮 `snippets-new` / `snippets-new-category`、`snippets-search`、列表 `snippets-list`、一个片段都没有时的 `snippets-empty` 与其中的 `snippets-empty-new`、搜不到时的 `snippets-no-match`、分类标题 `snippet-category:<分类 id>` 与未分类的 `snippet-category:none`(`aria_label` 形如「Docker 2」,搜索时数的是匹配的),卡片 `("snippet", 片段 id)`(`aria_label` 形如「容器 · docker ps -a」,点击时自动执行的后面再加「 · 点击时自动执行」)与它的执行按钮 `("snippet-run", 片段 id)`(指针不在卡片上时 `visible()` 为假、点不到:先把指针移到卡片上再点,`snippets.rs` 的 `point_at`);对话框的 `snippet-name`、`snippet-category`(包着下拉的 `div`)、`snippet-command`(包着 `Textarea` 的 `div`,点它再输入)、勾选框 `snippet-run-on-click`、分类对话框的 `snippet-category-name`。对话框关掉后焦点是空的,接着 `window.dispatch_action` 之前先点一下面板(`snippets.rs` 的 `dispatch_from_panel`);假终端的 shell 要先跑起来(`remote_lifecycle(..).accepts_input()`),点片段才有输入。历史命令的有:面板 `history`、汇总 `history-summary`(「共 3 条 · ~/.bash_history」)、`history-refresh`、说明 `history-note`、`history-truncated`、`history-search`、列表 `history-list`、`history-empty`、`history-loading`、问题说明 `history-message`(没连接、没有历史文件、不支持、读取失败)、卡片 `history-entry:<命令>`(`aria_label` 形如「ls -l · 3 分钟前 · 执行 2 次」)与它的执行按钮 `history-run:<命令>`;点了之后查 `FakeTerminalFactory::written_text()` 新增的那一段(「\x05\x15ls -l」,执行的末尾多一个 `\r`)。系统监控的有:面板 `monitor`、展开按钮 `monitor-cores-toggle` / `monitor-interfaces-toggle`(`checked()` 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 `monitor-loading`、问题说明 `monitor-message`(`aria_label` 是文字:没连接、不支持、读取失败)、`monitor-host` / `monitor-arch` / `monitor-os` / `monitor-uptime`(`aria_label` 是值,运行时长形如「62 天 00:39:24」)、`monitor-cpu-usage`(`aria_label` 形如「50.0%」,第一次读数时是「—」)、每核 `monitor-cpu-cores`(多于 1 核就有,`aria_label` 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 `("monitor-core", 序号)`(`sixty_four_cores_show_one_row_until_unfolded` 量它们的位置;收起时一行的个数要等第二帧,测试里先 `simulate_next_frame`)、`monitor-memory-usage` / `monitor-memory-amount`(「421.02 MB / 973.25 MB」)、有交换空间时的 `monitor-swap-usage` / `monitor-swap-amount`、网卡 `monitor-interface:<名称>`(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 `monitor-disk:<挂载点>`(「35.1%,3.09 GB / 9.21 GB」)。网络连接的有:面板 `netstat`、汇总 `netstat-summary`(`aria_label` 形如「共 5 条 · 3 个监听端口 · 1 条已连接」)、`netstat-refresh`、`netstat-search`、下拉 `netstat-protocol` / `netstat-state`(UI 测试不点下拉,筛选由 `model.rs` 的单元测试覆盖)、有搜索或筛选时的 `netstat-matches`(「筛选出 2 条」)、列表 `netstat-list`、一行都没有时的 `netstat-empty`、读到第一次之前的 `netstat-loading`、问题说明 `netstat-message`(没连接、不支持、读取失败)、`netstat-truncated`、看不全进程时的 `netstat-processes`(不是 root,或主机没有 `ss`)、卡片 `netstat-socket:<协议> <本地> <远端>`(如 `netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*`,`aria_label` 形如「TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root」,没有进程或用户的写「—」;列表是 `uniform_list`,只有看得见的卡片在元素树里)。进程管理的有:面板 `processes`、汇总 `processes-summary`(「共 3 个进程 · 每 15 秒刷新」)、`processes-refresh`、`processes-search`、排序按钮 `processes-sort-memory` / `processes-sort-cpu`(`checked()` 是正按它排的那个)、当前列出的个数 `processes-count`(`aria_label` 是数字)、列表 `processes-list`、一个都没有时的 `processes-empty`、`processes-loading`、`processes-message`、`processes-truncated`、卡片 `("process", PID)`(`aria_label` 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 `bounds` 看顺序);结束进程直接派发 `EndProcess { pid, force }`,确认框点 `ok`。详情对话框:正文 `process-details`、每项 `process-field:<标签>`(如 `process-field:父进程`,`aria_label` 是值)、命令行 `process-command`(`aria_label` 是整行)与读取中的 `process-command-reading`、读不到时的 `process-command-unknown`、按钮 `process-copy-command` / `process-copy-pid` / `process-end` / `process-force-end`;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。系统服务的有:面板 `services`、汇总 `services-summary`(「systemd 249 · 降级运行」)、`services-refresh`、`services-search`、标签栏外包的 `services-tabs`(`within("services-tabs").click(3usize)` 是「失败」,`aria_label` 形如「失败 1」)、列表 `services-list`、分组标题 `services-group:custom` / `services-group:system`(`aria_label` 是「自定义服务 1」)、卡片 `service:<名字>`(`aria_label` 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 `service-stop:<名字>` / `service-restart:<名字>` / `service-start:<名字>` / `service-more:<名字>`、`services-empty`、`services-loading`、`services-message`;详情的正文 `service-details`、标签栏 `service-tabs`(`1usize` 是日志)、每项 `service-field:<标签>`、日志 `service-journal`(`aria_label` 是全文)与读取中的 `service-status-reading` / `service-journal-reading`、读不到的 `service-status-unknown` / `service-journal-unknown`、底部按钮 `service-dialog-<动词>`(`stop` / `restart` / `start` / `enable` / `disable`);假终端按顺序回答列表、`@@status 0`、状态、日志。Docker 的有:面板 `docker`、汇总 `docker-summary`(「Docker 26.0.0 · Compose v2.25.0」)、`docker-refresh`、标签栏外包的 `docker-tabs`(序号 0–3 是容器、卷、镜像、网络,`aria_label` 形如「容器 4」)、列表 `docker-list`、项目卡片 `docker-project:<项目>`(`aria_label` 形如「php-56 · 1/1 运行中」)与点了折叠展开的那一行 `docker-project-line:<项目>`、容器行 `docker-container:<名字>`(`aria_label` 形如「php56 · 运行中 · 56000→9000/tcp」)、按钮 `docker-stop:<名字>` / `docker-start:<名字>` / `docker-restart:<名字>`(项目的是 `docker-stop:project:<项目>`)/ `docker-more:<名字>`、卡片 `docker-volume:<名字>` / `docker-image:<键>`(`仓库:标签`,没有标签的是镜像 ID)/ `docker-network:<名字>` 与它们的删除按钮 `<卡片 id>-remove`、`docker-empty`、`docker-loading`、`docker-message`;详情的标签栏 `container-tabs`、正文 `container-details`、各行 `container-basics:<标签>` / `container-ports:<端口>` / `container-mounts:<来源>` / `container-labels:<键>`、`container-environment`、日志 `container-output`、底部 `container-dialog-stop` / `-start` / `-restart` / `-remove`;卷、镜像、网络的详情正文 `docker-details`(读取中 `docker-details-reading`、读不到 `docker-details-unknown`),各行同上换前缀 `volume-` / `image-` / `network-`,底部 `docker-dialog-remove`。假终端 `FakeTerminalFactory::answering(&[输出…])` 依次回答旁路命令(最后一个重复,不看命令内容),`exec_commands()` / `exec_count()` 是执行过的命令,没给输出就丢掉请求、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`(对话框的校验错误,在错误通知里,`aria_label` 是错误文字)、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-ready`(已下载好时的那句)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `open-changelog`(点了查 `cx.opened_url()`)/ `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`editor`(内置编辑器:打开、保存、覆盖、关闭和退出的确认)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `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-` 与 `skill-status-`(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-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`recent-quick-connect`(快速连接:列表容器 `quick-connect`,每行 `("quick-connect-host", 主机 id)`(`aria_label` 形如「db-01 · 数据库 · root@10.0.0.2:22」,`aria_selected` 是被 Tab / ⌘A 选中,多选关着时总是假),搜不到或没有主机时 `quick-connect-empty`;打开后搜索框已有焦点,直接 `input`、`press`)、`host-search`、`new-host-panel`(主机列表标题栏的「+」,打开新建主机对话框;标题栏右上角的按钮是临时连接 `temporary-connection`,它的对话框字段 id 同主机对话框,另有说明 `temporary-note`)、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-services` / `tool-processes` / `tool-connections` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在);当前标签不是远程终端时 `tool-switch` 也不存在。命令片段的有:面板 `snippets`、汇总 `snippets-summary`(「共 4 个片段 · 2 个分类」,没有分类时只有前半)、按钮 `snippets-new` / `snippets-new-category`、`snippets-search`、列表 `snippets-list`、一个片段都没有时的 `snippets-empty` 与其中的 `snippets-empty-new`、搜不到时的 `snippets-no-match`、分类标题 `snippet-category:<分类 id>` 与未分类的 `snippet-category:none`(`aria_label` 形如「Docker 2」,搜索时数的是匹配的),卡片 `("snippet", 片段 id)`(`aria_label` 形如「容器 · docker ps -a」,点击时自动执行的后面再加「 · 点击时自动执行」)与它的执行按钮 `("snippet-run", 片段 id)`(指针不在卡片上时 `visible()` 为假、点不到:先把指针移到卡片上再点,`snippets.rs` 的 `point_at`);对话框的 `snippet-name`、`snippet-category`(包着下拉的 `div`)、`snippet-command`(包着 `Textarea` 的 `div`,点它再输入)、勾选框 `snippet-run-on-click`、分类对话框的 `snippet-category-name`。对话框关掉后焦点是空的,接着 `window.dispatch_action` 之前先点一下面板(`snippets.rs` 的 `dispatch_from_panel`);假终端的 shell 要先跑起来(`remote_lifecycle(..).accepts_input()`),点片段才有输入。历史命令的有:面板 `history`、汇总 `history-summary`(「共 3 条 · ~/.bash_history」)、`history-refresh`、说明 `history-note`、`history-truncated`、`history-search`、列表 `history-list`、`history-empty`、`history-loading`、问题说明 `history-message`(没连接、没有历史文件、不支持、读取失败)、卡片 `history-entry:<命令>`(`aria_label` 形如「ls -l · 3 分钟前 · 执行 2 次」)与它的执行按钮 `history-run:<命令>`;点了之后查 `FakeTerminalFactory::written_text()` 新增的那一段(「\x05\x15ls -l」,执行的末尾多一个 `\r`)。系统监控的有:面板 `monitor`、展开按钮 `monitor-cores-toggle` / `monitor-interfaces-toggle`(`checked()` 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 `monitor-loading`、问题说明 `monitor-message`(`aria_label` 是文字:没连接、不支持、读取失败)、`monitor-host` / `monitor-arch` / `monitor-os` / `monitor-uptime`(`aria_label` 是值,运行时长形如「62 天 00:39:24」)、`monitor-cpu-usage`(`aria_label` 形如「50.0%」,第一次读数时是「—」)、每核 `monitor-cpu-cores`(多于 1 核就有,`aria_label` 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 `("monitor-core", 序号)`(`sixty_four_cores_show_one_row_until_unfolded` 量它们的位置;收起时一行的个数要等第二帧,测试里先 `simulate_next_frame`)、`monitor-memory-usage` / `monitor-memory-amount`(「421.02 MB / 973.25 MB」)、有交换空间时的 `monitor-swap-usage` / `monitor-swap-amount`、网卡 `monitor-interface:<名称>`(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 `monitor-disk:<挂载点>`(「35.1%,3.09 GB / 9.21 GB」)。网络连接的有:面板 `netstat`、汇总 `netstat-summary`(`aria_label` 形如「共 5 条 · 3 个监听端口 · 1 条已连接」)、`netstat-refresh`、`netstat-search`、下拉 `netstat-protocol` / `netstat-state`(UI 测试不点下拉,筛选由 `model.rs` 的单元测试覆盖)、有搜索或筛选时的 `netstat-matches`(「筛选出 2 条」)、列表 `netstat-list`、一行都没有时的 `netstat-empty`、读到第一次之前的 `netstat-loading`、问题说明 `netstat-message`(没连接、不支持、读取失败)、`netstat-truncated`、看不全进程时的 `netstat-processes`(不是 root,或主机没有 `ss`)、卡片 `netstat-socket:<协议> <本地> <远端>`(如 `netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*`,`aria_label` 形如「TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root」,没有进程或用户的写「—」;列表是 `uniform_list`,只有看得见的卡片在元素树里)。进程管理的有:面板 `processes`、汇总 `processes-summary`(「共 3 个进程 · 每 15 秒刷新」)、`processes-refresh`、`processes-search`、排序按钮 `processes-sort-memory` / `processes-sort-cpu`(`checked()` 是正按它排的那个)、当前列出的个数 `processes-count`(`aria_label` 是数字)、列表 `processes-list`、一个都没有时的 `processes-empty`、`processes-loading`、`processes-message`、`processes-truncated`、卡片 `("process", PID)`(`aria_label` 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 `bounds` 看顺序);结束进程直接派发 `EndProcess { pid, force }`,确认框点 `ok`。详情对话框:正文 `process-details`、每项 `process-field:<标签>`(如 `process-field:父进程`,`aria_label` 是值)、命令行 `process-command`(`aria_label` 是整行)与读取中的 `process-command-reading`、读不到时的 `process-command-unknown`、按钮 `process-copy-command` / `process-copy-pid` / `process-end` / `process-force-end`;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。系统服务的有:面板 `services`、汇总 `services-summary`(「systemd 249 · 降级运行」)、`services-refresh`、`services-search`、标签栏外包的 `services-tabs`(`within("services-tabs").click(3usize)` 是「失败」,`aria_label` 形如「失败 1」)、列表 `services-list`、分组标题 `services-group:custom` / `services-group:system`(`aria_label` 是「自定义服务 1」)、卡片 `service:<名字>`(`aria_label` 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 `service-stop:<名字>` / `service-restart:<名字>` / `service-start:<名字>` / `service-more:<名字>`、`services-empty`、`services-loading`、`services-message`;详情的正文 `service-details`、标签栏 `service-tabs`(`1usize` 是日志)、每项 `service-field:<标签>`、日志 `service-journal`(`aria_label` 是全文)与读取中的 `service-status-reading` / `service-journal-reading`、读不到的 `service-status-unknown` / `service-journal-unknown`、底部按钮 `service-dialog-<动词>`(`stop` / `restart` / `start` / `enable` / `disable`);假终端按顺序回答列表、`@@status 0`、状态、日志。Docker 的有:面板 `docker`、汇总 `docker-summary`(「Docker 26.0.0 · Compose v2.25.0」)、`docker-refresh`、标签栏外包的 `docker-tabs`(序号 0–3 是容器、卷、镜像、网络,`aria_label` 形如「容器 4」)、列表 `docker-list`、项目卡片 `docker-project:<项目>`(`aria_label` 形如「php-56 · 1/1 运行中」)与点了折叠展开的那一行 `docker-project-line:<项目>`、容器行 `docker-container:<名字>`(`aria_label` 形如「php56 · 运行中 · 56000→9000/tcp」)、按钮 `docker-stop:<名字>` / `docker-start:<名字>` / `docker-restart:<名字>`(项目的是 `docker-stop:project:<项目>`)/ `docker-more:<名字>`、卡片 `docker-volume:<名字>` / `docker-image:<键>`(`仓库:标签`,没有标签的是镜像 ID)/ `docker-network:<名字>` 与它们的删除按钮 `<卡片 id>-remove`、`docker-empty`、`docker-loading`、`docker-message`;详情的标签栏 `container-tabs`、正文 `container-details`、各行 `container-basics:<标签>` / `container-ports:<端口>` / `container-mounts:<来源>` / `container-labels:<键>`、`container-environment`、日志 `container-output`、底部 `container-dialog-stop` / `-start` / `-restart` / `-remove`;卷、镜像、网络的详情正文 `docker-details`(读取中 `docker-details-reading`、读不到 `docker-details-unknown`),各行同上换前缀 `volume-` / `image-` / `network-`,底部 `docker-dialog-remove`。假终端 `FakeTerminalFactory::answering(&[输出…])` 依次回答旁路命令(最后一个重复,不看命令内容),`exec_commands()` / `exec_count()` 是执行过的命令,没给输出就丢掉请求、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`(对话框的校验错误,在错误通知里,`aria_label` 是错误文字)、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-ready`(已下载好时的那句)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `open-changelog`(点了查 `cx.opened_url()`)/ `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。编辑器相关的有:标签 `("editor", id)`(第一个编辑器是 `EditorId(1)`)、标签标题 `("editor-tab", id)`(`label` 是文件名)与其中有修改时才有的圆点 `modified`(`within(("editor-tab", id)).try_find("modified")`)、关闭按钮 `("close-editor", id)`、工具栏的 `("editor-path", id)`(`label` 是「db-01:/home/tester/文件 甲.txt」或本地路径)、`("editor-state", id)`(「已修改」「正在保存…」「正在重新加载…」,没事时不存在)、`("editor-reload", id)`、`("editor-save", id)`、包着 `Editor` 的 `("editor-text", id)`,窗口状态栏右边的 `status-editor-cursor`(「行 1,列 1」)和 `status-editor-format`(「UTF-8 · LF」),关闭问题的 `editor-close-cancel` / `editor-close-discard` / `editor-close-save`,其余确认框是 `ok` / `cancel`。编辑器打开后焦点在文本里,直接 `window.input` 打字(光标在开头)、`press("cmd-s")` 保存;文本经 `workspace.editor(id)` 的 `state().read(cx).value()` 读。假服务器 `FakeSftpProvider::with_files(&[(路径, 字节)])` 放文件(修改时间 100,每次保存加 1,`change_file` 模拟别人改过,`fake_stamp` 算戳),`reads` / `writes` 记下编辑器的读写;`/slow` 下的路径读了永远不回答;`Operate(CreateFile)` 也会放一个空文件。写入之后 SFTP 标签要 16 ms 一轮才收到回答,测试推进时钟(`editor.rs` 的 `wait_for_writes`)。`FakeLocalDirectory::files` 是本地文件,写入只改内存并记一条「write 路径」。退出用 `cx.update(|cx| cx.dispatch_action(&Quit))`(测试平台的 `quit` 什么也不做)。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/Cargo.lock b/Cargo.lock index f255f58..a3e5a03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1060,9 +1060,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.5" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -2929,6 +2929,22 @@ dependencies = [ "smallvec", "smol", "tracing", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-go", + "tree-sitter-html", + "tree-sitter-javascript", + "tree-sitter-json", + "tree-sitter-lua", + "tree-sitter-make", + "tree-sitter-md", + "tree-sitter-php", + "tree-sitter-python", + "tree-sitter-sequel", + "tree-sitter-toml-ng", + "tree-sitter-yaml", "uuid", "windows 0.58.0", ] @@ -8027,6 +8043,12 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -8349,7 +8371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -8794,6 +8816,176 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "tree-sitter" +version = "0.26.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-diff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" + +[[package]] +name = "tree-sitter-lua" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-sequel" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.16" diff --git a/Cargo.toml b/Cargo.toml index fe004ab..f12e933 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/README.en.md b/README.en.md index c489550..31784b2 100644 --- a/README.en.md +++ b/README.en.md @@ -62,6 +62,7 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database. - Upload and download by dragging or by shortcut, recursively. Start new transfers while one runs; they wait in a per-tab transfer queue. - Resumable transfers: data goes to a `.filepart` first, dropped connections are retried after 1, 3 and 10 seconds, and after a restart ShellRS offers to resume the same transfer. - Delete (local items go to the Trash), rename, create, and change permissions (a 3×3 grid plus octal, optionally recursive). +- Built-in editor: double-click a text file to edit it, ⌘S writes it back in place, after checking nobody else changed it; syntax highlighting for common formats. - Each SFTP tab has its own connection and asks only for the SFTP subsystem, so no shell is needed on the server. **Opening from a bastion host** diff --git a/README.md b/README.md index b85ed24..e287af4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF - 拖动或快捷键上传、下载,支持递归。传输进行中还能继续发起,新批次进传输队列依次执行。 - 断点续传:先写 `.filepart`,断线后按 1、3、10 秒自动重连;重启后再传同样的文件,会询问是否续传。 - 删除(本地移到废纸篓)、重命名、新建、修改权限(3×3 复选框加八进制,可递归)。 +- 内置编辑器:双击文本文件直接编辑,⌘S 原地写回服务器,保存前检查文件是否被别人改过;常见格式语法高亮。 - 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。 **从堡垒机打开** diff --git a/docs/manual.md b/docs/manual.md index 86a948e..2f5913f 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -265,7 +265,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以 - 框选:在名称右边的空白、大小、类型、时间等列或列表下方按下拖动,画出选框,框住的行被选中;按住 ⌘ 或 Shift 开始拖动则加到已有选择上。拖到列表上下边缘时自动滚动。拖动文件名才是把文件拖到另一侧传输:按下未选中的文件名时只选中它(其他选择取消),拖走的就是它一个;按下已选中的文件名则拖走全部已选。拖动时只有到了另一侧列表上才显示「上传 / 下载 N 个项目」,在自己的列表或其他不能放下的地方,指针显示为禁止。 - 键盘:↑↓、Home、End、PageUp、PageDown 移动,加 Shift 扩展;空格或 Insert 切换当前行;⌘A 全选;Tab 切到另一侧。 - 右键未选中的名称格会先选中它;右键空白处取消选择并弹出空白处菜单。 - - 文件名上的菜单:打开、上传… / 下载…、删除、重命名…、属性…。 + - 文件名上的菜单:打开(目录)或编辑(文件)、上传… / 下载…、删除、重命名…、属性…。 - 空白处的菜单:前往(上级 / 根 / 主目录 / 后退 / 前进)、刷新、添加路径到书签、新建。 ### 快捷键 @@ -277,13 +277,14 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以 | 新建文件夹 | F7 | | 删除 | F8、Delete;macOS 另有 ⌘⌫ | | 属性 | F9;macOS 另有 ⌘I | +| 编辑文件 | F4 | | 刷新 | ⌘R / Ctrl+R | | 上级目录 | Backspace;macOS 另有 ⌘↑ | | 根目录 | ⌘\ / Ctrl+\ | | 主目录 | macOS ⌘⇧H,其他平台 Ctrl+H | | 后退 / 前进 | ⌥← / ⌥→;macOS 另有 ⌘[ / ⌘] | | 添加路径到书签 | ⌘D / Ctrl+D | -| 打开目录 | Enter;macOS 另有 ⌘↓ | +| 打开目录 / 编辑文件 | Enter;macOS 另有 ⌘↓ | | 打开目录/书签 | ⌘O / Ctrl+O;macOS 另有 ⌘⇧G | ### 文件操作 @@ -302,6 +303,18 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以 - 含目录时可以递归应用;递归时默认「为目录添加执行权限(X)」,避免目录无法进入。 - 符号链接不参与修改权限。 +### 编辑文件 + +文本文件可以直接在 ShellRS 里改,远程和本地的都行(与 WinSCP 的内置编辑器相同): + +- 打开:双击文件、选中后回车或按 F4、右键「编辑」。「新建 › 文件…」建好后也直接打开。文件在中间区的「编辑器」标签里打开,和终端、SFTP 一样可以拖成分栏;同一个文件只开一个标签,再打开就切到它。 +- 编辑器有行号、查找替换(⌘F / Ctrl+F,替换 ⌘⇧F / Ctrl+H),按文件名为 Shell、YAML、TOML、JSON、Python、JavaScript、HTML、CSS、Markdown、SQL、PHP、Go、Lua、Makefile、Diff 着色,其他文件是纯文本。Makefile 和用 Tab 缩进的文件按 Tab 输入制表符。 +- 保存:⌘S / Ctrl+S 或标签上方的「保存」。远程文件经打开它的 SFTP 标签的连接直接写回原文件(原地改写),所有者、权限和指向它的链接都不变。保存前会核对文件的大小和修改时间:打开后被别人改过或删掉了,会先问是否「覆盖」,不会悄悄覆盖。写到一半连接断开时,服务器上的文件可能只写了一部分,编辑器里的内容还在,重新连接后再保存一次即可。 +- 「重新加载」从服务器或磁盘重新读一遍;有没保存的修改时先确认。 +- 标签上的圆点表示有没保存的修改。关闭这样的标签会问「保存」「放弃修改」还是「取消」;一次关闭多个标签、关闭 SFTP 标签(它打开的远程文件会一起关掉)或退出 ShellRS 时,有没保存的文件会先问一次。macOS 上从程序坞退出和注销关机不会询问。 +- 窗口左下角是这个文件所走的 SFTP 连接(本地文件写「本机文件」),右下角是光标位置和编码、换行方式(如「UTF-8 · LF」)。 +- 限制:只打开 5 MB 以内的 UTF-8 文本文件(带不带 BOM 都可以,保存时原样保留 BOM 和 CRLF 换行)。更大的文件、二进制文件和 GBK 等其他编码的文件会说明原因、不打开,远程文件可以改用「下载…」。 + ### 上传与下载 - 入口: diff --git a/src/app/actions.rs b/src/app/actions.rs index 76a5282..ebdd656 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -6,6 +6,7 @@ use gpui_kit::*; use crate::{ cli::AgentKind, docker::{ContainerCommand, DockerObject}, + editor::EditorId, explorer::{ExplorerId, FileSizeFormat}, host::{ CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop, SnippetCategoryId, SnippetId, @@ -197,6 +198,9 @@ id_actions! { /// Close one SFTP tab. CloseExplorer(ExplorerId); + + /// Close one editor tab, asking first when it has unsaved changes. + CloseEditor(EditorId); /// Open the dialog that gives one SFTP tab its own title. RenameExplorer(ExplorerId); @@ -338,6 +342,7 @@ pub enum CenterTab { LocalTerminal(LocalTerminalId), /// There is at most one settings tab. Settings, + Editor(EditorId), } /// A tool of the right sidebar, in the order of the switch beside it. @@ -423,10 +428,17 @@ pub enum ExplorerCommand { Forward { remote: bool, }, - /// Enter or double-click: open the directory under the cursor. + /// Enter or double-click: open the directory under the cursor, or edit + /// the file there. Open { remote: bool, }, + /// F4 and 编辑: open a file in the editor, `path` or else the one under + /// the cursor (WinSCP's Edit). + Edit { + remote: bool, + path: Option, + }, /// WinSCP's 打开目录/书签 dialog: type a directory or pick a bookmark. OpenDirectory { remote: bool, @@ -555,6 +567,50 @@ impl ExplorerAction { &self.command } } +/// Commands of an editor tab, shared by its toolbar, tab menu, dialogs and +/// key bindings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditorCommand { + /// ⌘S: write the file back, unless it changed since it was read. + Save, + /// 覆盖: write it back although it changed; `close` once that worked. + Overwrite { + close: bool, + }, + /// Read the file again, asking first when there are changes. + Reload, + /// Read it again, throwing the changes away. + ReloadConfirmed, + /// 保存 in the close question: save, then close if that worked. + SaveAndClose, + /// 放弃修改 in the close question: close without saving. + CloseConfirmed, + CopyPath, +} + +#[derive(Action, Clone, PartialEq, Eq)] +#[action(namespace = shellrs, no_json)] +pub struct EditorAction { + editor: EditorId, + command: EditorCommand, +} +impl EditorAction { + pub fn new(editor: EditorId, command: EditorCommand) -> Self { + Self { editor, command } + } + pub fn editor(&self) -> EditorId { + self.editor + } + pub fn command(&self) -> EditorCommand { + self.command + } +} +/// An editor key binding; the workspace sends it to the editor that holds +/// focus. +#[derive(Action, Clone, PartialEq, Eq)] +#[action(namespace = shellrs, no_json)] +pub struct EditorShortcut(pub EditorCommand); + /// A file-list key binding. The binding carries the pane side, and the /// workspace sends it to the explorer that holds focus. #[derive(Action, Clone, PartialEq, Eq)] diff --git a/src/app/assets.rs b/src/app/assets.rs index 2143376..cbd4ccb 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -65,6 +65,8 @@ icon_assets!( Ban, CircleDot, FileText, + FilePenLine, + Save, Zap, ExternalLink ] diff --git a/src/app/mod.rs b/src/app/mod.rs index e4b68e2..e7e1435 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,6 +4,7 @@ mod actions; mod app_icon; mod assets; mod paths; +mod quit; mod window_hiding; pub use actions::*; @@ -12,6 +13,7 @@ pub use assets::{AppAssets, CatalogIcon, DOCKER_ICON}; pub use paths::{ cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path, updates_dir, }; +pub use quit::{quit_held_back, set_quit_guard}; pub use window_hiding::{bring_forward, hide_when_closed}; use gpui_kit::component::{Theme, dock::ToggleZoom}; @@ -27,6 +29,8 @@ pub const FORWARD_PANEL_CONTEXT: &str = "ForwardPanel"; /// Key context of the credential list. pub const CREDENTIAL_PANEL_CONTEXT: &str = "CredentialPanel"; /// Key contexts of the two SFTP file lists. +/// Key context of an editor tab. +pub const EDITOR_CONTEXT: &str = "FileEditor"; pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList"; pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList"; @@ -36,7 +40,7 @@ pub fn init(cx: &mut App) { deepen_list_hover(cx); gpui_kit::component::set_locale("zh-CN"); cx.bind_keys(key_bindings()); - cx.on_action(|_: &Quit, cx: &mut App| cx.quit()); + cx.on_action(|_: &Quit, cx: &mut App| quit::quit(cx)); } /// Use a stronger version of the theme's neutral list hover color throughout @@ -80,6 +84,11 @@ fn key_bindings() -> Vec { KeyBinding::new(&primary("-"), ZoomOut, None), KeyBinding::new(&primary("0"), ZoomReset, None), KeyBinding::new(&primary("q"), Quit, None), + KeyBinding::new( + &primary("s"), + EditorShortcut(EditorCommand::Save), + Some(EDITOR_CONTEXT), + ), KeyBinding::new("shift-escape", ToggleZoom, None), KeyBinding::new("enter", ConnectSelected, Some(HOST_PANEL_CONTEXT)), KeyBinding::new("enter", ConnectSelected, Some(RECENT_HOSTS_CONTEXT)), @@ -207,6 +216,8 @@ fn file_list_key_bindings() -> Vec { 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", diff --git a/src/app/quit.rs b/src/app/quit.rs new file mode 100644 index 0000000..c8038c7 --- /dev/null +++ b/src/app/quit.rs @@ -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 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::() 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::().map(|guard| guard.ask.clone()) { + Some(ask) => ask(window, cx), + None => false, + } +} diff --git a/src/app/window_hiding.rs b/src/app/window_hiding.rs index 10a0faa..4148992 100644 --- a/src/app/window_hiding.rs +++ b/src/app/window_hiding.rs @@ -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. diff --git a/src/editor/editor_panel.rs b/src/editor/editor_panel.rs new file mode 100644 index 0000000..e20f0a6 --- /dev/null +++ b/src/editor/editor_panel.rs @@ -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, + 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, + state: Entity, + /// 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, + 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>, + _task: Option>, + _subscriptions: Vec, +} + +impl EditorPanel { + #[allow(clippy::too_many_arguments)] + pub fn new( + id: EditorId, + source: EditorSource, + location: FileLocation, + file: TextFile, + store: Entity, + dispatch: FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> 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 { + match &self.source { + EditorSource::Remote { explorer_id, .. } => Some(*explorer_id), + EditorSource::Local { .. } => None, + } + } + pub fn host_id(&self) -> Option { + 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 { + &self.state + } + pub fn tab_group(&self) -> Option> { + 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) { + 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) { + 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) { + 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, + window: &mut Window, + cx: &mut Context, + ) { + 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) { + 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, + window: &mut Window, + cx: &mut Context, + ) { + 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) { + 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) -> 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 for EditorPanel {} +impl EventEmitter 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) { + 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, _: &mut Window, _: &mut Context) { + self.tab_group = Some(group); + } + + fn on_removed(&mut self, _: &mut Window, cx: &mut Context) { + 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) -> 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, + ) -> 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>, + 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) -> 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())), + ), + ) + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs new file mode 100644 index 0000000..7c9b76a --- /dev/null +++ b/src/editor/mod.rs @@ -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::*; diff --git a/src/editor/model.rs b/src/editor/model.rs new file mode 100644 index 0000000..4072087 --- /dev/null +++ b/src/editor/model.rs @@ -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" + ); + } +} diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index 7c22d9a..2156a73 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -1,6 +1,6 @@ use super::{ - ExplorerId, FilePane, FilePaneEvent, LoadIntent, PaneSide, QueueId, QueueState, Removal, - TransferJob, TransferQueue, pane_operations::PendingOperation, + ExplorerId, FileLocation, FilePane, FilePaneEvent, LoadIntent, PaneSide, QueueId, QueueState, + Removal, TransferJob, TransferQueue, file_edit::PendingFile, pane_operations::PendingOperation, }; use crate::app::ExplorerDispatch as _; use crate::{ @@ -12,7 +12,7 @@ use crate::{ host::{BookmarkSide, ConnectionState, HostId, HostStore}, sftp::{ DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider, - SharedSftpTransportProvider, TransferDirection, TransferPhase, TransferQuestion, + SharedSftpTransportProvider, TextFile, TransferDirection, TransferPhase, TransferQuestion, UploadRequest, }, shared::{ClosableTabTitle, HostMark, LatencyLabel, RenamableTab, close_tab_items}, @@ -29,7 +29,8 @@ use gpui_kit::component::{ }; use gpui_kit::*; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, + rc::Rc, sync::atomic::{AtomicU64, Ordering}, time::Duration, }; @@ -43,6 +44,8 @@ pub enum ExplorerPanelEvent { /// connection changing: a directory could not be read, or can again. StatusChanged(ExplorerId), PromptRequested(ExplorerId, HostId, u64, ConnectionPrompt), + /// A file was read for the editor and can be edited. + EditFile(ExplorerId, FileLocation, Rc), } pub struct ExplorerPanel { id: ExplorerId, @@ -56,6 +59,10 @@ pub struct ExplorerPanel { /// File operations waiting for their result, by request id. pub(super) operations: HashMap, 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, + pub(super) opening: HashSet, commands: async_channel::Sender, state: ConnectionState, /// The connection's latest round trip; none until the first one after @@ -212,6 +219,8 @@ impl ExplorerPanel { local_provider, operations: HashMap::new(), next_operation: 0, + files: HashMap::new(), + opening: HashSet::new(), commands, state: ConnectionState::Connecting, latency: None, @@ -280,7 +289,7 @@ impl ExplorerPanel { /// Tell the user the remote side is disconnected, and why, with 重新连接 /// right there. When the queue owns reconnecting (a stopped batch waits /// for 继续, or a transfer is reconnecting by itself), say so instead. - fn offer_reconnect(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn offer_reconnect(&mut self, window: &mut Window, cx: &mut Context) { if window.has_active_dialog(cx) { return; } @@ -479,6 +488,10 @@ impl ExplorerPanel { SftpEvent::Operated { request_id, result } => { self.finish_operation(request_id, result, window, cx) } + SftpEvent::FileRead { request_id, result } => self.finish_file_read(request_id, result), + SftpEvent::FileWritten { request_id, result } => { + self.finish_file_write(request_id, result) + } SftpEvent::Progress(progress) => { let complete = progress.phase() == TransferPhase::Completed; let direction = progress.direction(); @@ -636,15 +649,36 @@ impl ExplorerPanel { } ExplorerCommand::Open { remote } => { let pane = self.pane(*remote).clone(); - if let Some(entry) = pane.read(cx).cursor_entry(cx) - && entry.is_dir() - { + let Some(entry) = pane.read(cx).cursor_entry(cx) else { + return; + }; + if entry.is_dir() { let path = if entry.is_parent() { pane.read(cx).parent_path() } else { pane.read(cx).child_path_of(&entry.name) }; self.navigate(*remote, path, LoadIntent::Visit, window, cx); + } else { + // A file opens in the editor, through the workspace, which + // shows the tab when the file is open already. + let path = pane.read(cx).child_path_of(&entry.name); + self.dispatch.dispatch_explorer_action( + &ExplorerAction::new( + self.id, + ExplorerCommand::Edit { + remote: *remote, + path: Some(path), + }, + ), + window, + cx, + ); + } + } + ExplorerCommand::Edit { remote, path } => { + if let Some(location) = self.edit_target(*remote, path.as_deref(), cx) { + self.open_for_edit(location, window, cx); } } ExplorerCommand::OpenDirectory { remote } => { @@ -925,6 +959,7 @@ fn needs_connection(command: &ExplorerCommand) -> bool { | C::Back { remote } | C::Forward { remote } | C::Open { remote } + | C::Edit { remote, .. } | C::OpenDirectory { remote } | C::Delete { remote } | C::Rename { remote } diff --git a/src/explorer/file_edit.rs b/src/explorer/file_edit.rs new file mode 100644 index 0000000..3afa033 --- /dev/null +++ b/src/explorer/file_edit.rs @@ -0,0 +1,330 @@ +//! Files for the editor: an SFTP tab reads them for it and writes the remote +//! ones back over its own connection, so editing never logs in again. +//! +//! A file is read before any editor tab exists: one that cannot be edited +//! (too large, binary, not UTF-8) is explained here and never flashes a tab. + +use super::{ExplorerPanel, ExplorerPanelEvent, format_size}; +use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _}; +use crate::host::ConnectionState; +use crate::sftp::{ + EDIT_LIMIT, FileStamp, ReadFailure, RemotePath, SaveFailure, SftpCommand, TextFile, + read_local_text, +}; +use futures::channel::oneshot; +use gpui_kit::component::{WindowExt as _, dialog::DialogButtonProps, notification::Notification}; +use gpui_kit::*; +use std::path::PathBuf; +use std::rc::Rc; + +/// Where a file to edit lives: on the server of an SFTP tab, or here. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum FileLocation { + Remote(RemotePath), + Local(PathBuf), +} + +impl FileLocation { + pub fn is_remote(&self) -> bool { + matches!(self, FileLocation::Remote(_)) + } + /// The file's own name. + pub fn name(&self) -> String { + match self { + FileLocation::Remote(path) => path.file_name().to_string(), + FileLocation::Local(path) => path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + } + } + /// The full path, as a pane shows paths. + pub fn path(&self) -> String { + match self { + FileLocation::Remote(path) => path.to_string(), + FileLocation::Local(path) => path.to_string_lossy().into_owned(), + } + } + /// The directory the file is in, as a pane shows paths. + pub fn directory(&self) -> String { + match self { + FileLocation::Remote(path) => path.parent().to_string(), + FileLocation::Local(path) => path + .parent() + .map(|parent| parent.to_string_lossy().into_owned()) + .unwrap_or_default(), + } + } +} + +/// A read or a write waiting for the worker, answered from `on_event`. +pub(super) enum PendingFile { + Read(oneshot::Sender>), + Write(oneshot::Sender>), +} + +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 { + let pane = self.pane(remote).read(cx); + let path = match path { + Some(path) => path.to_string(), + None => { + let entry = pane.cursor_entry(cx)?; + if entry.is_dir() || entry.is_parent() { + return None; + } + pane.child_path_of(&entry.name) + } + }; + Some(if remote { + FileLocation::Remote(RemotePath::new(path).ok()?) + } else { + FileLocation::Local(PathBuf::from(path)) + }) + } + + /// Read a file for a new editor tab; `EditFile` brings it to the + /// workspace, a refusal is explained here. + pub(super) fn open_for_edit( + &mut self, + location: FileLocation, + window: &mut Window, + cx: &mut Context, + ) { + if !self.opening.insert(location.clone()) { + return; + } + let Some(read) = self.read_file(&location, window, cx) else { + self.opening.remove(&location); + return; + }; + let remote = location.is_remote(); + let name = location.name(); + self.pane(remote) + .clone() + .update(cx, |pane, cx| pane.begin_opening(name, cx)); + cx.spawn_in(window, async move |this, cx| { + let result = read.await; + let _ = this.update_in(cx, |this, window, cx| { + this.opening.remove(&location); + this.pane(remote) + .clone() + .update(cx, |pane, cx| pane.finish_opening(cx)); + match result { + Ok(file) => cx.emit(ExplorerPanelEvent::EditFile( + this.id(), + location, + Rc::new(file), + )), + Err(failure) => this.refuse_edit(&location, failure, window, cx), + } + }); + }) + .detach(); + } + + /// Read a whole text file. `None` when the remote side is not connected, + /// which this tab has already told the user about. + pub fn read_file( + &mut self, + location: &FileLocation, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + match location { + FileLocation::Local(path) => { + let (provider, path) = (self.local_provider.clone(), path.clone()); + Some(cx.background_spawn(async move { read_local_text(provider.as_ref(), &path) })) + } + FileLocation::Remote(path) => { + if !self.reachable(window, cx) { + return None; + } + let (sender, receiver) = oneshot::channel(); + let request_id = self.next_file_request(PendingFile::Read(sender)); + self.send(SftpCommand::ReadFile { + request_id, + path: path.clone(), + }); + Some(cx.spawn(async move |_, _| { + receiver + .await + .unwrap_or_else(|_| Err(ReadFailure::Failed("SFTP 连接已断开".into()))) + })) + } + } + } + + /// Write bytes over a remote file in place. `None` when not connected, + /// which this tab has already told the user about. + pub fn write_remote_file( + &mut self, + path: RemotePath, + bytes: Vec, + expected: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + 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, + ) { + 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) { + if let Some(PendingFile::Read(sender)) = self.files.remove(&id) { + let _ = sender.send(result); + } + } + + pub(super) fn finish_file_write(&mut self, id: u64, result: Result) { + 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) -> bool { + match self.connection_state() { + ConnectionState::Connected => true, + ConnectionState::Disconnected => { + self.offer_reconnect(window, cx); + false + } + ConnectionState::Connecting => { + window.push_notification(Notification::warning("正在连接 SFTP,请稍候再试。"), cx); + false + } + } + } + + fn next_file_request(&mut self, pending: PendingFile) -> u64 { + self.next_operation += 1; + self.files.insert(self.next_operation, pending); + self.next_operation + } + + /// Why a file was not opened. Too large, binary or not UTF-8 is a + /// decision to make (a remote file can still be downloaded); anything + /// else is an error. + fn refuse_edit( + &mut self, + location: &FileLocation, + failure: ReadFailure, + window: &mut Window, + cx: &mut Context, + ) { + let name = location.name(); + let reason = match &failure { + ReadFailure::TooLarge(size) => format!( + "文件有 {},编辑器只打开 {} 以内的文件。", + format_size(*size), + format_size(EDIT_LIMIT) + ), + ReadFailure::NotText => { + "它不是 UTF-8 编码的文本文件:可能是二进制文件,或用了 GBK 等其他编码。".into() + } + ReadFailure::NotFile => "它不是普通文件。".into(), + ReadFailure::Failed(message) => { + window.push_notification( + Notification::error(message.clone()).title(format!("无法打开“{name}”")), + cx, + ); + return; + } + }; + let download = match location { + FileLocation::Remote(path) => Some(ExplorerCommand::DownloadPaths { + paths: vec![path.to_string()], + target: self.local().read(cx).path(), + }), + FileLocation::Local(_) => None, + }; + let description = if download.is_some() { + format!("{reason}可以下载到本机,用别的程序打开。") + } else { + reason + }; + let (dispatch, id) = (self.dispatch.clone(), self.id()); + window.open_alert_dialog(cx, move |alert, _, _| { + let alert = alert + .title(format!("无法在编辑器中打开“{name}”")) + .description(description.clone()); + match download.clone() { + Some(download) => alert + .button_props( + DialogButtonProps::default() + .ok_text("下载…") + .cancel_text("取消"), + ) + .show_cancel(true) + .on_ok({ + let dispatch = dispatch.clone(); + move |_, window, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(id, download.clone()), + window, + cx, + ); + true + } + }), + None => alert.button_props(DialogButtonProps::default().ok_text("知道了")), + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::FileLocation; + use crate::sftp::RemotePath; + use std::path::PathBuf; + + #[test] + fn a_location_names_its_file_and_directory_as_a_pane_does() { + let remote = FileLocation::Remote(RemotePath::new("/etc/nginx/nginx.conf").unwrap()); + assert_eq!(remote.name(), "nginx.conf"); + assert_eq!(remote.directory(), "/etc/nginx"); + let top = FileLocation::Remote(RemotePath::new("/hosts").unwrap()); + assert_eq!(top.directory(), "/"); + let local = FileLocation::Local(PathBuf::from("/Users/me/notes.md")); + assert_eq!(local.name(), "notes.md"); + assert_eq!(local.directory(), "/Users/me"); + assert_eq!(local.path(), "/Users/me/notes.md"); + } +} diff --git a/src/explorer/file_pane.rs b/src/explorer/file_pane.rs index eec92f1..7552246 100644 --- a/src/explorer/file_pane.rs +++ b/src/explorer/file_pane.rs @@ -195,6 +195,10 @@ pub struct FilePane { /// The load in flight has taken long enough to say so. slow_load: bool, slow_load_timer: Option>, + /// A file being read for the editor, once that has taken long enough to + /// say so; the timer that decides. + opening: Option, + opening_timer: Option>, /// 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, @@ -278,6 +282,8 @@ impl FilePane { loading: false, slow_load: false, slow_load_timer: None, + opening: None, + opening_timer: None, error: None, listed: false, connection: if side == PaneSide::Local { @@ -652,6 +658,8 @@ impl FilePane { fn render_status(&self, file_count: usize, selected: usize, cx: &App) -> impl IntoElement { let status = if self.connection == ConnectionState::Connecting { "正在连接 SFTP…".to_string() + } else if let Some(name) = &self.opening { + format!("正在打开 {name}…") } else if self.slow_load { "正在读取目录…".to_string() } else if selected == 0 { @@ -781,6 +789,24 @@ impl FilePane { cx.notify(); self.request_id } + /// A file of this pane is being read for the editor: the status line + /// says so if it takes a while, like a slow directory. + pub fn begin_opening(&mut self, name: String, cx: &mut Context) { + self.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.opening_timer = None; + if self.opening.take().is_some() { + cx.notify(); + } + } fn finish_loading(&mut self) { self.loading = false; self.slow_load = false; @@ -905,6 +931,12 @@ impl FilePane { remote: self.is_remote(), explorer: self.explorer, opens_directory: entries.len() == 1 && entries[0].is_dir(), + edits_file: match entries.as_slice() { + [entry] if !entry.is_dir() && !entry.is_parent() => { + Some(self.child_path_of(&entry.name)) + } + _ => None, + }, targets, can_go_up: self.parent_path() != self.path, can_go_home: !self.home.is_empty() && self.home != self.path, diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs index aa36e0d..6a12904 100644 --- a/src/explorer/mod.rs +++ b/src/explorer/mod.rs @@ -2,6 +2,7 @@ mod explorer_panel; mod file_dialogs; +mod file_edit; mod file_listing; mod file_pane; mod history; @@ -16,6 +17,7 @@ mod selection; mod transfer_queue; pub use explorer_panel::{ExplorerPanel, ExplorerPanelEvent, ExplorerStatus}; +pub use file_edit::FileLocation; pub use file_listing::FileListing; pub use file_pane::{FilePane, FilePaneEvent, PaneSide}; pub use history::{LoadIntent, NavigationHistory}; diff --git a/src/explorer/pane_menu.rs b/src/explorer/pane_menu.rs index 872f464..bcb7d88 100644 --- a/src/explorer/pane_menu.rs +++ b/src/explorer/pane_menu.rs @@ -16,6 +16,9 @@ pub(super) struct PaneMenuState { pub targets: Vec, /// 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, pub can_go_up: bool, pub can_go_home: bool, pub can_go_back: bool, @@ -45,6 +48,16 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { state.action(ExplorerCommand::Open { remote }), ) .separator() + } else if let Some(path) = state.edits_file.clone() { + menu.menu_with_icon( + "编辑", + Icon::new(CatalogIcon::FilePenLine), + state.action(ExplorerCommand::Edit { + remote, + path: Some(path), + }), + ) + .separator() } else { menu }; diff --git a/src/explorer/pane_operations.rs b/src/explorer/pane_operations.rs index f5188d5..e664b6e 100644 --- a/src/explorer/pane_operations.rs +++ b/src/explorer/pane_operations.rs @@ -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, + /// A new file, to open in the editor once it exists (WinSCP does). + edit: Option, } 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.operations.retain(|_, pending| !pending.remote); + self.files.clear(); self.remote.update(cx, |pane, cx| pane.set_busy(false, cx)); } } diff --git a/src/explorer/transfer_dialog.rs b/src/explorer/transfer_dialog.rs index 734a690..efa9b15 100644 --- a/src/explorer/transfer_dialog.rs +++ b/src/explorer/transfer_dialog.rs @@ -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, 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}并关闭")) diff --git a/src/lib.rs b/src/lib.rs index f0bc2fc..dfecb28 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/sftp/client.rs b/src/sftp/client.rs index 8ef66bb..6d66f32 100644 --- a/src/sftp/client.rs +++ b/src/sftp/client.rs @@ -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>; async fn open(&self, path: &RemotePath, create: bool) -> Result; + /// 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; /// Open an existing file for reading. async fn open_read(&self, path: &RemotePath) -> Result; /// 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 { + 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 { Ok(self .raw diff --git a/src/sftp/edit.rs b/src/sftp/edit.rs new file mode 100644 index 0000000..6b82416 --- /dev/null +++ b/src/sftp/edit.rs @@ -0,0 +1,435 @@ +//! Whole text files for the editor: read into memory, written back in place. +//! +//! Unlike transfers there is no `.filepart` and no rename: a save truncates +//! the file and writes it again, as vim, nano and VS Code Remote do, so the +//! owner, the permissions, links pointing at it and hard links all stay. +//! What keeps a save from clobbering someone else's change is the stamp: the +//! size and modification time the file had when it was read or last saved. + +use super::{EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath, client::RemoteFs}; +use anyhow::{Result, anyhow, bail}; +use futures::{StreamExt as _, stream::FuturesUnordered}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// The largest file the editor opens. The code editor is meant for up to +/// about fifty thousand lines. +pub const EDIT_LIMIT: u64 = 5 * 1024 * 1024; + +const CHUNK: u32 = 32 * 1024; +const MAX_IN_FLIGHT: usize = 16; +/// A NUL byte this early means a binary file, as `grep` and git decide. +const BINARY_PROBE: usize = 8 * 1024; +const BOM: &[u8] = b"\xEF\xBB\xBF"; + +/// What a file looked like when it was read or saved: a save checks it is +/// still so, otherwise someone else changed the file in the meantime. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FileStamp { + size: u64, + modified: Option, +} + +impl FileStamp { + pub fn new(size: u64, modified: Option) -> 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 { + 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, stamp: FileStamp) -> Result { + 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::() { + 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::() { + Ok(refusal) => refusal, + Err(error) => SaveFailure::Failed(format!("{error:#}")), + } + } +} + +/// Read a remote text file, following links. Refusals come back as a +/// `ReadFailure` inside the error, so that the worker can still tell a +/// broken connection from the rest. +pub(crate) async fn read_text(fs: &F, path: &RemotePath) -> Result { + let Some(metadata) = fs.stat(path).await? else { + bail!("文件不存在"); + }; + if metadata.kind() != EntryKind::File { + return Err(ReadFailure::NotFile.into()); + } + if metadata.size() > EDIT_LIMIT { + return Err(ReadFailure::TooLarge(metadata.size()).into()); + } + let handle = fs.open_read(path).await?; + let bytes = read_all(fs, &handle, metadata.size()).await; + let close = fs.close(&handle).await; + let bytes = bytes?; + close?; + Ok(TextFile::decode(bytes, FileStamp::of(&metadata))?) +} + +/// Read a local text file through `provider`. Blocking, like the provider. +pub fn read_local_text( + provider: &dyn LocalDirectoryProvider, + path: &std::path::Path, +) -> Result { + let (bytes, stamp) = provider + .read_file(path, EDIT_LIMIT) + .map_err(ReadFailure::from_error)?; + TextFile::decode(bytes, stamp) +} + +/// Write the editor's bytes over a local file through `provider`. Blocking. +pub fn write_local_text( + provider: &dyn LocalDirectoryProvider, + path: &std::path::Path, + bytes: &[u8], + expected: Option, +) -> Result { + provider + .write_file(path, bytes, expected) + .map_err(SaveFailure::from_error) +} + +/// `size` bytes from the start of an open file, reading ahead in parallel. +/// Reads may come back out of order or short; each lands at its offset. +async fn read_all(fs: &F, handle: &str, size: u64) -> Result> { + let mut buffer = vec![0; usize::try_from(size)?]; + let mut next = 0; + let mut remainders: Vec<(u64, u32)> = Vec::new(); + let mut reads = FuturesUnordered::new(); + loop { + while reads.len() < MAX_IN_FLIGHT && (!remainders.is_empty() || next < size) { + let (at, len) = remainders.pop().unwrap_or_else(|| { + let len = (size - next).min(u64::from(CHUNK)) as u32; + let request = (next, len); + next += u64::from(len); + request + }); + reads.push(async move { (at, len, fs.read(handle, at, len).await) }); + } + let Some((at, len, bytes)) = reads.next().await else { + return Ok(buffer); + }; + let bytes = match bytes? { + Some(bytes) if !bytes.is_empty() => bytes, + _ => bail!("文件在读取时变短了,请重新打开"), + }; + let got = bytes.len().min(len as usize); + let start = at as usize; + buffer[start..start + got].copy_from_slice(&bytes[..got]); + // Give up on a binary file before reading all of it. + if at == 0 && bytes[..got.min(BINARY_PROBE)].contains(&0) { + return Err(ReadFailure::NotText.into()); + } + if got < len as usize { + remainders.push((at + got as u64, len - got as u32)); + } + } +} + +/// Write `bytes` over a remote file in place, following links and creating +/// it when it is gone. With `expected`, the file must still match it, or +/// the save fails with `SaveFailure::Changed` and nothing is written. +pub(crate) async fn write_in_place( + fs: &F, + path: &RemotePath, + bytes: Vec, + expected: Option, +) -> Result { + 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(fs: &F, handle: &str, bytes: Vec) -> 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::().is_some()) + ); + assert_eq!( + SaveFailure::from_error(error), + SaveFailure::Interrupted("reset".into()) + ); + } +} diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index 9bfd636..94ebcd7 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -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; diff --git a/src/sftp/model.rs b/src/sftp/model.rs index 35f6768..b110da9 100644 --- a/src/sftp/model.rs +++ b/src/sftp/model.rs @@ -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, 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, + ) -> Result; } pub type SharedLocalDirectoryProvider = std::sync::Arc; #[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, 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, + ) -> Result { + 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 { diff --git a/src/sftp/tests.rs b/src/sftp/tests.rs index 88bd350..0cf0135 100644 --- a/src/sftp/tests.rs +++ b/src/sftp/tests.rs @@ -2,6 +2,7 @@ use super::{ client::RemoteFs, control::TransferControl, download::DownloadBatch, + edit::{read_text, write_in_place}, journal::{DownloadJournal, Journal, SourceMetadata, partial_path}, upload::UploadBatch, *, @@ -150,9 +151,31 @@ impl RemoteFs for Remote { } Ok(path.to_string()) } + async fn open_replace(&self, path: &RemotePath) -> Result { + 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 { - 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 { + let nodes = self.nodes.borrow(); + let mut current = path.to_string(); + for _ in 0..8 { + match nodes.get(¤t) { + Some(node) if node.metadata.kind() == EntryKind::Symlink => { + current = if node.link.starts_with('/') { + node.link.clone() + } else { + RemotePath::new(current.as_str())? + .parent() + .join(&node.link)? + .to_string() + }; + } + _ => return Ok(current), + } + } + bail!("too many levels of symbolic links") + } fn dir(&self, path: &str, mode: u32) { self.nodes.borrow_mut().insert( path.into(), @@ -1808,3 +1852,270 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() { assert_eq!(mode & 0o111, 0); }); } + +#[test] +fn a_text_file_is_read_whole_through_short_and_reordered_reads() { + runtime().block_on(async { + let remote = Remote::new(true); + let text: String = (0..40_000).map(|i| format!("line {i}\n")).collect(); + remote.file("/etc/app.conf", text.as_bytes()); + remote.short_reads.set(true); + let file = read_text(&remote, &remote_path("/etc/app.conf")) + .await + .unwrap(); + assert_eq!(file.text(), text); + assert_eq!( + file.stamp(), + FileStamp::of(&remote.nodes.borrow()["/etc/app.conf"].metadata) + ); + assert!(remote.reads.get() > text.len() / 1000); + }); +} + +#[test] +fn a_link_is_followed_to_the_file_it_points_at() { + runtime().block_on(async { + let remote = Remote::new(true); + remote.file("/srv/real.conf", b"old\n"); + remote.link("/etc/app.conf", "/srv/real.conf"); + let path = remote_path("/etc/app.conf"); + let file = read_text(&remote, &path).await.unwrap(); + assert_eq!(file.text(), "old\n"); + write_in_place(&remote, &path, b"new\n".to_vec(), Some(file.stamp())) + .await + .unwrap(); + // The link is still a link; the file behind it changed. + assert_eq!( + remote.nodes.borrow()["/etc/app.conf"].metadata.kind(), + EntryKind::Symlink + ); + assert_eq!(remote.bytes("/srv/real.conf"), b"new\n"); + }); +} + +#[test] +fn directories_large_files_and_binary_files_are_refused() { + runtime().block_on(async { + let remote = Remote::new(true); + remote.dir("/etc", 0o755); + remote.file("/var/big.log", &vec![b'x'; EDIT_LIMIT as usize + 1]); + let mut binary = vec![b'a'; 1_000_000]; + binary[10] = 0; + remote.file("/bin/tool", &binary); + let refusal = |path: &'static str| { + let remote = &remote; + async move { + ReadFailure::from_error(read_text(remote, &remote_path(path)).await.unwrap_err()) + } + }; + assert_eq!(refusal("/etc").await, ReadFailure::NotFile); + assert_eq!( + refusal("/var/big.log").await, + ReadFailure::TooLarge(EDIT_LIMIT + 1) + ); + let reads = remote.reads.get(); + assert_eq!(refusal("/bin/tool").await, ReadFailure::NotText); + // Given up after the first chunk, not after reading it all. + assert!(remote.reads.get() - reads < 31); + assert!(matches!(refusal("/missing").await, ReadFailure::Failed(_))); + }); +} + +#[test] +fn a_save_writes_in_place_and_keeps_the_permissions() { + runtime().block_on(async { + let remote = Remote::new(true); + let long: Vec = (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)) + ); +} diff --git a/src/sftp/transport.rs b/src/sftp/transport.rs index 36dcf82..b0e6c21 100644 --- a/src/sftp/transport.rs +++ b/src/sftp/transport.rs @@ -1,6 +1,6 @@ use super::{ - DirectoryListing, DownloadRequest, PermissionEdit, RemotePath, TransferAnswer, - TransferProgress, TransferQuestion, UploadRequest, + DirectoryListing, DownloadRequest, FileStamp, PermissionEdit, ReadFailure, RemotePath, + SaveFailure, TextFile, TransferAnswer, TransferProgress, TransferQuestion, UploadRequest, }; use crate::{ connection::{ConnectionPrompt, ConnectionPromptReply, Latency}, @@ -49,6 +49,20 @@ pub enum SftpCommand { request_id: u64, operation: RemoteOperation, }, + /// Read a whole text file for the editor; answered by `FileRead`. Runs + /// alongside listings and transfers, like an operation. + ReadFile { + request_id: u64, + path: RemotePath, + }, + /// Write the editor's text over a file in place; answered by + /// `FileWritten`. With `expected`, only if the file still matches it. + WriteFile { + request_id: u64, + path: RemotePath, + bytes: Vec, + expected: Option, + }, Upload(UploadRequest), /// A download batch; like an upload, one batch runs at a time. Download(DownloadRequest), @@ -82,6 +96,14 @@ pub enum SftpEvent { request_id: u64, result: Result<(), String>, }, + FileRead { + request_id: u64, + result: Result, + }, + FileWritten { + request_id: u64, + result: Result, + }, Progress(TransferProgress), Question(TransferQuestion), Notice(String), diff --git a/src/sftp/worker.rs b/src/sftp/worker.rs index 33cc165..1f6ab54 100644 --- a/src/sftp/worker.rs +++ b/src/sftp/worker.rs @@ -1,10 +1,11 @@ use super::{ - EntryKind, RemotePath, SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, - TransferDetail, TransferDirection, TransferPhase, TransferProgress, + EntryKind, ReadFailure, RemotePath, SaveFailure, SftpCommand, SftpEvent, SftpTransport, + SftpTransportProvider, TransferDetail, TransferDirection, TransferPhase, TransferProgress, client::RemoteFs as _, client::{SftpClient, is_network_error}, control::{Cancelled, TransferControl}, download::DownloadBatch, + edit, journal::{DownloadJournal, Journal}, meter::TransferMeter, model::{scp_local_target, scp_remote_target}, @@ -27,6 +28,9 @@ use std::{ }; use tokio::sync::{RwLock, watch}; +/// The answer to a request that needs the connection while there is none. +const NOT_CONNECTED: &str = "SFTP 未连接,请重新连接"; + /// How often the latency task looks for a connection to measure, and for /// the one it is measuring to be dropped. const LATENCY_POLL: Duration = Duration::from_millis(250); @@ -196,7 +200,7 @@ impl SshSftpTransport { Err(format!("无法读取目录:{error:#}")) } }, - None => Err("SFTP 未连接,请重新连接".into()), + None => Err(NOT_CONNECTED.into()), }; let _ = events.send(SftpEvent::Listed { request_id, result }).await; }); @@ -221,13 +225,68 @@ impl SshSftpTransport { } } } - None => Err("SFTP 未连接,请重新连接".into()), + None => Err(NOT_CONNECTED.into()), }; let _ = events .send(SftpEvent::Operated { request_id, result }) .await; }); } + SftpCommand::ReadFile { request_id, path } => { + let shared = client.clone(); + let connected = client.read().await.clone(); + let events = events.clone(); + tokio::spawn(async move { + let result = match connected { + Some(connected) => { + match edit::read_text(connected.as_ref(), &path).await { + Ok(file) => Ok(file), + Err(error) => { + drop_broken(&shared, &connected, &events, &error) + .await; + Err(ReadFailure::from_error(error)) + } + } + } + None => Err(ReadFailure::Failed(NOT_CONNECTED.into())), + }; + let _ = events + .send(SftpEvent::FileRead { request_id, result }) + .await; + }); + } + SftpCommand::WriteFile { + request_id, + path, + bytes, + expected, + } => { + let shared = client.clone(); + let connected = client.read().await.clone(); + let events = events.clone(); + tokio::spawn(async move { + let result = match connected { + Some(connected) => match edit::write_in_place( + connected.as_ref(), + &path, + bytes, + expected, + ) + .await + { + Ok(stamp) => Ok(stamp), + Err(error) => { + drop_broken(&shared, &connected, &events, &error).await; + Err(SaveFailure::from_error(error)) + } + }, + None => Err(SaveFailure::Failed(NOT_CONNECTED.into())), + }; + let _ = events + .send(SftpEvent::FileWritten { request_id, result }) + .await; + }); + } command => { if busy.swap(true, Ordering::AcqRel) { let _ = events diff --git a/src/shared/closable_tab.rs b/src/shared/closable_tab.rs index 0d84a68..7f9e87e 100644 --- a/src/shared/closable_tab.rs +++ b/src/shared/closable_tab.rs @@ -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)>, menu: Option, + 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, action: Box) -> 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) diff --git a/src/shared/tab_menu.rs b/src/shared/tab_menu.rs index 83ea358..088de4c 100644 --- a/src/shared/tab_menu.rs +++ b/src/shared/tab_menu.rs @@ -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 { 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)), } } diff --git a/src/update/status.rs b/src/update/status.rs index 4d58265..c655ac7 100644 --- a/src/update/status.rs +++ b/src/update/status.rs @@ -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 { (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] diff --git a/src/workspace/editors.rs b/src/workspace/editors.rs new file mode 100644 index 0000000..32019e3 --- /dev/null +++ b/src/workspace/editors.rs @@ -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> { + 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> { + 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, + window: &mut Window, + cx: &mut Context, + ) { + 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, + window: &mut Window, + cx: &mut Context, + ) { + 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, + event: &EditorPanelEvent, + window: &mut Window, + cx: &mut Context, + ) { + 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, + ) { + 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, + ) { + 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, + ) { + 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, + ) { + 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 { + 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 { + let mut names: Vec = 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 { + let ids: Vec = 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) -> 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, + window: &mut Window, + cx: &mut Context, + ) { + 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::>(); + 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 个文件" + ); + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index fa1d9aa..8d35913 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -4,6 +4,7 @@ mod credentials; mod dock_skin; +mod editors; mod forwards; mod links; mod recent_hosts; diff --git a/src/workspace/status_bar.rs b/src/workspace/status_bar.rs index 284f87a..7a9f69c 100644 --- a/src/workspace/status_bar.rs +++ b/src/workspace/status_bar.rs @@ -21,6 +21,14 @@ pub enum WorkspaceStatus { Local(TerminalStatus), /// An SFTP tab: its own connection and what went wrong in it. Explorer(Option, 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, 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, Option, ) = 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}"); diff --git a/src/workspace/updates.rs b/src/workspace/updates.rs index f3d8de5..47aa726 100644 --- a/src/workspace/updates.rs +++ b/src/workspace/updates.rs @@ -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(), } } } diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 89d106c..a8c28f0 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -19,18 +19,20 @@ use gpui_kit::component::{ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; +use super::editors::describe_files; +use crate::app::ExplorerDispatch as _; use crate::app::{ - CenterTab, ClearTerminal, CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseSettings, - CloseTabs, CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectHost, CopyAgentSkill, - CopyHostAddress, CopyHostId, CopyTerminal, DeleteGroup, DeleteHost, DisconnectHost, - DisconnectTerminal, DismissTerminalFind, DuplicateHost, EditHost, ExpandAllGroups, - ExplorerAction, ExplorerCommand, ExplorerShortcut, FindInTerminal, FindNextInTerminal, - FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand, MoveHostNode, - NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal, NewTemporaryConnection, - OpenExplorer, OpenSettings, PasteTerminal, QuickConnect, ReconnectTerminal, - RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, RenameExplorer, RenameGroup, - RenameTerminal, RestartLocalTerminal, SetFileSizeFormat, ToggleHostPanel, ToggleTheme, ZoomIn, - ZoomOut, ZoomReset, + CenterTab, ClearTerminal, CloseActiveTab, CloseEditor, CloseExplorer, CloseLocalTerminal, + CloseSettings, CloseTabs, CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectHost, + CopyAgentSkill, CopyHostAddress, CopyHostId, CopyTerminal, DeleteGroup, DeleteHost, + DisconnectHost, DisconnectTerminal, DismissTerminalFind, DuplicateHost, EditHost, + ExpandAllGroups, ExplorerAction, ExplorerCommand, ExplorerShortcut, FindInTerminal, + FindNextInTerminal, FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand, + MoveHostNode, NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal, + NewTemporaryConnection, OpenExplorer, OpenSettings, PasteTerminal, QuickConnect, + ReconnectTerminal, RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, RenameExplorer, + RenameGroup, RenameTerminal, RestartLocalTerminal, SetFileSizeFormat, ToggleHostPanel, + ToggleTheme, ZoomIn, ZoomOut, ZoomReset, }; use crate::cli::{CliIntegration, CliServer, CliTarget, IntegrationPaths, SshCliBackend}; use crate::connection::{ @@ -38,6 +40,7 @@ use crate::connection::{ ConnectionSecret, SharedConnectionTester, }; use crate::credential::CredentialPanel; +use crate::editor::{EditorId, EditorPanel}; use crate::explorer::{ExplorerId, ExplorerPanel, ExplorerPanelEvent, confirm_close_transfer}; use crate::forward::{ ForwardManager, ForwardManagerEvent, ForwardPanel, SharedForwardTransportProvider, @@ -55,7 +58,7 @@ use crate::sftp::{ SharedLocalDirectoryProvider, SharedSftpTransportProvider, SshSftpTransportProvider, SystemLocalDirectoryProvider, }; -use crate::shared::{commit_footer, open_rename_tab_dialog}; +use crate::shared::{commit_footer, confirm_danger, open_rename_tab_dialog}; use crate::terminal::{ LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, LocalTerminalPanelEvent, RemoteTerminalId, SearchDirection, SharedRemoteTerminalTransportProvider, @@ -165,7 +168,7 @@ pub struct Workspace { local_terminal_factory: SharedTerminalTransportFactory, remote_terminal_provider: SharedRemoteTerminalTransportProvider, sftp_provider: SharedSftpTransportProvider, - local_directory_provider: SharedLocalDirectoryProvider, + pub(super) local_directory_provider: SharedLocalDirectoryProvider, /// Backs the host dialog's 「测试连接」. connection_tester: SharedConnectionTester, next_remote_terminal_id: u64, @@ -183,7 +186,10 @@ pub struct Workspace { /// Dispatch target for the title bar and start page: actions sent to it /// reach the workspace handlers whatever is focused. pub(super) focus_handle: FocusHandle, - _subscriptions: Vec, + /// Editor tabs, one per file. + pub(super) editors: HashMap>, + pub(super) next_editor_id: u64, + pub(super) _subscriptions: Vec, } impl Workspace { @@ -433,6 +439,17 @@ impl Workspace { .filter(|host| host.state.is_connected()) .map(|host| host.id) .collect(); + // ⌘Q (and on Windows and Linux the window's close button) asks first + // when an editor has changes not saved yet. + let this = cx.weak_entity(); + crate::app::set_quit_guard( + window.window_handle(), + move |window, cx| { + this.update(cx, |this, cx| this.ask_before_quit(window, cx)) + .unwrap_or(false) + }, + cx, + ); let mut terminals = HashMap::new(); let mut center = DockLayout::tabs(); let mut next_remote_terminal_id = 1; @@ -515,6 +532,8 @@ impl Workspace { prompt_queue: VecDeque::new(), active_prompt: None, focus_handle, + editors: HashMap::new(), + next_editor_id: 1, _subscriptions: subscriptions, } } @@ -627,6 +646,10 @@ impl Workspace { .settings_tab .as_ref() .map(|panel| panel.read(cx).focus_handle(cx)), + Some(CenterTab::Editor(id)) => self + .editors + .get(&id) + .map(|panel| panel.read(cx).focus_handle(cx)), None => None, }; let handle = tab.or_else(|| { @@ -1249,7 +1272,7 @@ impl Workspace { } /// Display one tab of a tab group, found by its panel's entity. - fn activate_tab( + pub(super) fn activate_tab( &self, group: Option>, panel: EntityId, @@ -1292,20 +1315,63 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - 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, + ) { + if let Some(panel) = self.explorers.get(&id).cloned() { let generation = panel.read(cx).generation(); + // Its remote files close with it: changes not saved yet would be + // lost, so the question names them. + let unsaved = if ask_about_unsaved { + self.unsaved_files(&self.editors_of(id, cx), cx) + } else { + Vec::new() + }; + let unsaved = (!unsaved.is_empty()).then(|| { + format!( + "{}有未保存的修改,关闭 SFTP 标签会一起关掉它们,修改会丢失。", + describe_files(&unsaved) + ) + }); if panel.read(cx).is_transferring() { let direction = panel.read(cx).transfer_direction(); confirm_close_transfer( - action.0, + id, generation, direction, + unsaved, self.focus_handle.clone(), window, cx, ); + } else if let Some(unsaved) = unsaved { + let dispatch = self.focus_handle.clone(); + confirm_danger( + "关闭 SFTP 标签?".into(), + Some(unsaved.into()), + "放弃修改并关闭", + Rc::new(move |window, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(id, ExplorerCommand::CloseConfirmed) + .with_generation(generation), + window, + cx, + ) + }), + window, + cx, + ); } else { - self.remove_explorer(action.0, window, cx); + self.remove_explorer(id, window, cx); } } } @@ -1343,6 +1409,15 @@ impl Workspace { cx, ); } + // A file open in an editor already shows that editor: no second read, + // no second tab. + if let ExplorerCommand::Edit { remote, path } = action.command() + && let Some(location) = panel.read(cx).edit_target(*remote, path.as_deref(), cx) + && let Some(editor) = self.editor_for_location(action.explorer(), &location, cx) + { + self.show_editor(&editor, window, cx); + return; + } if matches!(action.command(), ExplorerCommand::CloseConfirmed) { self.remove_explorer(action.explorer(), window, cx); } else { @@ -1392,7 +1467,12 @@ impl Workspace { /// Close one center tab through its own close path, so an explorer that /// is uploading still asks first. - fn close_center_tab(&mut self, tab: CenterTab, window: &mut Window, cx: &mut Context) { + pub(super) fn close_center_tab( + &mut self, + tab: CenterTab, + window: &mut Window, + cx: &mut Context, + ) { match tab { CenterTab::Terminal(id) => self.on_close_terminal(&CloseTerminal(id), window, cx), CenterTab::Explorer(id) => self.on_close_explorer(&CloseExplorer(id), window, cx), @@ -1400,6 +1480,7 @@ impl Workspace { self.on_close_local_terminal(&CloseLocalTerminal(id), window, cx) } CenterTab::Settings => self.on_close_settings(&CloseSettings, window, cx), + CenterTab::Editor(id) => self.on_close_editor(&CloseEditor(id), window, cx), } } @@ -1422,6 +1503,10 @@ impl Workspace { let panel = self.settings_tab.as_ref()?; (panel.read(cx).tab_group(), panel.entity_id()) } + CenterTab::Editor(id) => { + let panel = self.editors.get(&id)?; + (panel.read(cx).tab_group(), panel.entity_id()) + } }; Some((group?.upgrade()?, PanelId::from(entity))) } @@ -1451,6 +1536,12 @@ impl Workspace { .filter(|panel| is(panel.entity_id())) .map(|_| CenterTab::Settings) }) + .or_else(|| { + self.editors + .iter() + .find(|(_, entity)| is(entity.entity_id())) + .map(|(id, _)| CenterTab::Editor(*id)) + }) } fn on_close_tabs(&mut self, action: &CloseTabs, window: &mut Window, cx: &mut Context) { @@ -1472,9 +1563,7 @@ impl Workspace { .into_iter() .filter_map(|target| self.center_tab_for_panel(panels[target])) .collect(); - for tab in tabs { - self.close_center_tab(tab, window, cx); - } + self.close_center_tabs(tabs, window, cx); } fn on_rename_terminal( @@ -1553,7 +1642,7 @@ impl Workspace { CenterTab::LocalTerminal(id) => { Some(self.local_terminals.get(&id)?.read(cx).terminal().clone()) } - CenterTab::Explorer(_) | CenterTab::Settings => None, + CenterTab::Explorer(_) | CenterTab::Settings | CenterTab::Editor(_) => None, } } @@ -2134,6 +2223,11 @@ fn new_explorer_panel( this.set_active_tab(Some(CenterTab::Explorer(*id)), window, cx) } ExplorerPanelEvent::Closed(id, host_id) => { + // Its remote files can no longer be written: their editors + // go with it (anything unsaved was asked about first). + for editor in this.editors_of(*id, cx) { + this.remove_editor(editor, window, cx); + } this.explorers.remove(id); if this.active_tab == Some(CenterTab::Explorer(*id)) { this.set_active_tab(None, window, cx); @@ -2163,6 +2257,9 @@ fn new_explorer_panel( window, cx, ), + ExplorerPanelEvent::EditFile(id, location, file) => { + this.open_editor(*id, location.clone(), file.clone(), window, cx) + } }, ); (panel, subscription) @@ -2224,6 +2321,31 @@ impl Render for Workspace { } None => WorkspaceStatus::Host(active, None), }, + // An editor of a remote file tells the connection of the SFTP + // tab its saves go through. + Some(CenterTab::Editor(id)) => match self.editors.get(&id) { + Some(editor) => { + let editor = editor.read(cx); + let remote = editor.explorer_id().map(|explorer| { + let host = editor + .host_id() + .and_then(|host| self.store.read(cx).host(host).cloned()); + let state = self + .explorers + .get(&explorer) + .map_or(ConnectionState::Disconnected, |panel| { + panel.read(cx).connection_state() + }); + (host, state) + }); + WorkspaceStatus::Editor { + remote, + cursor: editor.cursor_label(), + format: editor.format_label(), + } + } + None => WorkspaceStatus::Host(active, None), + }, _ => WorkspaceStatus::Host(active, None), }; let sidebar = self.sidebar_showing(cx); @@ -2260,6 +2382,9 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_explorer_shortcut)) .on_action(cx.listener(Self::on_close_terminal)) .on_action(cx.listener(Self::on_close_explorer)) + .on_action(cx.listener(Self::on_close_editor)) + .on_action(cx.listener(Self::on_editor_action)) + .on_action(cx.listener(Self::on_editor_shortcut)) .on_action(cx.listener(Self::on_close_local_terminal)) .on_action(cx.listener(Self::on_open_settings)) .on_action(cx.listener(Self::on_install_cli_command)) diff --git a/tests/ui/editor.rs b/tests/ui/editor.rs new file mode 100644 index 0000000..c9da0d7 --- /dev/null +++ b/tests/ui/editor.rs @@ -0,0 +1,550 @@ +//! The built-in editor: opening a file from an SFTP tab, saving it back in +//! place, and what is asked before changes would be lost. + +use crate::support::*; +use shellrs::app::{ + CloseActiveTab, CloseEditor, CloseExplorer, ExplorerAction, ExplorerCommand, Quit, +}; +use shellrs::editor::EditorId; +use shellrs::explorer::{NewEntryKind, PaneOperation}; + +const REMOTE_FILE: &str = "/home/tester/文件 甲.txt"; +const OTHER_REMOTE_FILE: &str = "/home/tester/文件 乙.txt"; +const LOCAL_FILE: &str = "/local/tester/文件 甲.txt"; +const FIRST: EditorId = EditorId(1); + +/// An SFTP tab on a server with these files, the remote side listed. +async fn open_server( + cx: &mut TestAppContext, + files: &[(&str, &[u8])], +) -> (WindowHandle, Entity, Arc) { + 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, 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, 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, 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, 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, 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, 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()); +} diff --git a/tests/ui/main.rs b/tests/ui/main.rs index 80b808c..8cac4e4 100644 --- a/tests/ui/main.rs +++ b/tests/ui/main.rs @@ -10,6 +10,7 @@ mod support; mod connection; mod credential; mod docker; +mod editor; mod forward; mod history; mod host_dialog; diff --git a/tests/ui/support/mod.rs b/tests/ui/support/mod.rs index 2da2573..19e1c13 100644 --- a/tests/ui/support/mod.rs +++ b/tests/ui/support/mod.rs @@ -37,8 +37,9 @@ pub use shellrs::host::{ pub use shellrs::secrets::{InMemorySecretStore, SecretRef, SecretStore as _}; pub use shellrs::settings::{Appearance, InterfaceLanguage, SettingsStore}; pub use shellrs::sftp::{ - DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath, - SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadRequest, + DirectoryEntry, DirectoryListing, EDIT_LIMIT, EntryKind, FileMetadata, FileStamp, + LocalDirectoryProvider, ReadFailure, RemotePath, SaveFailure, SftpCommand, SftpEvent, + SftpTransport, SftpTransportProvider, TextFile, UploadRequest, }; pub use shellrs::terminal::{ FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId, diff --git a/tests/ui/support/sftp.rs b/tests/ui/support/sftp.rs index 8ef11be..6316103 100644 --- a/tests/ui/support/sftp.rs +++ b/tests/ui/support/sftp.rs @@ -2,6 +2,11 @@ use super::*; +/// A fake server's files for the editor: bytes and modification time. +pub type FakeFiles = Arc, u32)>>>; +/// The editor's writes: path, bytes and the stamp the file had to have. +pub type FakeWrites = Arc, Option)>>>; + #[derive(Default)] pub struct FakeSftpProvider { pub requests: Arc>>, @@ -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>>, + /// 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 { + 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>>>, pub hold: Option>, pub reconnects: Arc>, + pub files: FakeFiles, + pub reads: Arc>>, + pub writes: FakeWrites, pub home: &'static str, } @@ -80,6 +128,12 @@ impl SftpTransport for FakeSftpTransport { operation, } => { let refused = format!("{operation:?}").contains("denied"); + if let shellrs::sftp::RemoteOperation::CreateFile { path } = &operation { + self.files + .lock() + .unwrap() + .insert(path.to_string(), (Vec::new(), 100)); + } self.operations.lock().unwrap().push(operation); events.send_blocking(SftpEvent::Operated { request_id, @@ -90,6 +144,46 @@ impl SftpTransport for FakeSftpTransport { }, })?; } + SftpCommand::ReadFile { request_id, path } => { + self.reads.lock().unwrap().push(path.to_string()); + if path.as_str().starts_with("/slow") { + continue; + } + let result = match self.files.lock().unwrap().get(path.as_str()) { + Some((bytes, _)) if bytes.len() as u64 > EDIT_LIMIT => { + Err(ReadFailure::TooLarge(bytes.len() as u64)) + } + Some((bytes, modified)) => { + TextFile::decode(bytes.clone(), fake_stamp(bytes, *modified)) + } + None => Err(ReadFailure::Failed("文件不存在".into())), + }; + events.send_blocking(SftpEvent::FileRead { request_id, result })?; + } + SftpCommand::WriteFile { + request_id, + path, + bytes, + expected, + } => { + self.writes + .lock() + .unwrap() + .push((path.to_string(), bytes.clone(), expected)); + let mut files = self.files.lock().unwrap(); + let now = files.get(path.as_str()).map(|(b, m)| fake_stamp(b, *m)); + let result = if expected.is_some() && expected != now { + Err(SaveFailure::Changed) + } else { + // Every save is a second later. + let modified = files.get(path.as_str()).map_or(100, |(_, m)| m + 1); + let stamp = fake_stamp(&bytes, modified); + files.insert(path.to_string(), (bytes, modified)); + Ok(stamp) + }; + drop(files); + events.send_blocking(SftpEvent::FileWritten { request_id, result })?; + } SftpCommand::Download(request) => { self.downloads.lock().unwrap().push(request); events.send_blocking(SftpEvent::Progress( @@ -154,6 +248,8 @@ impl SftpTransport for FakeSftpTransport { #[derive(Clone, Default)] pub struct FakeLocalDirectory { pub calls: Arc>>, + /// The files the editor reads and writes, in memory. + pub files: Arc>>>, } impl FakeLocalDirectory { @@ -180,8 +276,45 @@ impl LocalDirectoryProvider for FakeLocalDirectory { self.record(format!("mkdir {}", path.display())) } fn create_file(&self, path: &std::path::Path) -> anyhow::Result<()> { + self.files.lock().unwrap().insert(path.into(), Vec::new()); self.record(format!("touch {}", path.display())) } + fn read_file( + &self, + path: &std::path::Path, + limit: u64, + ) -> anyhow::Result<(Vec, 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, + ) -> anyhow::Result { + 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],