diff --git a/CLAUDE.md b/CLAUDE.md index 4709ed5..c22ff62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);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 的「编辑」,做成中间区的「编辑器」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」。 +已确认的产品决定: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」。预览(用户定的):图片(PNG、JPG、GIF、WebP、SVG、BMP、ICO、TIFF)和 Markdown,显示在一个像快速查看的大对话框里(占窗口大部分,Esc 关,不留标签;没做成标签页);双击图片就预览,Markdown 双击仍是编辑;SVG 是文本(用户指出的),和 Markdown 一样双击编辑、右键另有「预览」;右键单选一个文件时,SVG 以外的图片只有「预览」(当文本编辑必然被拒),Markdown 和 SVG 是「编辑」「预览」,其他文件只有「编辑」;F4 永远是编辑;不做 HTML(gpui-kit 的简化渲染和浏览器差别太大),PDF 和 Office 画不了;图片上限 20 MB,Markdown 同编辑器 5 MB、只认 UTF-8;预览的是磁盘 / 服务器上的文件,不是编辑器里没保存的内容;页脚是远程文件的「下载…」、Markdown 的「编辑」和「关闭」。 做 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` 为准。 @@ -47,8 +47,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `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`)。`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 写锁。 +- `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`)。`preview.rs`(预览:纯函数 `PreviewKind::of` 按扩展名认图片和 Markdown、`IMAGE_LIMIT`,`open_preview_dialog` 是快速查看式的对话框,Markdown 是 `TextView::markdown`)、`image_preview.rs`(对话框里的图片 `ImagePreview`:自己在后台 `Image::to_image_data` 解码(解不开就写「无法显示这张图片」,不用 `img` 的加载、那样解不开会一直转),用 `ImageSource::Render` 画、关掉时 `drop_image`;缩放 `Zoom::{Fit, Scale}`,纯函数 `fit_scale` / `zoom_in` / `zoom_out`(10%–800% 的一串档位)/ `scroll_area` / `recenter`)、`file_edit.rs`(给编辑器和预览读写文件:`FileLocation`、`edit_target` / `preview_target`、`open_file(location, Opening::{Edit, Preview(kind)})`(先读后开:编辑发 `ExplorerPanelEvent::EditFile`,预览在这里直接开对话框;读不了的在 `refuse_open` 说明)、`read_bytes`(图片按原样读,`SftpCommand::ReadBytes`)、`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 就放弃)和给预览的 `read_whole`(同一个 `read_bytes`,不判二进制,上限由调用的一方给)、`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_*` 把错误映射成中文。 @@ -96,6 +96,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **文本**:只认 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` 只在对话框里写明会丢。 + - **预览**走同一条先读后开的路(`open_file` 的 `Opening::Preview`):Markdown 读文本(`ReadFile`,同编辑器的上限和 UTF-8 规则),图片读原始字节(`SftpCommand::ReadBytes` / `SftpEvent::BytesRead`,`FileBytes` 的 `Debug` 只写字节数,本地经 `read_local_bytes`);读成功才开对话框,太大时同编辑器一样说明并给「下载…」。双击(`ExplorerCommand::Open`)遇到 `PreviewKind::preview_only` 的图片(SVG 以外)派发 `Preview`,其他文件派发 `Edit`。预览里的图片由 `ImagePreview` 自己算尺寸:画框的大小由一个 `canvas` 在 prepaint 量出(变了就 `on_next_frame` 通知重画,同路径标签),适合窗口的比例是 `fit_scale`(不放大小图),图片元素的宽高都给成绝对值(缩放后的像素),外面套一层「图片和画框取大」的盒子(`scroll_area`)在 flex 里居中:比画框小就在正中,比画框大就由 `preview-scroll`(`overflow_scroll`,滚动条挂在不滚动的画框上)滚动。不要再用 `size_full` 加 `ObjectFit`:`img` 自己给盒子加上图片的宽高比,在这里压过了百分比高度,长图的盒子会按宽度算出几万像素高、只露出顶上一截,也不居中。缩放时 `recenter` 让画框中间的那一点留在中间。缩放的四个动作(`ZoomPreviewIn` / `ZoomPreviewOut` / `FitPreview` / `ActualSizePreview`)是对话框自己的状态,由 `ImagePreview` 自己处理(同搜索框是面板自己的状态,工作区够不着对话框里的视图),绑在 key context `ImagePreview` 上:⌘= / ⌘- 放大缩小,⌘0 原图,⌘9 适合窗口(同 macOS 预览),比全局的界面缩放深一层所以优先;预览打开后在同一次更新里把焦点交给它,键才有落点。⌘(Ctrl)加滚轮按比例连续缩放,双击图片在适合窗口和 100% 之间切换。对话框的按钮先 `close_dialog` 再经工作区的 focus handle 延后派发,所以「编辑」走工作区的查重、切到已开的编辑器。 - **⌘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`,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。 @@ -219,4 +220,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 标签的面板、导航、书签、文件操作、选择)、`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` 初始为已连接并已打开终端标签。 +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` 什么也不做)。预览的有:对话框里的 `file-preview`(`label` 是文件名)、画框 `preview-image` 与其中的图片 `preview-picture`(量它们的 `bounds` 看居中和缩放)、滚动区 `preview-scroll`、解码中或解不开时的 `preview-note`、缩放比例 `preview-zoom`(只写比例,「28%」「125%」;是不是适合窗口看 `preview-fit` 按钮的选中状态,测试用 `wait_for_zoom` 等它,解码完、画框量好之后才有比例)和按钮 `preview-zoom-out` / `preview-zoom-in` / `preview-fit` / `preview-actual-size`、`preview-markdown`、`preview-info`(「1 × 1 像素 · 70 B」,解码前只有大小,Markdown 是「Markdown · 12 B」)和页脚的 `preview-download` / `preview-edit` / `preview-close`;假服务器的远程 `/pictures` 目录列出 `with_files` 放在那里的文件(`files_listing`),所以测试导航过去再双击;`editor.rs` 里有 1×1 的 `PNG` 和 4×2000 的 `LONG_PNG` 常量(字节由脚本生成,别手抄:抄错一个字节就解不开);滚动和 ⌘ 加滚轮用 `scroll_preview` 派发 `ScrollWheelEvent`。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/README.en.md b/README.en.md index 31784b2..83cc3d3 100644 --- a/README.en.md +++ b/README.en.md @@ -62,7 +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. +- Built-in editor: double-click a text file to edit it, ⌘S writes it back in place, after checking nobody else changed it; syntax highlighting for common formats. Images and Markdown can be previewed. - Each SFTP tab has its own connection and asks only for the SFTP subsystem, so no shell is needed on the server. **Opening from a bastion host** diff --git a/README.md b/README.md index e287af4..d67a887 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF - 拖动或快捷键上传、下载,支持递归。传输进行中还能继续发起,新批次进传输队列依次执行。 - 断点续传:先写 `.filepart`,断线后按 1、3、10 秒自动重连;重启后再传同样的文件,会询问是否续传。 - 删除(本地移到废纸篓)、重命名、新建、修改权限(3×3 复选框加八进制,可递归)。 -- 内置编辑器:双击文本文件直接编辑,⌘S 原地写回服务器,保存前检查文件是否被别人改过;常见格式语法高亮。 +- 内置编辑器:双击文本文件直接编辑,⌘S 原地写回服务器,保存前检查文件是否被别人改过;常见格式语法高亮。图片和 Markdown 可以预览。 - 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。 **从堡垒机打开** diff --git a/docs/manual.md b/docs/manual.md index 2f5913f..0389676 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 切到另一侧。 - 右键未选中的名称格会先选中它;右键空白处取消选择并弹出空白处菜单。 - - 文件名上的菜单:打开(目录)或编辑(文件)、上传… / 下载…、删除、重命名…、属性…。 + - 文件名上的菜单:打开(目录)或编辑(文件;图片、SVG 和 Markdown 另有预览,SVG 以外的图片只有预览)、上传… / 下载…、删除、重命名…、属性…。 - 空白处的菜单:前往(上级 / 根 / 主目录 / 后退 / 前进)、刷新、添加路径到书签、新建。 ### 快捷键 @@ -315,6 +315,17 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以 - 窗口左下角是这个文件所走的 SFTP 连接(本地文件写「本机文件」),右下角是光标位置和编码、换行方式(如「UTF-8 · LF」)。 - 限制:只打开 5 MB 以内的 UTF-8 文本文件(带不带 BOM 都可以,保存时原样保留 BOM 和 CRLF 换行)。更大的文件、二进制文件和 GBK 等其他编码的文件会说明原因、不打开,远程文件可以改用「下载…」。 +### 预览 + +图片(PNG、JPG、GIF、WebP、SVG、BMP、ICO、TIFF)和 Markdown 可以预览,远程和本地的都行: + +- 打开:双击图片;Markdown 和 SVG 是文本,双击是编辑,预览用右键「预览」。 +- 预览是一个占窗口大部分的对话框,Esc、右上角的关闭或「关闭」按钮关掉。Markdown 排版后显示,可以滚动、选中复制,点链接用浏览器打开。 +- 图片默认「适合窗口」:按原比例缩小到放得下(小图不放大),居中显示;上方写像素尺寸、文件大小和当前比例。放大、缩小按钮(或 ⌘= / ⌘-,其他平台 Ctrl)按 10%–800% 的档位缩放,按住 ⌘(Ctrl)滚动滚轮或触控板可以连续缩放;「原图」(⌘0)按一个图片像素一个点显示,「适合窗口」(⌘9)回到放得下的大小;双击图片在两者之间切换。图片比预览区大时可以上下左右滚动查看,缩放时画面中间的位置保持不动。 +- 下方的「下载…」把远程文件下载到本机(打开下载确认框),Markdown 的「编辑」打开编辑器(已打开就切过去)。 +- 预览的是服务器或磁盘上的文件,编辑器里没保存的修改不会出现在预览里。 +- 限制:图片 20 MB 以内,Markdown 5 MB 以内且是 UTF-8;更大的会说明原因,远程文件可以改用「下载…」。 + ### 上传与下载 - 入口: diff --git a/src/app/actions.rs b/src/app/actions.rs index ebdd656..b273ae1 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -115,6 +115,14 @@ gpui_kit::actions!( ZoomReset, /// Quit the application. Quit, + /// Show the previewed image larger, by one step. + ZoomPreviewIn, + /// Show the previewed image smaller, by one step. + ZoomPreviewOut, + /// Fit the previewed image into its frame. + FitPreview, + /// Show the previewed image at its own size (100%). + ActualSizePreview, /// Put the `shellrs` command on the PATH. InstallCliCommand, /// Take the `shellrs` command off the PATH. @@ -439,6 +447,12 @@ pub enum ExplorerCommand { remote: bool, path: Option, }, + /// 预览: show an image or a Markdown file, `path` or else the one under + /// the cursor. + Preview { + remote: bool, + path: Option, + }, /// WinSCP's 打开目录/书签 dialog: type a directory or pick a bookmark. OpenDirectory { remote: bool, diff --git a/src/app/assets.rs b/src/app/assets.rs index cbd4ccb..d35f838 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -67,6 +67,9 @@ icon_assets!( FileText, FilePenLine, Save, + Eye, + ZoomIn, + ZoomOut, Zap, ExternalLink ] diff --git a/src/app/mod.rs b/src/app/mod.rs index e7e1435..ee44ef1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -31,6 +31,8 @@ pub const CREDENTIAL_PANEL_CONTEXT: &str = "CredentialPanel"; /// Key contexts of the two SFTP file lists. /// Key context of an editor tab. pub const EDITOR_CONTEXT: &str = "FileEditor"; +/// Key context of the image in a preview. +pub const IMAGE_PREVIEW_CONTEXT: &str = "ImagePreview"; pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList"; pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList"; @@ -89,6 +91,16 @@ fn key_bindings() -> Vec { EditorShortcut(EditorCommand::Save), Some(EDITOR_CONTEXT), ), + // Over a previewed image, the interface zoom keys zoom the image; + // 0 and 9 as in macOS Preview. + KeyBinding::new(&primary("="), ZoomPreviewIn, Some(IMAGE_PREVIEW_CONTEXT)), + KeyBinding::new(&primary("-"), ZoomPreviewOut, Some(IMAGE_PREVIEW_CONTEXT)), + KeyBinding::new( + &primary("0"), + ActualSizePreview, + Some(IMAGE_PREVIEW_CONTEXT), + ), + KeyBinding::new(&primary("9"), FitPreview, Some(IMAGE_PREVIEW_CONTEXT)), KeyBinding::new("shift-escape", ToggleZoom, None), KeyBinding::new("enter", ConnectSelected, Some(HOST_PANEL_CONTEXT)), KeyBinding::new("enter", ConnectSelected, Some(RECENT_HOSTS_CONTEXT)), diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index 2156a73..3e58d78 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -1,6 +1,9 @@ use super::{ ExplorerId, FileLocation, FilePane, FilePaneEvent, LoadIntent, PaneSide, QueueId, QueueState, - Removal, TransferJob, TransferQueue, file_edit::PendingFile, pane_operations::PendingOperation, + Removal, TransferJob, TransferQueue, + file_edit::{Opening, PendingFile}, + pane_operations::PendingOperation, + preview::PreviewKind, }; use crate::app::ExplorerDispatch as _; use crate::{ @@ -492,6 +495,9 @@ impl ExplorerPanel { SftpEvent::FileWritten { request_id, result } => { self.finish_file_write(request_id, result) } + SftpEvent::BytesRead { request_id, result } => { + self.finish_bytes_read(request_id, result) + } SftpEvent::Progress(progress) => { let complete = progress.phase() == TransferPhase::Completed; let direction = progress.direction(); @@ -660,17 +666,19 @@ impl ExplorerPanel { }; 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); + // A picture is previewed; any other file (an SVG too, + // being text) opens in the editor, through the workspace, + // which shows the tab when the file is open already. + let path = Some(pane.read(cx).child_path_of(&entry.name)); + let remote = *remote; + let command = + if PreviewKind::of(&entry.name).is_some_and(|kind| kind.preview_only()) { + ExplorerCommand::Preview { remote, path } + } else { + ExplorerCommand::Edit { remote, path } + }; self.dispatch.dispatch_explorer_action( - &ExplorerAction::new( - self.id, - ExplorerCommand::Edit { - remote: *remote, - path: Some(path), - }, - ), + &ExplorerAction::new(self.id, command), window, cx, ); @@ -678,7 +686,12 @@ impl ExplorerPanel { } ExplorerCommand::Edit { remote, path } => { if let Some(location) = self.edit_target(*remote, path.as_deref(), cx) { - self.open_for_edit(location, window, cx); + self.open_file(location, Opening::Edit, window, cx); + } + } + ExplorerCommand::Preview { remote, path } => { + if let Some((location, kind)) = self.preview_target(*remote, path.as_deref(), cx) { + self.open_file(location, Opening::Preview(kind), window, cx); } } ExplorerCommand::OpenDirectory { remote } => { @@ -960,6 +973,7 @@ fn needs_connection(command: &ExplorerCommand) -> bool { | C::Forward { remote } | C::Open { remote } | C::Edit { remote, .. } + | C::Preview { remote, .. } | C::OpenDirectory { remote } | C::Delete { remote } | C::Rename { remote } diff --git a/src/explorer/file_edit.rs b/src/explorer/file_edit.rs index 3afa033..0ff86a1 100644 --- a/src/explorer/file_edit.rs +++ b/src/explorer/file_edit.rs @@ -1,15 +1,18 @@ -//! 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. +//! Files for the editor and for previews: an SFTP tab reads them and writes +//! the remote ones back over its own connection, so neither ever logs in +//! again. //! -//! A file is read before any editor tab exists: one that cannot be edited -//! (too large, binary, not UTF-8) is explained here and never flashes a tab. +//! A file is read before any editor tab or preview exists: one that cannot +//! be shown (too large, binary, not UTF-8) is explained here and never +//! flashes a tab. +use super::preview::{IMAGE_LIMIT, Preview, PreviewContent, PreviewKind, open_preview_dialog}; use super::{ExplorerPanel, ExplorerPanelEvent, format_size}; use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _}; use crate::host::ConnectionState; use crate::sftp::{ - EDIT_LIMIT, FileStamp, ReadFailure, RemotePath, SaveFailure, SftpCommand, TextFile, - read_local_text, + EDIT_LIMIT, FileBytes, FileStamp, ReadFailure, RemotePath, SaveFailure, SftpCommand, TextFile, + read_local_bytes, read_local_text, }; use futures::channel::oneshot; use gpui_kit::component::{WindowExt as _, dialog::DialogButtonProps, notification::Notification}; @@ -60,9 +63,23 @@ impl FileLocation { /// A read or a write waiting for the worker, answered from `on_event`. pub(super) enum PendingFile { Read(oneshot::Sender>), + Bytes(oneshot::Sender>), Write(oneshot::Sender>), } +/// Why a file is read: for the editor, or for a preview of its kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Opening { + Edit, + Preview(PreviewKind), +} + +/// A file as read for an `Opening`: text, or an image's bytes. +enum Opened { + Text(TextFile), + Bytes(FileBytes), +} + impl ExplorerPanel { /// The file `path` names on one side, or the one under that side's /// cursor; `None` for a directory or nothing. @@ -85,18 +102,41 @@ impl ExplorerPanel { }) } - /// 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( + /// The file `path` names on one side, or the one under the cursor, + /// when it can be previewed. + pub fn preview_target( + &self, + remote: bool, + path: Option<&str>, + cx: &App, + ) -> Option<(FileLocation, PreviewKind)> { + let location = self.edit_target(remote, path, cx)?; + let kind = PreviewKind::of(&location.name())?; + Some((location, kind)) + } + + /// Read a file for a new editor tab or a preview. `EditFile` brings one + /// for the editor to the workspace; a preview opens here; a refusal is + /// explained here. + pub(super) fn open_file( &mut self, location: FileLocation, + opening: Opening, window: &mut Window, cx: &mut Context, ) { if !self.opening.insert(location.clone()) { return; } - let Some(read) = self.read_file(&location, window, cx) else { + let read = match opening { + Opening::Preview(PreviewKind::Image(_)) => self + .read_bytes(&location, IMAGE_LIMIT, window, cx) + .map(|read| cx.spawn(async move |_, _| read.await.map(Opened::Bytes))), + Opening::Edit | Opening::Preview(PreviewKind::Markdown) => self + .read_file(&location, window, cx) + .map(|read| cx.spawn(async move |_, _| read.await.map(Opened::Text))), + }; + let Some(read) = read else { self.opening.remove(&location); return; }; @@ -112,13 +152,15 @@ impl ExplorerPanel { 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), + match (opening, result) { + (Opening::Edit, Ok(Opened::Text(file))) => cx.emit( + ExplorerPanelEvent::EditFile(this.id(), location, Rc::new(file)), + ), + (Opening::Preview(kind), Ok(opened)) => { + this.show_preview(&location, kind, opened, window, cx) + } + (Opening::Edit, Ok(Opened::Bytes(_))) => {} + (_, Err(failure)) => this.refuse_open(&location, opening, failure, window, cx), } }); }) @@ -157,6 +199,42 @@ impl ExplorerPanel { } } + /// Read a whole file as it is, up to `limit` bytes. `None` when the + /// remote side is not connected, which this tab has already said. + pub(super) fn read_bytes( + &mut self, + location: &FileLocation, + limit: u64, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + match location { + FileLocation::Local(path) => { + let (provider, path) = (self.local_provider.clone(), path.clone()); + Some(cx.background_spawn(async move { + read_local_bytes(provider.as_ref(), &path, limit) + })) + } + FileLocation::Remote(path) => { + if !self.reachable(window, cx) { + return None; + } + let (sender, receiver) = oneshot::channel(); + let request_id = self.next_file_request(PendingFile::Bytes(sender)); + self.send(SftpCommand::ReadBytes { + request_id, + path: path.clone(), + limit, + }); + Some(cx.spawn(async move |_, _| { + receiver + .await + .unwrap_or_else(|_| Err(ReadFailure::Failed("SFTP 连接已断开".into()))) + })) + } + } + } + /// Write bytes over a remote file in place. `None` when not connected, /// which this tab has already told the user about. pub fn write_remote_file( @@ -210,6 +288,12 @@ impl ExplorerPanel { } } + pub(super) fn finish_bytes_read(&mut self, id: u64, result: Result) { + if let Some(PendingFile::Bytes(sender)) = self.files.remove(&id) { + let _ = sender.send(result); + } + } + pub(super) fn finish_file_write(&mut self, id: u64, result: Result) { if let Some(PendingFile::Write(sender)) = self.files.remove(&id) { let _ = sender.send(result); @@ -238,22 +322,91 @@ impl ExplorerPanel { self.next_operation } + /// The preview dialog for a file just read. + fn show_preview( + &mut self, + location: &FileLocation, + kind: PreviewKind, + opened: Opened, + window: &mut Window, + cx: &mut Context, + ) { + let remote = location.is_remote(); + let (content, size) = match (kind, opened) { + (PreviewKind::Image(format), Opened::Bytes(bytes)) => { + let size = bytes.len() as u64; + let image = Image::from_bytes(format, bytes.into_inner()); + (PreviewContent::Image(image), size) + } + (PreviewKind::Markdown, Opened::Text(file)) => { + let size = file.stamp().size(); + let (text, _, _) = file.into_parts(); + (PreviewContent::Markdown(text.into()), size) + } + _ => return, + }; + let id = self.id(); + let download = self.download_action(location, cx); + let edit = (kind == PreviewKind::Markdown).then(|| { + ExplorerAction::new( + id, + ExplorerCommand::Edit { + remote, + path: Some(location.path()), + }, + ) + }); + open_preview_dialog( + Preview { + name: location.name(), + size, + content, + download: download.map(|command| ExplorerAction::new(id, command)), + edit, + dispatch: self.dispatch.clone(), + }, + window, + cx, + ); + } + + /// 下载… for a remote file, into the local pane's directory. + fn download_action(&self, location: &FileLocation, cx: &App) -> Option { + match location { + FileLocation::Remote(path) => Some(ExplorerCommand::DownloadPaths { + paths: vec![path.to_string()], + target: self.local().read(cx).path(), + }), + FileLocation::Local(_) => None, + } + } + /// Why a file was not opened. Too large, binary or not UTF-8 is a /// decision to make (a remote file can still be downloaded); anything /// else is an error. - fn refuse_edit( + fn refuse_open( &mut self, location: &FileLocation, + opening: Opening, failure: ReadFailure, window: &mut Window, cx: &mut Context, ) { let name = location.name(); + let (title, what, limit) = match opening { + Opening::Edit => (format!("无法在编辑器中打开“{name}”"), "编辑器", EDIT_LIMIT), + Opening::Preview(PreviewKind::Image(_)) => { + (format!("无法预览“{name}”"), "预览", IMAGE_LIMIT) + } + Opening::Preview(PreviewKind::Markdown) => { + (format!("无法预览“{name}”"), "预览", EDIT_LIMIT) + } + }; let reason = match &failure { ReadFailure::TooLarge(size) => format!( - "文件有 {},编辑器只打开 {} 以内的文件。", + "文件有 {},{what}只打开 {} 以内的文件。", format_size(*size), - format_size(EDIT_LIMIT) + format_size(limit) ), ReadFailure::NotText => { "它不是 UTF-8 编码的文本文件:可能是二进制文件,或用了 GBK 等其他编码。".into() @@ -267,13 +420,7 @@ impl ExplorerPanel { 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 download = self.download_action(location, cx); let description = if download.is_some() { format!("{reason}可以下载到本机,用别的程序打开。") } else { @@ -281,9 +428,7 @@ impl ExplorerPanel { }; let (dispatch, id) = (self.dispatch.clone(), self.id()); window.open_alert_dialog(cx, move |alert, _, _| { - let alert = alert - .title(format!("无法在编辑器中打开“{name}”")) - .description(description.clone()); + let alert = alert.title(title.clone()).description(description.clone()); match download.clone() { Some(download) => alert .button_props( diff --git a/src/explorer/file_pane.rs b/src/explorer/file_pane.rs index 7552246..4fcf636 100644 --- a/src/explorer/file_pane.rs +++ b/src/explorer/file_pane.rs @@ -1,6 +1,6 @@ use super::{ ClickMode, CursorMotion, ExplorerId, FileEntry, FileListing, FileSizeFormat, LoadIntent, - NavigationHistory, Selection, child_path, + NavigationHistory, PreviewKind, Selection, child_path, file_listing::{ListGeometry, ListingContext, MenuHit, Pressed, accept_drops, offer_drop}, pane_menu::{ PaneMenuState, bookmark_menu, directory_menu, item_menu, new_menu, size_format_menu, @@ -937,6 +937,10 @@ impl FilePane { } _ => None, }, + preview_kind: match entries.as_slice() { + [entry] if !entry.is_dir() && !entry.is_parent() => PreviewKind::of(&entry.name), + _ => None, + }, targets, can_go_up: self.parent_path() != self.path, can_go_home: !self.home.is_empty() && self.home != self.path, diff --git a/src/explorer/image_preview.rs b/src/explorer/image_preview.rs new file mode 100644 index 0000000..fc55c73 --- /dev/null +++ b/src/explorer/image_preview.rs @@ -0,0 +1,493 @@ +//! The image in a preview: fitted into its frame and centered, or zoomed, +//! with scrolling once it is larger than the frame. +//! +//! The zoom is the preview's own state, like a list's search text: its +//! actions are handled here, where the keys reach it while the preview has +//! focus, not by the workspace, which never holds a dialog's content. + +use std::{cell::Cell, rc::Rc, sync::Arc}; + +use gpui_kit::component::{ + ActiveTheme as _, Icon, Selectable as _, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + scroll::ScrollableElement as _, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use super::format_size; +use crate::app::{ + ActualSizePreview, CatalogIcon, FitPreview, IMAGE_PREVIEW_CONTEXT, ZoomPreviewIn, + ZoomPreviewOut, +}; + +/// The zoom levels the buttons and keys step through, as a share of the +/// image's own size (one image pixel to one point at 100%). +const STEPS: [f32; 14] = [ + 0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 5.0, 8.0, +]; +const MIN_SCALE: f32 = 0.1; +const MAX_SCALE: f32 = 8.0; + +/// How the image is sized: to fit the frame (never above its own size), +/// or at a scale of its own. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Zoom { + Fit, + Scale(f32), +} + +/// The scale at which the whole image fits the frame; a small image keeps +/// its own size. +pub(super) fn fit_scale(frame: Size, image: Size) -> f32 { + if image.width <= px(0.) || image.height <= px(0.) { + return 1.0; + } + (frame.width / image.width) + .min(frame.height / image.height) + .min(1.0) +} + +/// The next step above `scale`. +pub(super) fn zoom_in(scale: f32) -> f32 { + STEPS + .into_iter() + .find(|step| *step > scale + 0.001) + .unwrap_or(MAX_SCALE) +} + +/// The next step below `scale`. +pub(super) fn zoom_out(scale: f32) -> f32 { + STEPS + .into_iter() + .rev() + .find(|step| *step < scale - 0.001) + .unwrap_or(MIN_SCALE) +} + +/// The area scrolled over: the image, or the frame where the image is +/// smaller, so that it stays in the middle. +pub(super) fn scroll_area(frame: Size, shown: Size) -> Size { + size(shown.width.max(frame.width), shown.height.max(frame.height)) +} + +/// The scroll offset that keeps the point in the middle of the frame where +/// it was when the area changes size (zooming). +pub(super) fn recenter( + frame: Size, + old_area: Size, + new_area: Size, + offset: Point, +) -> Point { + let axis = |offset: Pixels, frame: Pixels, old: Pixels, new: Pixels| { + let center = (-offset + frame / 2.) / old; + let start = new * center - frame / 2.; + -start.clamp(px(0.), (new - frame).max(px(0.))) + }; + point( + axis(offset.x, frame.width, old_area.width, new_area.width), + axis(offset.y, frame.height, old_area.height, new_area.height), + ) +} + +/// 「37%」. +pub(super) fn percent(scale: f32) -> String { + format!("{}%", (scale * 100.).round()) +} + +pub(super) struct ImagePreview { + /// The decoded image: `None` while it is decoded, an error when it + /// cannot be. + decoded: Option, String>>, + name: SharedString, + /// The file's size in bytes. + bytes: u64, + zoom: Zoom, + scroll: ScrollHandle, + /// The frame's size as last laid out: the fit, the centering and the + /// scroll area go by it. + frame: Rc>>>, + focus_handle: FocusHandle, + _decode: Task<()>, +} + +impl ImagePreview { + /// The image is decoded on the background executor, here rather than + /// by `img`'s loading, so that one that cannot be decoded says so + /// instead of loading for ever. + pub(super) fn new( + image: Image, + name: SharedString, + bytes: u64, + cx: &mut Context, + ) -> Self { + let renderer = cx.svg_renderer(); + let decode = cx.spawn(async move |this, cx| { + let decoded = cx + .background_spawn(async move { image.to_image_data(renderer) }) + .await + .map_err(|error| format!("{error:#}")); + let _ = this.update(cx, |this, cx| { + this.decoded = Some(decoded); + cx.notify(); + }); + }); + // The decoded image lives in the window's texture atlas until it is + // dropped from there; the window is free again once the dialog that + // released this view has closed. + cx.on_release(|this, cx| { + if let Some(Ok(image)) = this.decoded.take() { + cx.defer(move |cx| cx.drop_image(image, None)); + } + }) + .detach(); + Self { + decoded: None, + name, + bytes, + zoom: Zoom::Fit, + scroll: ScrollHandle::new(), + frame: Rc::new(Cell::new(None)), + focus_handle: cx.focus_handle(), + _decode: decode, + } + } + + pub(super) fn focus_handle(&self) -> &FocusHandle { + &self.focus_handle + } + + /// The image's own size, one image pixel to one point; `None` until it + /// is decoded, or when it cannot be. + fn pixels(&self) -> Option> { + let Some(Ok(decoded)) = &self.decoded else { + return None; + }; + let pixels = decoded.size(0); + Some(size( + px(u32::from(pixels.width) as f32), + px(u32::from(pixels.height) as f32), + )) + } + + fn scale(&self, frame: Size, image: Size) -> f32 { + match self.zoom { + Zoom::Fit => fit_scale(frame, image), + Zoom::Scale(scale) => scale, + } + } + + /// Zoom, keeping what is in the middle of the frame there. + fn set_zoom(&mut self, zoom: Zoom, cx: &mut Context) { + if let (Some(image), Some(frame)) = (self.pixels(), self.frame.get()) { + let old = self.scale(frame, image); + self.zoom = zoom; + let new = self.scale(frame, image); + let area = + |scale: f32| scroll_area(frame, size(image.width * scale, image.height * scale)); + self.scroll + .set_offset(recenter(frame, area(old), area(new), self.scroll.offset())); + } else { + self.zoom = zoom; + } + cx.notify(); + } + + fn current_scale(&self) -> f32 { + match (self.pixels(), self.frame.get()) { + (Some(image), Some(frame)) => self.scale(frame, image), + _ => 1.0, + } + } + + fn zoom_in(&mut self, _: &ZoomPreviewIn, _: &mut Window, cx: &mut Context) { + let scale = zoom_in(self.current_scale()); + self.set_zoom(Zoom::Scale(scale), cx); + } + + fn zoom_out(&mut self, _: &ZoomPreviewOut, _: &mut Window, cx: &mut Context) { + let scale = zoom_out(self.current_scale()); + self.set_zoom(Zoom::Scale(scale), cx); + } + + fn fit(&mut self, _: &FitPreview, _: &mut Window, cx: &mut Context) { + self.set_zoom(Zoom::Fit, cx); + } + + fn actual_size(&mut self, _: &ActualSizePreview, _: &mut Window, cx: &mut Context) { + self.set_zoom(Zoom::Scale(1.0), cx); + } +} + +impl Render for ImagePreview { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let viewport = window.viewport_size(); + let height = viewport.height * 0.7; + let pixels = self.pixels(); + // Before the first layout, a guess at the frame: the next frame has + // its measure. + let frame = self + .frame + .get() + .unwrap_or_else(|| size(viewport.width * 0.8 - px(48.), height)); + let scale = pixels.map(|image| self.scale(frame, image)); + let info: SharedString = match pixels { + Some(image) => format!( + "{} × {} 像素 · {}", + f32::from(image.width) as u32, + f32::from(image.height) as u32, + format_size(self.bytes) + ), + None => format_size(self.bytes), + } + .into(); + // Only the scale: whether it fits is the 适合窗口 button's state. + let zoom_label: SharedString = scale.map(percent).unwrap_or_default().into(); + let focus = self.focus_handle.clone(); + let command = move |action: Box| { + let focus = focus.clone(); + move |_: &ClickEvent, window: &mut Window, cx: &mut App| { + focus.dispatch_action(action.as_ref(), window, cx) + } + }; + let toolbar = h_flex() + .gap_1() + .child( + div() + .id("preview-info") + .test_support() + .aria_label(info.clone()) + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(info), + ) + .child( + Button::new("preview-zoom-out") + .ghost() + .small() + .icon(Icon::new(CatalogIcon::ZoomOut)) + .accessibility_label("缩小") + .tooltip_with_action("缩小", &ZoomPreviewOut, Some(IMAGE_PREVIEW_CONTEXT)) + .on_click(command(Box::new(ZoomPreviewOut))), + ) + .child( + div() + .id("preview-zoom") + .test_support() + .aria_label(zoom_label.clone()) + .min_w_20() + .text_center() + .text_sm() + .font_family(cx.theme().mono_font_family.clone()) + .child(zoom_label), + ) + .child( + Button::new("preview-zoom-in") + .ghost() + .small() + .icon(Icon::new(CatalogIcon::ZoomIn)) + .accessibility_label("放大") + .tooltip_with_action("放大", &ZoomPreviewIn, Some(IMAGE_PREVIEW_CONTEXT)) + .on_click(command(Box::new(ZoomPreviewIn))), + ) + .child( + Button::new("preview-fit") + .ghost() + .small() + .label("适合窗口") + .selected(self.zoom == Zoom::Fit) + .tooltip_with_action("适合窗口", &FitPreview, Some(IMAGE_PREVIEW_CONTEXT)) + .on_click(command(Box::new(FitPreview))), + ) + .child( + Button::new("preview-actual-size") + .ghost() + .small() + .label("原图") + .selected(self.zoom == Zoom::Scale(1.0)) + .tooltip_with_action( + "原图(100%)", + &ActualSizePreview, + Some(IMAGE_PREVIEW_CONTEXT), + ) + .on_click(command(Box::new(ActualSizePreview))), + ); + + let measured = self.frame.clone(); + let this = cx.entity().downgrade(); + let decoded = match &self.decoded { + Some(Ok(decoded)) => Some(decoded.clone()), + _ => None, + }; + let picture = pixels + .zip(scale) + .zip(decoded) + .map(|((image, scale), decoded)| { + let shown = size(image.width * scale, image.height * scale); + let area = scroll_area(frame, shown); + // The area is at least the frame, so a smaller image sits in its + // middle; a larger one fills it and scrolls. + div() + .w(area.width) + .h(area.height) + .flex() + .items_center() + .justify_center() + .child( + img(ImageSource::Render(decoded)) + .id("preview-picture") + .w(shown.width) + .h(shown.height) + .flex_none() + // A double click goes between fitting and 100%. + .on_click(cx.listener(|this, event: &ClickEvent, _, cx| { + if event.click_count() == 2 { + let zoom = if this.zoom == Zoom::Fit { + Zoom::Scale(1.0) + } else { + Zoom::Fit + }; + this.set_zoom(zoom, cx); + } + })) + .test_support(), + ) + }); + let frame_box = div() + .id("preview-image") + .test_support() + .aria_label(self.name.clone()) + .relative() + .h(height) + .w_full() + .rounded(cx.theme().radius) + .bg(cx.theme().muted) + .overflow_hidden() + // The frame's size decides the fit; a new size takes effect on + // the next frame. + .child( + canvas( + move |bounds, window, _| { + if measured.get() != Some(bounds.size) { + measured.set(Some(bounds.size)); + if let Some(this) = this.upgrade() { + let id = this.entity_id(); + window.on_next_frame(move |_, cx| cx.notify(id)); + } + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .child( + div() + .id("preview-scroll") + .size_full() + .overflow_scroll() + .track_scroll(&self.scroll) + // ⌘ (Ctrl) with the wheel or trackpad zooms instead of + // scrolling. + .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| { + if !event.modifiers.secondary() { + return; + } + cx.stop_propagation(); + let delta = event.delta.pixel_delta(px(20.)).y; + let scale = this.current_scale(); + let scale = + (scale * (1. + f32::from(delta) / 200.)).clamp(MIN_SCALE, MAX_SCALE); + this.set_zoom(Zoom::Scale(scale), cx); + })) + .children(picture) + .when(pixels.is_none(), |scroll| { + let note = match &self.decoded { + Some(Err(_)) => "无法显示这张图片", + _ => "正在显示图片…", + }; + scroll.child( + div() + .id("preview-note") + .test_support() + .aria_label(note) + .size_full() + .flex() + .items_center() + .justify_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(note), + ) + }), + ) + .vertical_scrollbar(&self.scroll) + .horizontal_scrollbar(&self.scroll); + + v_flex() + .id("image-preview") + .key_context(IMAGE_PREVIEW_CONTEXT) + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::zoom_in)) + .on_action(cx.listener(Self::zoom_out)) + .on_action(cx.listener(Self::fit)) + .on_action(cx.listener(Self::actual_size)) + .gap_2() + .child(toolbar) + .child(frame_box) + } +} + +#[cfg(test)] +mod tests { + use super::{fit_scale, percent, recenter, scroll_area, zoom_in, zoom_out}; + use gpui_kit::{point, px, size}; + + #[test] + fn a_large_image_fits_and_a_small_one_keeps_its_size() { + let frame = size(px(800.), px(600.)); + assert_eq!(fit_scale(frame, size(px(4000.), px(1000.))), 0.2); + assert_eq!(fit_scale(frame, size(px(100.), px(3000.))), 0.2); + assert_eq!(fit_scale(frame, size(px(10.), px(10.))), 1.0); + assert_eq!(fit_scale(frame, size(px(0.), px(0.))), 1.0); + } + + #[test] + fn zooming_steps_up_and_down_within_bounds() { + assert_eq!(zoom_in(0.2), 0.25); + assert_eq!(zoom_in(1.0), 1.25); + assert_eq!(zoom_in(8.0), 8.0); + assert_eq!(zoom_out(1.0), 0.75); + assert_eq!(zoom_out(0.3), 0.25); + assert_eq!(zoom_out(0.1), 0.1); + assert_eq!(percent(0.333), "33%"); + } + + #[test] + fn the_middle_stays_in_the_middle_when_zooming() { + let frame = size(px(100.), px(100.)); + // A small image centered: nothing to scroll. + let small = scroll_area(frame, size(px(50.), px(20.))); + assert_eq!(small, frame); + // From fitting to twice the frame: the middle of the area. + let large = scroll_area(frame, size(px(200.), px(400.))); + assert_eq!( + recenter(frame, small, large, point(px(0.), px(0.))), + point(px(-50.), px(-150.)) + ); + // Scrolled to the top, then zoomed out: the offset stays in range. + assert_eq!( + recenter(frame, large, small, point(px(-100.), px(-300.))), + point(px(0.), px(0.)) + ); + } +} diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs index 6a12904..8e6dab0 100644 --- a/src/explorer/mod.rs +++ b/src/explorer/mod.rs @@ -6,11 +6,13 @@ mod file_edit; mod file_listing; mod file_pane; mod history; +mod image_preview; mod model; mod open_directory_dialog; mod pane_menu; mod pane_operations; mod path_label; +mod preview; mod properties_dialog; mod queue_panel; mod selection; @@ -23,6 +25,7 @@ pub use file_pane::{FilePane, FilePaneEvent, PaneSide}; pub use history::{LoadIntent, NavigationHistory}; pub use model::*; pub use pane_operations::PaneOperation; +pub use preview::{IMAGE_LIMIT, PreviewKind}; pub use selection::{ClickMode, CursorMotion, Selection}; pub use transfer_queue::{ QueueEntry, QueueId, QueueState, Removal, TransferJob, TransferQueue, percent, diff --git a/src/explorer/pane_menu.rs b/src/explorer/pane_menu.rs index bcb7d88..a55a487 100644 --- a/src/explorer/pane_menu.rs +++ b/src/explorer/pane_menu.rs @@ -2,7 +2,7 @@ //! and the toolbar's bookmark and 新建 menus. Each item dispatches the same //! `ExplorerCommand` as the toolbar button with that verb. -use super::{ExplorerId, FileSizeFormat, NewEntryKind}; +use super::{ExplorerId, FileSizeFormat, NewEntryKind, PreviewKind}; use crate::app::{CatalogIcon, ExplorerAction, ExplorerCommand, SetFileSizeFormat}; use gpui_kit::component::{Icon, IconName, menu::PopupMenu}; use gpui_kit::*; @@ -19,6 +19,8 @@ pub(super) struct PaneMenuState { /// The single selected row is a file the editor can try to open: its /// full path. pub edits_file: Option, + /// How that file can be previewed, if it can. + pub preview_kind: Option, pub can_go_up: bool, pub can_go_home: bool, pub can_go_back: bool, @@ -49,15 +51,34 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { ) .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() + // A picture is only previewed: as text it would be refused. An SVG + // is text, and is both edited and previewed. + let kind = state.preview_kind; + let menu = if kind.is_some_and(|kind| kind.preview_only()) { + menu + } else { + menu.menu_with_icon( + "编辑", + Icon::new(CatalogIcon::FilePenLine), + state.action(ExplorerCommand::Edit { + remote, + path: Some(path.clone()), + }), + ) + }; + let menu = if kind.is_some() { + menu.menu_with_icon( + "预览", + Icon::new(CatalogIcon::Eye), + state.action(ExplorerCommand::Preview { + remote, + path: Some(path), + }), + ) + } else { + menu + }; + menu.separator() } else { menu }; diff --git a/src/explorer/preview.rs b/src/explorer/preview.rs new file mode 100644 index 0000000..2fe06c2 --- /dev/null +++ b/src/explorer/preview.rs @@ -0,0 +1,213 @@ +//! Previews of files an SFTP tab shows: images and Markdown, in a large +//! dialog in the manner of Quick Look. Nothing stays behind: closing the +//! dialog drops what was read. + +use gpui_kit::component::{ + ActiveTheme as _, WindowExt as _, button::Button, dialog::DialogFooter, text::TextView, v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use super::format_size; +use super::image_preview::ImagePreview; +use crate::app::{ExplorerAction, ExplorerDispatch as _}; + +/// The largest image a preview reads. +pub const IMAGE_LIMIT: u64 = 20 * 1024 * 1024; + +/// How a file can be previewed, by its name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreviewKind { + Image(ImageFormat), + Markdown, +} + +impl PreviewKind { + pub fn of(file_name: &str) -> Option { + let (_, extension) = file_name.rsplit_once('.')?; + Some(match extension.to_ascii_lowercase().as_str() { + "png" => PreviewKind::Image(ImageFormat::Png), + "jpg" | "jpeg" => PreviewKind::Image(ImageFormat::Jpeg), + "gif" => PreviewKind::Image(ImageFormat::Gif), + "webp" => PreviewKind::Image(ImageFormat::Webp), + "svg" => PreviewKind::Image(ImageFormat::Svg), + "bmp" => PreviewKind::Image(ImageFormat::Bmp), + "ico" => PreviewKind::Image(ImageFormat::Ico), + "tif" | "tiff" => PreviewKind::Image(ImageFormat::Tiff), + "md" | "markdown" => PreviewKind::Markdown, + _ => return None, + }) + } + + /// Only previewed, never edited: a picture is not text. An SVG is text + /// (XML), so it opens in the editor and is previewed on request, like + /// Markdown. + pub fn preview_only(&self) -> bool { + matches!(self, PreviewKind::Image(format) if *format != ImageFormat::Svg) + } +} + +pub(super) enum PreviewContent { + Image(Image), + Markdown(SharedString), +} + +/// What a preview dialog shows, and the commands at its foot. +pub(super) struct Preview { + pub name: String, + /// The file's size in bytes. + pub size: u64, + pub content: PreviewContent, + /// 下载…, for a remote file. + pub download: Option, + /// 编辑, for a Markdown file. + pub edit: Option, + /// The workspace's focus handle, where those are dispatched. + pub dispatch: FocusHandle, +} + +/// Show a preview over the window: most of it, the image fitted (and +/// zoomable) or the Markdown laid out and scrollable. Esc closes it. +pub(super) fn open_preview_dialog(preview: Preview, window: &mut Window, cx: &mut App) { + let Preview { + name, + size, + content, + download, + edit, + dispatch, + } = preview; + let focus = window.focused(cx); + let name: SharedString = name.into(); + let body = match content { + PreviewContent::Image(image) => { + let view = cx.new(|cx| ImagePreview::new(image, name.clone(), size, cx)); + PreviewBody::Image(view) + } + PreviewContent::Markdown(text) => PreviewBody::Markdown(text), + }; + let image = match &body { + PreviewBody::Image(view) => Some(view.clone()), + PreviewBody::Markdown(_) => None, + }; + window.open_dialog(cx, move |dialog, window, cx| { + let viewport = window.viewport_size(); + let height = viewport.height * 0.7; + let content = match &body { + PreviewBody::Image(view) => view.clone().into_any_element(), + PreviewBody::Markdown(text) => { + let info = format!("Markdown · {}", format_size(size)); + v_flex() + .gap_2() + .child( + div() + .id("preview-info") + .test_support() + .aria_label(info.clone()) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(info), + ) + .child( + div() + .id("preview-markdown") + .test_support() + .aria_label(name.clone()) + .h(height) + .w_full() + .child( + TextView::markdown("preview-markdown-text", text.clone()) + .scrollable(true) + .selectable(true), + ), + ) + .into_any_element() + } + }; + // A command at the foot closes the preview first: what it opens + // (the download question, the editor) goes in front. + let command = |id: &'static str, label: &'static str, action: ExplorerAction| { + let dispatch = dispatch.clone(); + Button::new(id).label(label).on_click(move |_, window, cx| { + window.close_dialog(cx); + dispatch.dispatch_explorer_action(&action, window, cx); + }) + }; + dialog + .title(name.clone()) + .w(viewport.width * 0.8) + .margin_top(viewport.height * 0.05) + .overlay_closable(false) + .child( + div() + .id("file-preview") + .test_support() + .aria_label(name.clone()) + .child(content), + ) + .footer( + DialogFooter::new() + .when_some(download.clone(), |footer, action| { + footer.child(command("preview-download", "下载…", action)) + }) + .when_some(edit.clone(), |footer, action| { + footer.child(command("preview-edit", "编辑", action)) + }) + .child( + Button::new("preview-close") + .label("关闭") + .on_click(|_, window, cx| window.close_dialog(cx)), + ), + ) + .on_close({ + let focus = focus.clone(); + move |_, window, cx| { + if let Some(focus) = &focus { + window.focus(focus, cx); + } + } + }) + }); + // The zoom keys reach the image while it has focus. Only a focus taken + // in this same update holds: the dialog took it just now. + if let Some(view) = image { + let focus = view.read(cx).focus_handle().clone(); + window.focus(&focus, cx); + } +} + +/// What the dialog shows, made once: the image's view keeps its zoom and +/// scroll across frames. +enum PreviewBody { + Image(Entity), + Markdown(SharedString), +} + +#[cfg(test)] +mod tests { + use super::PreviewKind; + use gpui_kit::ImageFormat; + + #[test] + fn images_and_markdown_are_previewed_by_their_names() { + assert_eq!( + PreviewKind::of("photo.JPG"), + Some(PreviewKind::Image(ImageFormat::Jpeg)) + ); + assert_eq!( + PreviewKind::of("logo.svg"), + Some(PreviewKind::Image(ImageFormat::Svg)) + ); + assert_eq!(PreviewKind::of("README.md"), Some(PreviewKind::Markdown)); + assert_eq!( + PreviewKind::of("notes.markdown"), + Some(PreviewKind::Markdown) + ); + assert_eq!(PreviewKind::of("archive.tar.gz"), None); + assert_eq!(PreviewKind::of("Makefile"), None); + assert_eq!(PreviewKind::of("nginx.conf"), None); + assert!(PreviewKind::of("a.png").unwrap().preview_only()); + assert!(!PreviewKind::of("a.svg").unwrap().preview_only()); + assert!(!PreviewKind::Markdown.preview_only()); + } +} diff --git a/src/sftp/edit.rs b/src/sftp/edit.rs index 6b82416..c86fdc1 100644 --- a/src/sftp/edit.rs +++ b/src/sftp/edit.rs @@ -215,25 +215,73 @@ impl SaveFailure { } } +/// A whole file read as it is, for a preview: an image, say. +#[derive(Clone, PartialEq, Eq)] +pub struct FileBytes(Vec); + +impl FileBytes { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn into_inner(self) -> Vec { + self.0 + } +} + +impl std::fmt::Debug for FileBytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FileBytes({} bytes)", self.0.len()) + } +} + /// Read a remote text file, following links. Refusals come back as a /// `ReadFailure` inside the error, so that the worker can still tell a /// broken connection from the rest. pub(crate) async fn read_text(fs: &F, path: &RemotePath) -> Result { + let (bytes, stamp) = read_bytes(fs, path, EDIT_LIMIT, true).await?; + Ok(TextFile::decode(bytes, stamp)?) +} + +/// Read a remote file whole, following links, binary or not; refused like +/// `read_text` when it is not a file or larger than `limit`. +pub(crate) async fn read_whole( + fs: &F, + path: &RemotePath, + limit: u64, +) -> Result { + let (bytes, _) = read_bytes(fs, path, limit, false).await?; + Ok(FileBytes(bytes)) +} + +/// `stat`, refuse what is not a file or too large, then read it all. For +/// `text`, a binary file is given up on after its first chunk. +async fn read_bytes( + fs: &F, + path: &RemotePath, + limit: u64, + text: bool, +) -> Result<(Vec, FileStamp)> { let Some(metadata) = fs.stat(path).await? else { bail!("文件不存在"); }; if metadata.kind() != EntryKind::File { return Err(ReadFailure::NotFile.into()); } - if metadata.size() > EDIT_LIMIT { + if metadata.size() > limit { return Err(ReadFailure::TooLarge(metadata.size()).into()); } let handle = fs.open_read(path).await?; - let bytes = read_all(fs, &handle, metadata.size()).await; + let bytes = read_all(fs, &handle, metadata.size(), text).await; let close = fs.close(&handle).await; let bytes = bytes?; close?; - Ok(TextFile::decode(bytes, FileStamp::of(&metadata))?) + Ok((bytes, FileStamp::of(&metadata))) } /// Read a local text file through `provider`. Blocking, like the provider. @@ -247,6 +295,18 @@ pub fn read_local_text( TextFile::decode(bytes, stamp) } +/// Read a local file whole through `provider`, binary or not. Blocking. +pub fn read_local_bytes( + provider: &dyn LocalDirectoryProvider, + path: &std::path::Path, + limit: u64, +) -> Result { + let (bytes, _) = provider + .read_file(path, limit) + .map_err(ReadFailure::from_error)?; + Ok(FileBytes(bytes)) +} + /// Write the editor's bytes over a local file through `provider`. Blocking. pub fn write_local_text( provider: &dyn LocalDirectoryProvider, @@ -261,7 +321,8 @@ pub fn write_local_text( /// `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> { +/// For `text`, a NUL early on stops it: a binary file. +async fn read_all(fs: &F, handle: &str, size: u64, text: bool) -> Result> { let mut buffer = vec![0; usize::try_from(size)?]; let mut next = 0; let mut remainders: Vec<(u64, u32)> = Vec::new(); @@ -287,7 +348,7 @@ async fn read_all(fs: &F, handle: &str, size: u64) -> Result = (0..300_000u32).map(|i| (i % 256) as u8).collect(); + remote.file("/srv/photo.png", &image); + remote.short_reads.set(true); + let path = remote_path("/srv/photo.png"); + let bytes = read_whole(&remote, &path, 1_000_000).await.unwrap(); + assert_eq!(bytes.into_inner(), image); + let error = read_whole(&remote, &path, 1000).await.unwrap_err(); + assert_eq!( + ReadFailure::from_error(error), + ReadFailure::TooLarge(300_000) + ); + }); +} diff --git a/src/sftp/transport.rs b/src/sftp/transport.rs index b0e6c21..f89f3d5 100644 --- a/src/sftp/transport.rs +++ b/src/sftp/transport.rs @@ -1,6 +1,7 @@ use super::{ - DirectoryListing, DownloadRequest, FileStamp, PermissionEdit, ReadFailure, RemotePath, - SaveFailure, TextFile, TransferAnswer, TransferProgress, TransferQuestion, UploadRequest, + DirectoryListing, DownloadRequest, FileBytes, FileStamp, PermissionEdit, ReadFailure, + RemotePath, SaveFailure, TextFile, TransferAnswer, TransferProgress, TransferQuestion, + UploadRequest, }; use crate::{ connection::{ConnectionPrompt, ConnectionPromptReply, Latency}, @@ -55,6 +56,13 @@ pub enum SftpCommand { request_id: u64, path: RemotePath, }, + /// Read a whole file as it is, up to `limit` bytes, for a preview; + /// answered by `BytesRead`. + ReadBytes { + request_id: u64, + path: RemotePath, + limit: u64, + }, /// Write the editor's text over a file in place; answered by /// `FileWritten`. With `expected`, only if the file still matches it. WriteFile { @@ -104,6 +112,10 @@ pub enum SftpEvent { request_id: u64, result: Result, }, + BytesRead { + request_id: u64, + result: Result, + }, Progress(TransferProgress), Question(TransferQuestion), Notice(String), diff --git a/src/sftp/worker.rs b/src/sftp/worker.rs index 1f6ab54..2f4657d 100644 --- a/src/sftp/worker.rs +++ b/src/sftp/worker.rs @@ -255,6 +255,35 @@ impl SshSftpTransport { .await; }); } + SftpCommand::ReadBytes { + request_id, + path, + limit, + } => { + let shared = client.clone(); + let connected = client.read().await.clone(); + let events = events.clone(); + tokio::spawn(async move { + let result = match connected { + Some(connected) => { + match edit::read_whole(connected.as_ref(), &path, limit) + .await + { + Ok(bytes) => Ok(bytes), + Err(error) => { + drop_broken(&shared, &connected, &events, &error) + .await; + Err(ReadFailure::from_error(error)) + } + } + } + None => Err(ReadFailure::Failed(NOT_CONNECTED.into())), + }; + let _ = events + .send(SftpEvent::BytesRead { request_id, result }) + .await; + }); + } SftpCommand::WriteFile { request_id, path, diff --git a/tests/ui/editor.rs b/tests/ui/editor.rs index c9da0d7..0033e3b 100644 --- a/tests/ui/editor.rs +++ b/tests/ui/editor.rs @@ -1,12 +1,13 @@ -//! 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. +//! The built-in editor and previews: opening a file from an SFTP tab, +//! saving it back in place, what is asked before changes would be lost, and +//! images and Markdown shown in a preview. use crate::support::*; use shellrs::app::{ CloseActiveTab, CloseEditor, CloseExplorer, ExplorerAction, ExplorerCommand, Quit, }; use shellrs::editor::EditorId; -use shellrs::explorer::{NewEntryKind, PaneOperation}; +use shellrs::explorer::{IMAGE_LIMIT, NewEntryKind, PaneOperation}; const REMOTE_FILE: &str = "/home/tester/文件 甲.txt"; const OTHER_REMOTE_FILE: &str = "/home/tester/文件 乙.txt"; @@ -548,3 +549,317 @@ async fn closing_all_tabs_asks_once_for_every_unsaved_file(cx: &mut TestAppConte assert!(!is_open(&workspace, EditorId(2), cx)); assert!(provider.writes.lock().unwrap().is_empty()); } + +/// A 1×1 PNG. +const PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0d\x49\x44\x41\x54\x78\x9c\x63\xf8\xcf\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99\x3d\x1d\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82"; + +/// An SFTP tab whose remote side lists `/pictures`, where these files are. +async fn open_pictures( + cx: &mut TestAppContext, + files: &[(&str, &[u8])], +) -> (WindowHandle, Entity, Arc) { + let (handle, workspace, provider) = open_server(cx, files).await; + in_frame(cx, handle, |window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + ExplorerId(SFTP_TAB), + ExplorerCommand::Navigate { + remote: true, + path: "/pictures".into(), + }, + )), + cx, + ) + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("remote-path").value() == Some("/pictures") + }) + .await; + (handle, workspace, provider) +} + +fn preview(cx: &mut TestAppContext, handle: WindowHandle, remote: bool, path: &str) { + let path = path.to_string(); + in_frame(cx, handle, move |window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + ExplorerId(SFTP_TAB), + ExplorerCommand::Preview { + remote, + path: Some(path), + }, + )), + cx, + ) + }); +} + +#[gpui_kit::test] +async fn double_clicking_an_image_previews_it(cx: &mut TestAppContext) { + let (handle, workspace, provider) = open_pictures(cx, &[("/pictures/图.png", PNG)]).await; + open_file(cx, handle, "remote-pane", "图.png"); + wait_for(cx, handle, "file-preview").await; + in_frame(cx, handle, |window, _| { + assert_eq!(window.find("file-preview").label(), Some("图.png")); + assert!(window.try_find("preview-image").is_some()); + assert!( + window + .find("preview-info") + .label() + .is_some_and(|info| info.ends_with("70 B")) + ); + // A remote image can be downloaded; it cannot be edited. + assert!(window.try_find("preview-download").is_some()); + assert!(window.try_find("preview-edit").is_none()); + }); + assert!(!is_open(&workspace, FIRST, cx)); + assert_eq!(*provider.reads.lock().unwrap(), vec!["/pictures/图.png"]); + + in_frame(cx, handle, |window, cx| window.press("escape", cx)); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("file-preview").is_none()) + }); +} + +#[gpui_kit::test] +async fn markdown_is_edited_on_a_double_click_and_previewed_from_the_menu(cx: &mut TestAppContext) { + let (handle, workspace, _) = open_pictures( + cx, + &[("/pictures/README.md", "# 标题\n\n正文\n".as_bytes())], + ) + .await; + open_file(cx, handle, "remote-pane", "README.md"); + wait_for_editor(cx, handle, FIRST).await; + + preview(cx, handle, true, "/pictures/README.md"); + wait_for(cx, handle, "preview-markdown").await; + in_frame(cx, handle, |window, _| { + assert!( + window + .find("preview-info") + .label() + .is_some_and(|info| info.starts_with("Markdown")) + ); + }); + // 编辑 goes to the editor already open, not a second one. + in_frame(cx, handle, |window, cx| window.click("preview-edit", cx)); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("file-preview").is_none()); + assert!(window.try_find(("editor", FIRST.0)).is_some()); + }); + assert!(!is_open(&workspace, EditorId(2), cx)); + + // 下载… asks where to, as a download does. + preview(cx, handle, true, "/pictures/README.md"); + wait_for(cx, handle, "preview-download").await; + in_frame(cx, handle, |window, cx| { + window.click("preview-download", cx) + }); + wait_for(cx, handle, "download-confirm").await; +} + +#[gpui_kit::test] +async fn an_image_too_large_to_preview_offers_the_download(cx: &mut TestAppContext) { + let large = vec![0; IMAGE_LIMIT as usize + 1]; + let (handle, _, _) = open_pictures(cx, &[("/pictures/图.png", &large)]).await; + open_file(cx, handle, "remote-pane", "图.png"); + wait_for(cx, handle, "ok").await; + in_frame(cx, handle, |window, _| { + assert!(window.try_find("file-preview").is_none()) + }); + in_frame(cx, handle, |window, cx| window.click("ok", cx)); + wait_for(cx, handle, "download-confirm").await; +} + +#[gpui_kit::test] +async fn a_local_image_is_previewed_without_a_download(cx: &mut TestAppContext) { + let local = FakeLocalDirectory::default(); + local + .files + .lock() + .unwrap() + .insert("/local/tester/图.png".into(), PNG.to_vec()); + let (handle, _) = + open_workspace_with_services(cx, Arc::new(FakeSftpProvider::default()), local); + open_test_explorer(cx, handle).await; + preview(cx, handle, false, "/local/tester/图.png"); + wait_for(cx, handle, "file-preview").await; + in_frame(cx, handle, |window, _| { + assert!(window.try_find("preview-image").is_some()); + assert!(window.try_find("preview-download").is_none()); + }); +} + +/// A 4×2000 PNG: a long screenshot, in small, taller than any frame. +const LONG_PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x04\x00\x00\x07\xd0\x08\x06\x00\x00\x00\xe5\xf9\xfe\xc9\x00\x00\x00\x6e\x49\x44\x41\x54\x78\xda\xed\xc8\xb1\x0d\x00\x00\x08\xc0\xa0\xfe\xff\xb4\xee\x7e\x60\xc2\xc0\x42\xd5\x1c\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x88\x9f\xb1\xc4\x5f\x22\x92\xa1\xbb\x97\x39\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82"; + +/// The preview's zoom, once the image is decoded and the frame measured. +async fn wait_for_zoom(cx: &mut TestAppContext, handle: WindowHandle, zoom: &'static str) { + cx.wait_for(handle.into(), Duration::from_secs(2), move |window, cx| { + window.render_frame(cx); + window + .try_find("preview-zoom") + .is_some_and(|label| label.label() == Some(zoom)) + }) + .await; +} + +fn bounds_of( + cx: &mut TestAppContext, + handle: WindowHandle, + id: &'static str, +) -> gpui_kit::Bounds { + in_frame(cx, handle, |window, _| window.find(id).bounds()) +} + +fn scroll_preview(cx: &mut TestAppContext, handle: WindowHandle, by: f32, zoom: bool) { + in_frame(cx, handle, |window, cx| { + let position = window.find("preview-image").bounds().center(); + window.dispatch_event( + gpui_kit::ScrollWheelEvent { + position, + delta: gpui_kit::ScrollDelta::Pixels(point(px(0.), px(by))), + modifiers: if zoom { + gpui_kit::Modifiers::secondary_key() + } else { + gpui_kit::Modifiers::default() + }, + touch_phase: gpui_kit::TouchPhase::Moved, + } + .to_platform_input(), + cx, + ); + }); +} + +#[gpui_kit::test] +async fn a_previewed_image_fits_its_frame_in_the_middle(cx: &mut TestAppContext) { + let (handle, _, _) = open_pictures( + cx, + &[("/pictures/长图.png", LONG_PNG), ("/pictures/点.png", PNG)], + ) + .await; + // A long image: scaled down to the frame's height, in its middle. + open_file(cx, handle, "remote-pane", "长图.png"); + wait_for_zoom(cx, handle, "28%").await; + let frame = bounds_of(cx, handle, "preview-image"); + let picture = bounds_of(cx, handle, "preview-picture"); + in_frame(cx, handle, |window, _| { + assert!( + window + .find("preview-info") + .label() + .is_some_and(|info| info.starts_with("4 × 2000 像素")) + ); + }); + assert!( + (picture.size.height - frame.size.height).abs() < px(1.), + "{picture:?} in {frame:?}" + ); + assert!( + (picture.center().x - frame.center().x).abs() < px(1.), + "{picture:?} in {frame:?}" + ); + in_frame(cx, handle, |window, cx| window.press("escape", cx)); + + // A small one keeps its size, in the middle both ways. + open_file(cx, handle, "remote-pane", "点.png"); + wait_for_zoom(cx, handle, "100%").await; + let frame = bounds_of(cx, handle, "preview-image"); + let picture = bounds_of(cx, handle, "preview-picture"); + assert_eq!(picture.size, size(px(1.), px(1.))); + assert!((picture.center().x - frame.center().x).abs() < px(1.)); + assert!((picture.center().y - frame.center().y).abs() < px(1.)); +} + +#[gpui_kit::test] +async fn a_previewed_image_zooms_and_scrolls(cx: &mut TestAppContext) { + let (handle, _, _) = open_pictures(cx, &[("/pictures/长图.png", LONG_PNG)]).await; + open_file(cx, handle, "remote-pane", "长图.png"); + wait_for_zoom(cx, handle, "28%").await; + + // 原图: its own size, larger than the frame, from its middle. + in_frame(cx, handle, |window, cx| { + window.click("preview-actual-size", cx) + }); + wait_for_zoom(cx, handle, "100%").await; + let frame = bounds_of(cx, handle, "preview-image"); + let picture = bounds_of(cx, handle, "preview-picture"); + assert_eq!(picture.size.height, px(2000.)); + assert!((picture.center().y - frame.center().y).abs() < px(1.)); + + // It scrolls. + scroll_preview(cx, handle, -300., false); + let scrolled = bounds_of(cx, handle, "preview-picture"); + assert_eq!(scrolled.top(), picture.top() - px(300.)); + + // The buttons and the keys step the zoom. + in_frame(cx, handle, |window, cx| window.click("preview-zoom-in", cx)); + wait_for_zoom(cx, handle, "125%").await; + in_frame(cx, handle, |window, cx| window.press("cmd--", cx)); + wait_for_zoom(cx, handle, "100%").await; + in_frame(cx, handle, |window, cx| window.press("cmd-9", cx)); + wait_for_zoom(cx, handle, "28%").await; + in_frame(cx, handle, |window, cx| { + window.click("preview-zoom-out", cx) + }); + wait_for_zoom(cx, handle, "25%").await; + in_frame(cx, handle, |window, cx| window.press("cmd-0", cx)); + wait_for_zoom(cx, handle, "100%").await; + in_frame(cx, handle, |window, cx| window.click("preview-fit", cx)); + wait_for_zoom(cx, handle, "28%").await; + + // A double click goes to 100% and back. + in_frame(cx, handle, |window, cx| { + window.double_click("preview-picture", cx) + }); + wait_for_zoom(cx, handle, "100%").await; + in_frame(cx, handle, |window, cx| { + window.double_click("preview-picture", cx) + }); + wait_for_zoom(cx, handle, "28%").await; + + // ⌘ with the wheel zooms instead of scrolling. + scroll_preview(cx, handle, 100., true); + in_frame(cx, handle, |window, _| { + assert_eq!(window.find("preview-zoom").label(), Some("42%")); + }); +} + +#[gpui_kit::test] +async fn an_svg_is_edited_on_a_double_click_and_previewed_from_the_menu(cx: &mut TestAppContext) { + let svg = r#""#; + let (handle, workspace, _) = open_pictures(cx, &[("/pictures/logo.svg", svg.as_bytes())]).await; + // An SVG is text: a double click edits it. + open_file(cx, handle, "remote-pane", "logo.svg"); + wait_for_editor(cx, handle, FIRST).await; + assert_eq!(text_of(&workspace, FIRST, cx), svg); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("file-preview").is_none()) + }); + + preview(cx, handle, true, "/pictures/logo.svg"); + wait_for(cx, handle, "file-preview").await; + in_frame(cx, handle, |window, _| { + assert!(window.try_find("preview-image").is_some()); + // The editor is open already; the preview offers no second way in. + assert!(window.try_find("preview-edit").is_none()); + }); +} + +#[gpui_kit::test] +async fn an_image_that_cannot_be_decoded_says_so(cx: &mut TestAppContext) { + let (handle, _, _) = open_pictures(cx, &[("/pictures/坏.png", b"not a png at all")]).await; + open_file(cx, handle, "remote-pane", "坏.png"); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window + .try_find("preview-note") + .is_some_and(|note| note.label() == Some("无法显示这张图片")) + }) + .await; + in_frame(cx, handle, |window, _| { + assert!(window.try_find("preview-picture").is_none()) + }); +} diff --git a/tests/ui/support/mod.rs b/tests/ui/support/mod.rs index 19e1c13..7c31421 100644 --- a/tests/ui/support/mod.rs +++ b/tests/ui/support/mod.rs @@ -37,7 +37,7 @@ 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, EDIT_LIMIT, EntryKind, FileMetadata, FileStamp, + DirectoryEntry, DirectoryListing, EDIT_LIMIT, EntryKind, FileBytes, FileMetadata, FileStamp, LocalDirectoryProvider, ReadFailure, RemotePath, SaveFailure, SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, TextFile, UploadRequest, }; diff --git a/tests/ui/support/sftp.rs b/tests/ui/support/sftp.rs index 6316103..a11bafc 100644 --- a/tests/ui/support/sftp.rs +++ b/tests/ui/support/sftp.rs @@ -119,6 +119,8 @@ impl SftpTransport for FakeSftpTransport { let result = match path.as_str() { "/denied" => Err("权限不足".into()), "/empty" => Ok(DirectoryListing::new("/empty", vec![])), + // The files put there, as `with_files` gave them. + "/pictures" => Ok(files_listing(&self.files, "/pictures")), _ => Ok(fake_listing(path.as_str())), }; events.send_blocking(SftpEvent::Listed { request_id, result })?; @@ -160,6 +162,24 @@ impl SftpTransport for FakeSftpTransport { }; events.send_blocking(SftpEvent::FileRead { request_id, result })?; } + SftpCommand::ReadBytes { + request_id, + path, + limit, + } => { + self.reads.lock().unwrap().push(path.to_string()); + if path.as_str().starts_with("/slow") { + continue; + } + let result = match self.files.lock().unwrap().get(path.as_str()) { + Some((bytes, _)) if bytes.len() as u64 > limit => { + Err(ReadFailure::TooLarge(bytes.len() as u64)) + } + Some((bytes, _)) => Ok(FileBytes::new(bytes.clone())), + None => Err(ReadFailure::Failed("文件不存在".into())), + }; + events.send_blocking(SftpEvent::BytesRead { request_id, result })?; + } SftpCommand::WriteFile { request_id, path, @@ -330,6 +350,29 @@ impl LocalDirectoryProvider for FakeLocalDirectory { } } +/// A directory of the fake server's files, as they are now. +pub fn files_listing(files: &FakeFiles, path: &str) -> DirectoryListing { + let prefix = format!("{path}/"); + let entries = files + .lock() + .unwrap() + .iter() + .filter_map(|(file, (bytes, modified))| { + let name = file.strip_prefix(&prefix)?; + Some(DirectoryEntry::new( + name, + FileMetadata::new( + EntryKind::File, + bytes.len() as u64, + Some(*modified), + Some(0o644), + ), + )) + }) + .collect(); + DirectoryListing::new(path, entries) +} + pub fn fake_listing(path: &str) -> DirectoryListing { DirectoryListing::new( path,