From bf72546c64c30141d979cfe2a1deef138f8e648d Mon Sep 17 00:00:00 2001 From: Xuz Date: Sat, 26 Sep 2026 09:19:07 +0800 Subject: [PATCH] =?UTF-8?q?SFTP=EF=BC=9A=E6=96=AD=E7=BD=91=E5=92=8C?= =?UTF-8?q?=E8=AF=BB=E7=9B=AE=E5=BD=95=E9=94=99=E8=AF=AF=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=9C=A8=E7=AA=97=E5=8F=A3=E5=B7=A6=E4=B8=8B=E8=A7=92=EF=BC=8C?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=A2=9E=E5=8A=A0=E7=BB=88=E7=AB=AF=E7=9A=84?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=EF=BC=8C=E7=A9=BA=E9=97=B2=E6=97=B6=E4=B9=9F?= =?UTF-8?q?=E8=83=BD=E5=8F=91=E7=8E=B0=E6=96=AD=E7=BD=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 读不了目录、连接断开的原因不再插在列表上方,改为用红字显示在窗口 状态栏最左边(平时写「已连接 会话名」处);SFTP 标签在前时那里显示 这个标签自己的连接状态。终端标签的会话断开时也是红字 - SFTP 标签菜单增加打开 SFTP、复制 IP 地址 / 主机名、重新连接,标签栏 和远程终端一样有「SFTP」「重新连接」按钮;重新连接在已连接时也可用 (先断开再连),有批次在跑或停在队首时不可用 - russh-sftp 的「session closed」算作断网,断网后刷新不再停在 「已连接」、无法重连 - SFTP 通道流包一层 WatchedStream,读到结尾即发出关闭信号;工作线程 空闲时等它,和终端一样在 keepalive 关掉连接时立刻报告断开,不必等 下一次请求。等待时不持有客户端,主动断开仍会立即关掉连接 Co-Authored-By: Claude --- CLAUDE.md | 11 +- README.md | 4 +- src/app/actions.rs | 3 + src/explorer/explorer_panel.rs | 172 +++++++++++++++++++++++++++----- src/explorer/file_pane.rs | 133 ++++++++++-------------- src/explorer/mod.rs | 4 +- src/sftp/client.rs | 111 ++++++++++++++++++--- src/sftp/protocol_tests.rs | 78 ++++++++++++++- src/sftp/worker.rs | 44 +++++++- src/workspace/status_bar.rs | 80 ++++++++++++--- src/workspace/workspace_view.rs | 15 +++ tests/ui.rs | 115 +++++++++++++++++---- 12 files changed, 599 insertions(+), 171 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1d86afc..903790e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,8 +44,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `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`。 - **会话对话框的「测试连接」是真登录。** 会话模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(表单当前值,秘密用 `Zeroizing`,`Debug` 打码)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的最后一个服务参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 - `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。 -- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎)、`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`(删除确认、重命名 / 新建的名称表单)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载确认、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 -- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 +- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载确认、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 +- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 - `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是会话树里会话的那个菜单,两处都调用 `session::session_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 - `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。 @@ -70,7 +70,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **`Select` 必须放进有尺寸的容器。** 它的外层是 `size_full()`,直接放进工具栏行会盖住整行,别的按钮全点不到。 - **路径标签按上一帧的宽度折叠。** render 时拿不到布局宽度,所以 `path-parts` 里放一个 `canvas`,在 prepaint 记下宽度,变了就用 `on_next_frame` 通知面板重画(绘制阶段里 `notify` 会丢)。各段宽度在 render 里用 `text_system().shape_line` 按 `text_sm` 量,和实际布局一致(各段不加内边距,否则 `/ home/ tester/` 看起来像多了空格)。折叠规则是纯函数 `folded_parts`:留住根,中段换成 `…`,末尾尽量多留几级;`…` 点了去它藏起的最后一级(WinSCP 同)。「当前面板」是最近获得焦点的一侧(`on_focus_in`,必须 `defer_in`,见下条),路径标签一律是 `list_active` 底色,当前面板的再加一条 `list_active_border` 下边框。 - **焦点监听器里改渲染状态要 `defer_in`。** `on_focus_in` / `on_focus` / `on_blur` 在 `draw` 的焦点阶段同步执行,这时 `cx.notify()` 只记下脏视图、不置窗口脏也不排新帧,改动要等下一次别的原因重绘才看得见。点列表空白处(GPUI 对 `track_focus` 元素在按下时自动聚焦,没有别的处理器触发重绘)因此要点两下才切换当前面板;改为 `cx.defer_in` 在绘制结束后执行就好。测试 `sftp_clicking_empty_list_space_makes_that_pane_current_at_once` 不自己 `render_frame`,只看点击本身触发的帧。 -- **读目录不许挪动列表。** 路径标签和列表之间只放持续性的状态(出错 / 断开),「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status` 里,而且读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。 +- **SFTP 空闲时也要发现断网,和终端同时。** 终端靠主循环一直 `channel.wait()`,russh 的 keepalive(`SshConnector` 里 30 s × 3,所有 SSH 连接共用)一关掉连接它就返回。SFTP 连接同样会被关掉,但 russh-sftp 只在下一次请求时才报 `session closed`,而且不公开关闭状态。所以 `SftpClient` 把通道流包一层 `WatchedStream`:读到结尾或出错就置位一个 `watch`,`SftpClient::closed()` 等它;工作线程空闲等命令时同时等它,一触发就 `forget` 这个连接并发 `Disconnected`。等待时只持有 `Weak` 和那个 `watch` 接收端,**不能持有 `Arc`**:主动「断开」靠丢掉最后一个引用来关连接,监听攥着强引用会让 SSH 连接一直开着(`an_idle_connection_that_drops_is_reported_without_a_request` 用服务器端的 `ended` 计数守着)。`is_network_error` 也认 russh-sftp 的 `session closed`。 +- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status`(`FilePane::render_status`)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。读不了目录、远程侧断开的原因是面板的 `problem`,显示在窗口状态栏最左边、平时写「已连接 会话名」的 `status-connection`:SFTP 标签在前时,工作区用 `ExplorerPanel::status`(这个标签自己的连接状态,不是会话汇总的,加上 `problem`,远程侧优先)生成 `WorkspaceStatus::explorer`,未连接时写「未连接 会话名:原因」,已连接但读不了目录时只写原因,两种都是 `danger` 红字;会话(终端标签)断开时同样是红字。`problem` 变化时面板发 `FilePaneEvent::ProblemChanged`,标签转成 `ExplorerPanelEvent::StatusChanged`,工作区只在那个标签在前时 `notify`(不观察整个标签,传输进度每 50 ms 一次)。 - **「打开目录/书签」的选中项是推导出来的。** 列表里被选中的就是与目录框(按面板规则展开 `~`、相对路径并去掉末尾分隔符后)相同的书签,没有另存选中状态,和 WinSCP 的 `DirectoryEditChange` 一致:单击书签只是把它写进目录框。书签的增删和排序都派发 `ExplorerCommand`(`AddBookmark { path: Some(..) }`、`RemoveBookmark`、`MoveBookmark`),改动立即写库,取消对话框也不回滚(WinSCP 同)。对话框打开后在 `open_dialog` 同步返回之后立刻 `focus` 目录框并全选,这样能生效(`Root::open_dialog` 是同步聚焦对话框宿主的,之后同一更新里的聚焦会覆盖它);延迟到下一帧的聚焦才会失效。 - **书签按会话、按侧持久化。** `bookmarks` 表(schema v6)对 `sessions` 有 `ON DELETE CASCADE`;`remove_unnotified` / `remove_group_unnotified` 必须同时丢掉内存里被删会话的书签。`BookmarkSide` 放在 session 模块,因为 session 不能依赖 explorer。 - **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串;`FakeLocalDirectory` 只记录本地操作,不真的改文件。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。 @@ -110,7 +111,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 焦点匹配按回滚历史的增长量跟着输出上移,再用 `regex_search_right` 核对,对不上就丢掉。计数不随新输出刷新。 - **查找条**必须是终端 `div` 的兄弟元素,不能是子元素:否则输入框里敲的键会冒泡到终端的 `on_key_down`,被发给 shell。它还要 `.occlude()`,否则点它会被下面终端的鼠标处理器抢走焦点。 - **清屏**只在本地模拟器里做:把光标所在的逻辑行(含折行)挪到第 0 行,清空回滚历史,远端不知情。`ALT_SCREEN` 下什么也不做,菜单项置灰。 -- **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。中间区各面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 `custom_title`)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(`RenamableTab::default_title`:终端是会话名,SFTP 是「会话名 · SFTP」)时回到默认,默认名随会话改名。SFTP 标签的菜单没有「编辑会话…」,只有「重命名标签…」加关闭一族。 +- **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。中间区各面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 `custom_title`)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(`RenamableTab::default_title`:终端是会话名,SFTP 是「会话名 · SFTP」)时回到默认,默认名随会话改名。SFTP 标签的菜单(`explorer_panel.rs` 的 `TabMenu`)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑会话…」;标签栏按钮和远程终端一样是「SFTP」(`("open-sftp", id)`)和「重新连接」(`("reconnect-sftp", id)`)。SFTP 的「重新连接」是 `ExplorerCommand::Reconnect`:已连接时先 `disconnect` 再发 `Resume`(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。 - **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效,要预先聚焦某个字段只能在 `open_dialog` 返回后的同一次更新里同步调用(见 `open_directory_dialog.rs`)。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_session_stay_closable`)。 - **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。 - **会话行的 tooltip 开在行的右侧。** 悬停会话行 500 ms 后,行右侧显示 `user@host:port`(`session_tooltip`,不显示系统),配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字)。只有这一个 tooltip 这样:gpui-kit 的 tooltip 和菜单、下拉共用 `popover` 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip(`Button::tooltip_placement` 那套)是 crate 私有的,普通元素的 `.tooltip()` 只会贴着鼠标弹、盖住下面的行,所以 `SessionPanel` 自己持有一个 `gpui_kit::base::TooltipOverlay`(`row_tooltip`,作为面板的子元素渲染,面板收起就一起消失;动画 `animate_row_tooltip` 仿 gpui-kit 自己的)。行在 `on_hover` 里带着行的边界 `request_show(...placement(Placement::Right))`,任意鼠标按下立即 `hide`,拖动中不显示。**只有 tooltip 的当前主人才能收起它**(`row_tooltip_owner`):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 `request_hide` 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(`the_row_tooltip_follows_the_pointer_down_the_list`)。按键会收起 tooltip 是 GPUI 的 `HoverListenerMode::InputModalityAware`:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 `canvas` 在 prepaint 记下,**必须**带 `top_0().left_0()`:`ListItem` 的内容盒不是 `relative`,不设偏移的 canvas(gpui-base 的 `ElementExt::on_prepaint` 就是这样)会落到内容下方,tooltip 随之偏下将近一行。 @@ -123,4 +124,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-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`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `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)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`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`、文件拖动预览 `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`、远程断开时的 `reconnect-sftp`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、传输队列 `("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)` 才会重画。SFTP 测试的夹具会 `set_reduce_motion(true)`:对话框按真实时间滑入,小目标(复选框、「浏览…」)会在定位和点击之间移位。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-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`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `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)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`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`、文件拖动预览 `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)` 才会重画。SFTP 测试的夹具会 `set_reduce_motion(true)`:对话框按真实时间滑入,小目标(复选框、「浏览…」)会在定位和点击之间移位。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/README.md b/README.md index 978a978..b008462 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ shellrs download <远程路径> <本地路径> ## 标签页 -远程终端标签和 SFTP 标签都以主机系统徽章为图标(与会话列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「会话名称 · SFTP」)加关闭命令,本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。远程终端运行时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。批量关闭时,正在上传的 SFTP 标签仍会先确认。 +远程终端标签和 SFTP 标签都以主机系统徽章为图标(与会话列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「会话名称 · SFTP」)、打开 SFTP、复制 IP 地址 / 主机名、重新连接加关闭命令,标签栏右侧和远程终端一样有「SFTP」和「重新连接」按钮;本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。远程终端运行时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。批量关闭时,正在上传的 SFTP 标签仍会先确认。 ## 终端 @@ -94,7 +94,7 @@ shellrs download <远程路径> <本地路径> - 书签的改动立即保存,按「取消」也不会撤销,与 WinSCP 相同。 - 回车或「打开」前往目录;Esc 或「取消」关闭。 -远程侧断开时,原因显示在路径标签下方,旁边有「重新连接」。连接中和读取目录超过 0.3 秒时,列表下方的状态栏显示「正在连接 SFTP…」或「正在读取目录…」,列表本身不会移动。 +列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 会话名」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单。这些都不会让列表移动。 列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。 diff --git a/src/app/actions.rs b/src/app/actions.rs index fffb493..3aa2087 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -403,6 +403,9 @@ pub enum ExplorerCommand { CancelTransfer, /// Go on with the stopped batch at the head of the queue, or reconnect. ResumeTransfer, + /// 重新连接, as on a terminal tab: a fresh SFTP connection, dropping the + /// current one first. Not while a batch runs or waits stopped. + Reconnect, /// Throw away the stopped head's progress; the queue moves on. DiscardTransfer, /// Select a row of the transfer queue, by its id. diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index 5147522..804197c 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -1,11 +1,14 @@ use super::{ - ExplorerId, FilePane, LoadIntent, PaneSide, QueueEntry, QueueId, QueueState, Removal, - TransferJob, TransferQueue, + ExplorerId, FilePane, FilePaneEvent, LoadIntent, PaneSide, QueueEntry, QueueId, QueueState, + Removal, TransferJob, TransferQueue, pane_operations::{PaneOperation, PendingOperation}, }; use crate::app::ExplorerDispatch as _; use crate::{ - app::{CatalogIcon, CenterTab, CloseExplorer, ExplorerAction, ExplorerCommand, RenameExplorer}, + app::{ + CatalogIcon, CenterTab, CloseExplorer, CopySessionHost, ExplorerAction, ExplorerCommand, + OpenExplorer, RenameExplorer, + }, connection::{ConnectionPrompt, ConnectionPromptReply}, session::{BookmarkSide, ConnectionState, SessionId, SessionStore}, sftp::{ @@ -16,7 +19,8 @@ use crate::{ shared::{ClosableTabTitle, HostMark, RenamableTab, close_tab_items}, }; use gpui_kit::component::{ - Icon, Sizable as _, WindowExt as _, + Disableable as _, Icon, IconName, Sizable as _, WindowExt as _, + button::Button, dock::{BasePanel, Panel, PanelEvent, TabGroup}, menu::PopupMenu, notification::Notification, @@ -35,6 +39,9 @@ pub enum ExplorerPanelEvent { Activated(ExplorerId), Closed(ExplorerId, SessionId), StateChanged(ExplorerId, SessionId), + /// What the window's status line says for this tab changed without the + /// connection changing: a directory could not be read, or can again. + StatusChanged(ExplorerId), PromptRequested(ExplorerId, SessionId, u64, ConnectionPrompt), } pub struct ExplorerPanel { @@ -166,8 +173,13 @@ impl ExplorerPanel { }); let local_focus = local.read(cx).focus_handle(cx); let remote_focus = remote.read(cx).focus_handle(cx); + let problem_changed = |this: &mut Self, _, _: &FilePaneEvent, cx: &mut Context| { + cx.emit(ExplorerPanelEvent::StatusChanged(this.id)) + }; let subscriptions = vec![ cx.observe(&store, |_, _, cx| cx.notify()), + cx.subscribe(&local, problem_changed), + cx.subscribe(&remote, problem_changed), // Focus listeners run inside a draw, where a view's `notify` is // dropped without asking for another frame: a click on empty list // space would switch panes unseen. Deferring runs it after the draw. @@ -248,6 +260,22 @@ impl ExplorerPanel { pub fn connection_state(&self) -> ConnectionState { self.state } + /// What the window's status line shows for this tab: its own connection, + /// and what went wrong, remote side first. + pub fn status(&self, cx: &App) -> ExplorerStatus { + let problem = [&self.remote, &self.local] + .into_iter() + .find_map(|pane| pane.read(cx).problem().map(SharedString::from)); + ExplorerStatus { + state: self.state, + problem, + } + } + /// 重新连接 is there unless it is under way, or a batch runs or waits + /// stopped (that one goes on with its own 继续). + pub fn can_reconnect(&self) -> bool { + self.state != ConnectionState::Connecting && !self.engine_busy && !self.head_stopped() + } /// The engine's last report on the batch at the head of the queue. pub fn progress(&self) -> Option<&TransferProgress> { self.queue.head().and_then(QueueEntry::progress) @@ -332,15 +360,11 @@ impl ExplorerPanel { let state = self.state; // A batch confirmed while another runs waits its turn in the queue. let transfer = state == ConnectionState::Connected; - // A stopped transfer offers its own 继续 instead. - let reconnect = - state == ConnectionState::Disconnected && !self.head_stopped() && !self.engine_busy; self.local.update(cx, |pane, cx| { - pane.set_available(ConnectionState::Connected, transfer, false, cx) - }); - self.remote.update(cx, |pane, cx| { - pane.set_available(state, transfer, reconnect, cx) + pane.set_available(ConnectionState::Connected, transfer, cx) }); + self.remote + .update(cx, |pane, cx| pane.set_available(state, transfer, cx)); } /// WinSCP's current pane: the one used last, whose path label stands out /// and which takes focus back when the tab is shown again. @@ -712,6 +736,16 @@ impl ExplorerPanel { self.send(SftpCommand::Resume); } } + ExplorerCommand::Reconnect => { + if self.can_reconnect() { + if self.state == ConnectionState::Connected { + self.disconnect(window, cx); + } + // With no stopped batch, 继续 is a bare reconnect. + self.engine_busy = true; + self.send(SftpCommand::Resume); + } + } ExplorerCommand::DiscardTransfer => { if !self.engine_busy && self.head_stopped() { self.queue.abandon_head(Removal::Discarded); @@ -785,12 +819,13 @@ impl Panel for ExplorerPanel { .map(|s| s.name.clone()) .unwrap_or_else(|| "SFTP".into()); let os = session.and_then(|s| s.os); - let (id, group, panel) = (self.id, self.tab_group.clone(), cx.entity_id()); + let id = self.id; + let menu = self.tab_menu(cx); // The host's mark, as on the session's terminal tabs. let mark = HostMark::new(("explorer-tab-os", id.0), name, os).small(); ClosableTabTitle::new(("explorer-tab", id.0), mark, self.tab_title(cx)) .closable(("close-explorer", id.0), Box::new(CloseExplorer(id))) - .context_menu(move |menu, _, cx| tab_menu(menu, id, group.clone(), panel, cx)) + .context_menu(move |popup, _, cx| menu.build(popup, cx)) } fn dropdown_menu( &mut self, @@ -798,7 +833,30 @@ impl Panel for ExplorerPanel { _: &mut Window, cx: &mut Context, ) -> PopupMenu { - tab_menu(menu, self.id, self.tab_group.clone(), cx.entity_id(), cx) + self.tab_menu(cx).build(menu, cx) + } + /// A terminal tab's buttons that apply here too. + fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context) -> Option> { + let (id, session_id) = (self.id, self.session_id); + Some(vec![ + Button::new(("open-sftp", id.0)) + .icon(Icon::new(CatalogIcon::FolderTree)) + .label("SFTP") + .tooltip("打开 SFTP 文件浏览") + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(OpenExplorer(session_id)), cx) + }), + Button::new(("reconnect-sftp", id.0)) + .icon(Icon::new(CatalogIcon::RefreshCw)) + .tooltip("重新连接") + .disabled(!self.can_reconnect()) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new(id, ExplorerCommand::Reconnect)), + cx, + ) + }), + ]) } fn inner_padding(&self, _: &App) -> bool { false @@ -824,23 +882,83 @@ impl RenamableTab for ExplorerPanel { cx.notify(); } } -/// The commands of an SFTP tab, shared by its context menu and the tab bar's -/// 「…」 menu. -fn tab_menu( - menu: PopupMenu, +/// The status of an SFTP tab for the window's status line. +pub struct ExplorerStatus { + pub state: ConnectionState, + /// What went wrong reading a directory, or why the connection dropped. + pub problem: Option, +} + +/// The commands of an SFTP tab, as a snapshot taken while the tab renders. +/// Its context menu and the tab bar's 「…」 menu both build from it, so the +/// two always list the same commands. +#[derive(Clone)] +struct TabMenu { id: ExplorerId, + session_id: SessionId, + host_is_ip: bool, + can_reconnect: bool, group: Option>, panel: EntityId, - cx: &App, -) -> PopupMenu { - let menu = menu - .menu_with_icon( - "重命名标签…", - Icon::new(CatalogIcon::Pencil), - Box::new(RenameExplorer(id)), +} + +impl ExplorerPanel { + fn tab_menu(&self, cx: &Context) -> TabMenu { + TabMenu { + id: self.id, + session_id: self.session_id, + host_is_ip: self + .store + .read(cx) + .session(self.session_id) + .is_some_and(|session| session.host_is_ip()), + can_reconnect: self.can_reconnect(), + group: self.tab_group.clone(), + panel: cx.entity_id(), + } + } +} + +impl TabMenu { + /// A terminal tab's commands that apply to an SFTP tab, then closing. + fn build(&self, menu: PopupMenu, cx: &App) -> PopupMenu { + let (id, session_id) = (self.id, self.session_id); + let copy_host = if self.host_is_ip { + "复制 IP 地址" + } else { + "复制主机名" + }; + let menu = menu + .menu_with_icon( + "重命名标签…", + Icon::new(CatalogIcon::Pencil), + Box::new(RenameExplorer(id)), + ) + .menu_with_icon( + "打开 SFTP", + Icon::new(CatalogIcon::FolderTree), + Box::new(OpenExplorer(session_id)), + ) + .menu_with_icon( + copy_host, + Icon::new(IconName::Copy), + Box::new(CopySessionHost(session_id)), + ) + .menu_with_icon_and_disabled( + "重新连接", + Icon::new(CatalogIcon::RefreshCw), + Box::new(ExplorerAction::new(id, ExplorerCommand::Reconnect)), + !self.can_reconnect, + ) + .separator(); + close_tab_items( + menu, + CenterTab::Explorer(id), + self.group.clone(), + self.panel, + cx, ) - .separator(); - close_tab_items(menu, CenterTab::Explorer(id), group, panel, cx) + } } impl Render for ExplorerPanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { diff --git a/src/explorer/file_pane.rs b/src/explorer/file_pane.rs index 0cbe7c3..b00321a 100644 --- a/src/explorer/file_pane.rs +++ b/src/explorer/file_pane.rs @@ -51,6 +51,12 @@ struct Marquee { base: Option, } +/// What changed in a pane that its tab passes on. +pub enum FilePaneEvent { + /// The pane's `problem` appeared, changed or went away. + ProblemChanged, +} + /// What a drag from outside a name carries: only whose it is. GPUI tells /// every listener for the type about every drag of it, so the other pane, /// and other SFTP tabs, must leave this one alone. @@ -189,10 +195,10 @@ pub struct FilePane { /// The load in flight has taken long enough to say so. slow_load: bool, slow_load_timer: Option>, + /// What went wrong reading this pane's directory, or why the remote + /// side dropped: the window's status line shows it, see `problem`. error: Option, connection: ConnectionState, - /// The remote pane offers 重新连接. - can_reconnect: bool, transfer_enabled: bool, /// A file operation on this pane is running. busy: bool, @@ -275,7 +281,6 @@ impl FilePane { } else { ConnectionState::Connecting }, - can_reconnect: false, transfer_enabled: false, busy: false, store, @@ -417,11 +422,9 @@ impl FilePane { &mut self, connection: ConnectionState, transfer: bool, - can_reconnect: bool, cx: &mut Context, ) { self.connection = connection; - self.can_reconnect = can_reconnect; self.transfer_enabled = transfer; self.sync_listing(cx); cx.notify(); @@ -626,6 +629,33 @@ impl FilePane { } } + /// The status line under the list: connecting, reading or the item + /// count. It is always there, so none of these move the list. What went + /// wrong goes to the window's status line instead, see `problem`. + fn render_status(&self, file_count: usize, selected: usize, cx: &App) -> impl IntoElement { + let status = if self.connection == ConnectionState::Connecting { + "正在连接 SFTP…".to_string() + } else if self.slow_load { + "正在读取目录…".to_string() + } else if selected == 0 { + format!("{file_count} 个项目") + } else { + format!("{file_count} 个项目 · 已选择 {selected} 项") + }; + h_flex() + .id("pane-status") + .test_support() + .role(Role::Status) + .aria_label(status.clone()) + .px_2() + .py_1() + .border_t_1() + .border_color(cx.theme().border) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(status) + } + fn end_marquee(&mut self, cx: &mut Context) { if self.marquee.take().is_some() { cx.notify(); @@ -696,14 +726,25 @@ impl FilePane { self.request_id += 1; self.pending = None; self.finish_loading(); - self.error = Some(message); + self.set_error(Some(message), cx); cx.notify(); } + /// What went wrong reading the directory, or why the remote side + /// dropped, until the next load clears it. + pub fn problem(&self) -> Option<&str> { + self.error.as_deref() + } + fn set_error(&mut self, error: Option, cx: &mut Context) { + if self.error != error { + self.error = error; + cx.emit(FilePaneEvent::ProblemChanged); + } + } pub fn begin_load(&mut self, intent: LoadIntent, cx: &mut Context) -> u64 { self.request_id += 1; self.pending = Some((self.request_id, intent, self.path.clone())); self.loading = true; - self.error = None; + self.set_error(None, cx); // Most directories arrive at once; saying 正在读取 for those only // flickers, so it waits. let id = self.request_id; @@ -751,7 +792,7 @@ impl FilePane { self.hovered_part = None; } self.path = listing.path().into(); - self.error = None; + self.set_error(None, cx); let mut rows: Vec<_> = listing .entries() .iter() @@ -772,7 +813,7 @@ impl FilePane { } Err(error) => { self.select_after_load = None; - self.error = Some(error); + self.set_error(Some(error), cx); } } cx.notify(); @@ -855,6 +896,7 @@ impl FilePane { } } +impl EventEmitter for FilePane {} impl Render for FilePane { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let remote = self.is_remote(); @@ -1085,53 +1127,6 @@ impl Render for FilePane { .child(navigation) .child(operations) .child(self.render_path_label(window, cx)) - // Only lasting trouble goes above the list; passing states such - // as connecting and reading go in the status line under it, so the - // list never jumps. - .map(|this| { - if self.connection == ConnectionState::Connecting { - this - } else if let Some(error) = self.error.clone() { - let dispatch = self.dispatch.clone(); - this.child( - h_flex() - .gap_2() - .px_2() - .py_1() - .child( - div() - .id("directory-error") - .test_support() - .flex_1() - .min_w_0() - .text_sm() - .text_color(cx.theme().danger) - .child(error), - ) - // WinSCP's disconnected panel offers to reconnect. - .when(self.can_reconnect, |this| { - this.child( - Button::new("reconnect-sftp") - .ghost() - .small() - .label("重新连接") - .on_click(move |_, window, cx| { - dispatch.dispatch_explorer_action( - &ExplorerAction::new( - sid, - ExplorerCommand::ResumeTransfer, - ), - window, - cx, - ) - }), - ) - }), - ) - } else { - this - } - }) .child( div() .id(("file-list", u64::from(remote))) @@ -1330,30 +1325,6 @@ impl Render for FilePane { }) .children(self.marquee_rectangle(cx)), ) - .child({ - // The status line is always there, so connecting and reading - // a directory never move the list. - let status = if self.connection == ConnectionState::Connecting { - "正在连接 SFTP…".to_string() - } else if self.slow_load { - "正在读取目录…".to_string() - } else if selected == 0 { - format!("{file_count} 个项目") - } else { - format!("{file_count} 个项目 · 已选择 {selected} 项") - }; - h_flex() - .id("pane-status") - .test_support() - .role(Role::Status) - .aria_label(status.clone()) - .px_2() - .py_1() - .border_t_1() - .border_color(cx.theme().border) - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(status) - }) + .child(self.render_status(file_count, selected, cx)) } } diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs index 5496f7e..8270743 100644 --- a/src/explorer/mod.rs +++ b/src/explorer/mod.rs @@ -15,10 +15,10 @@ mod queue_panel; mod selection; mod transfer_queue; -pub use explorer_panel::{ExplorerPanel, ExplorerPanelEvent}; +pub use explorer_panel::{ExplorerPanel, ExplorerPanelEvent, ExplorerStatus}; pub use file_dialogs::validate_entry_name; pub use file_listing::FileListing; -pub use file_pane::{FilePane, PaneSide}; +pub use file_pane::{FilePane, FilePaneEvent, PaneSide}; pub use history::{LoadIntent, NavigationHistory}; pub use model::*; pub use properties_dialog::PermissionDraft; diff --git a/src/sftp/client.rs b/src/sftp/client.rs index 38e7f07..5245300 100644 --- a/src/sftp/client.rs +++ b/src/sftp/client.rs @@ -6,7 +6,15 @@ use russh_sftp::{ client::{RawSftpSession, error::Error}, protocol::{FileAttributes, OpenFlags, Packet, StatusCode}, }; -use std::sync::Arc; +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + sync::watch, +}; /// Small protocol seam, also implemented by deterministic fault-injection tests. pub(crate) trait RemoteFs { @@ -45,6 +53,8 @@ pub(crate) trait RemoteFs { pub(crate) struct SftpClient { raw: RawSftpSession, _ssh: Option, + /// Turns true once the server side of the SFTP stream has ended. + closed: watch::Receiver, fingerprint: String, atomic_replace: bool, fsync: bool, @@ -59,21 +69,20 @@ impl SftpClient { tokio::time::timeout(std::time::Duration::from_secs(15), async { let channel = ssh.channel_open_session().await?; channel.request_subsystem(true, "sftp").await?; - Self::initialize( - RawSftpSession::new(channel.into_stream()), - Some(ssh), - fingerprint, - ) - .await + Self::initialize(channel.into_stream(), Some(ssh), fingerprint).await }) .await .map_err(|_| anyhow!("启动 SFTP 超时"))? } - async fn initialize( - raw: RawSftpSession, - ssh: Option, - fingerprint: String, - ) -> Result { + async fn initialize(stream: S, ssh: Option, fingerprint: String) -> Result + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + let (closed_tx, closed) = watch::channel(false); + let raw = RawSftpSession::new(WatchedStream { + inner: stream, + closed: closed_tx, + }); let version = raw.init().await?; if version.version != 3 { bail!("服务器未提供 SFTP v3"); @@ -89,6 +98,7 @@ impl SftpClient { Ok(Self { raw, _ssh: ssh, + closed, fingerprint, atomic_replace, fsync, @@ -111,13 +121,22 @@ impl SftpClient { .kill_on_drop(true) .spawn()?; let stream = tokio::io::join(child.stdout.take().unwrap(), child.stdin.take().unwrap()); - let client = - Self::initialize(RawSftpSession::new(stream), None, "local-test-key".into()).await?; + let client = Self::initialize(stream, None, "local-test-key".into()).await?; Ok((client, child)) } pub fn fingerprint(&self) -> &str { &self.fingerprint } + /// Resolves once the server side of the SFTP session has gone, which + /// russh-sftp otherwise only tells the next request. It does not hold on + /// to the client, so dropping the client still closes the connection. + pub fn closed(&self) -> impl Future + Send + 'static { + let mut closed = self.closed.clone(); + async move { + // An error means the stream itself is gone: closed too. + let _ = closed.wait_for(|closed| *closed).await; + } + } pub async fn canonicalize(&self, path: &RemotePath) -> Result { let response = self.raw.realpath(path.as_str()).await?; RemotePath::new(checked_text( @@ -361,6 +380,52 @@ fn metadata(attrs: &FileAttributes) -> FileMetadata { attrs.permissions, ) } +/// The SFTP stream, telling when the server side has ended. russh ends it +/// when the channel or the whole SSH connection closes, including when +/// keepalives go unanswered after the network drops; russh-sftp keeps that +/// to itself until the next request fails. Watching it lets an idle SFTP tab +/// notice at the same moment a terminal on the same host does. +struct WatchedStream { + inner: S, + closed: watch::Sender, +} + +impl AsyncRead for WatchedStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let wanted = buf.remaining() > 0; + let before = buf.filled().len(); + let poll = Pin::new(&mut this.inner).poll_read(cx, buf); + // Nothing read into room for something is the end of the stream. + if let Poll::Ready(result) = &poll + && (result.is_err() || (wanted && buf.filled().len() == before)) + { + this.closed.send_replace(true); + } + poll + } +} + +impl AsyncWrite for WatchedStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write(cx, buf) + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} + pub(crate) fn is_network_error(error: &anyhow::Error) -> bool { error.chain().any(|cause| { if cause.downcast_ref::().is_some_and(|e| { @@ -390,8 +455,12 @@ pub(crate) fn is_network_error(error: &anyhow::Error) -> bool { .downcast_ref::() .is_some_and(|error| match error { Error::IO(_) | Error::Timeout => true, + // `session closed`: the SSH channel under the SFTP session is + // gone, as when the network drops; russh-sftp says so before + // sending anything more. Error::UnexpectedBehavior(message) => { message == "sender dropped" + || message == "session closed" || message.contains("SendError") || message.contains("RecvError") } @@ -464,3 +533,17 @@ mod longname_tests { ); } } + +#[cfg(test)] +mod network_error_tests { + use super::{Error, is_network_error}; + + #[test] + fn a_closed_session_is_a_lost_connection() { + let closed = anyhow::Error::new(Error::UnexpectedBehavior("session closed".into())) + .context("无法读取目录"); + assert!(is_network_error(&closed)); + let other = anyhow::Error::new(Error::UnexpectedBehavior("bad packet".into())); + assert!(!is_network_error(&other)); + } +} diff --git a/src/sftp/protocol_tests.rs b/src/sftp/protocol_tests.rs index 145bf75..3dd8de4 100644 --- a/src/sftp/protocol_tests.rs +++ b/src/sftp/protocol_tests.rs @@ -24,14 +24,20 @@ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; struct TestServer { directory: PathBuf, connections: Arc, + ended: Arc, shells: Arc, interrupts: Arc, + hangup: Arc, unsupported: bool, } struct Handler { directory: PathBuf, + /// Counts connections that have ended, as this drops with its own. + ended: Arc, shells: Arc, interrupts: Arc, + /// Ends an SFTP session while nothing is asked of it. + hangup: Arc, unsupported: bool, channels: HashMap>, } @@ -41,13 +47,20 @@ impl server::Server for TestServer { self.connections.fetch_add(1, Ordering::SeqCst); Handler { directory: self.directory.clone(), + ended: self.ended.clone(), shells: self.shells.clone(), interrupts: self.interrupts.clone(), + hangup: self.hangup.clone(), unsupported: self.unsupported, channels: HashMap::new(), } } } +impl Drop for Handler { + fn drop(&mut self) { + self.ended.fetch_add(1, Ordering::SeqCst); + } +} impl server::Handler for Handler { type Error = anyhow::Error; async fn auth_password( @@ -95,6 +108,7 @@ impl server::Handler for Handler { let stream = self.channels.remove(&channel).unwrap().into_stream(); let directory = self.directory.clone(); let interrupts = self.interrupts.clone(); + let hangup = self.hangup.clone(); tokio::spawn(async move { let executable = if cfg!(target_os = "macos") { "/usr/libexec/sftp-server" @@ -139,7 +153,11 @@ impl server::Handler for Handler { input.flush().await?; } }; - tokio::select! { _ = forward=>{}, _=tokio::io::copy(&mut output,&mut writer)=>{} } + tokio::select! { + _ = forward => {} + _ = tokio::io::copy(&mut output, &mut writer) => {} + _ = hangup.notified() => {} + } child.kill().await.ok(); child.wait().await.ok(); }); @@ -149,8 +167,10 @@ impl server::Handler for Handler { struct Running { port: u16, connections: Arc, + ended: Arc, shells: Arc, interrupts: Arc, + hangup: Arc, task: tokio::task::JoinHandle<()>, } impl Drop for Running { @@ -169,13 +189,17 @@ async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Opt }; let port = listener.local_addr().unwrap().port(); let connections = Arc::new(AtomicUsize::new(0)); + let ended = Arc::new(AtomicUsize::new(0)); let shells = Arc::new(AtomicUsize::new(0)); let interrupts = Arc::new(AtomicUsize::new(interrupts)); + let hangup = Arc::new(tokio::sync::Notify::new()); let mut server = TestServer { directory, connections: connections.clone(), + ended: ended.clone(), shells: shells.clone(), interrupts: interrupts.clone(), + hangup: hangup.clone(), unsupported, }; let key = russh::keys::PrivateKey::random( @@ -196,8 +220,10 @@ async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Opt Some(Running { port, connections, + ended, shells, interrupts, + hangup, task, }) } @@ -329,6 +355,56 @@ fn worker_uses_sftp_without_shell_and_reconnects_after_interrupted_write() { }); } +/// A connection that drops while nothing is asked of it is reported at +/// once, as a terminal's is, not at the next request; 继续 connects again. +#[test] +fn an_idle_connection_that_drops_is_reported_without_a_request() { + runtime().block_on(async { + let temp = tempfile::tempdir().unwrap(); + let Some(server) = server(temp.path().into(), 0, false).await else { + return; + }; + let worker = worker(server.port, temp.path()); + loop { + match next(&worker).await { + SftpEvent::Connected { .. } => break, + SftpEvent::Disconnected(e) => panic!("connect failed: {e}"), + _ => {} + } + } + server.hangup.notify_one(); + loop { + match next(&worker).await { + SftpEvent::Disconnected(reason) => { + assert_eq!(reason, "SFTP 连接中断,请重新连接"); + break; + } + SftpEvent::Connected { .. } => panic!("still connected"), + _ => {} + } + } + worker.commands.send(SftpCommand::Resume).await.unwrap(); + loop { + match next(&worker).await { + SftpEvent::Connected { .. } => break, + SftpEvent::Disconnected(e) => panic!("reconnect failed: {e}"), + _ => {} + } + } + assert_eq!(server.connections.load(Ordering::SeqCst), 2); + + // Watching an idle connection must not keep one open after 断开. + worker.commands.send(SftpCommand::Disconnect).await.unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while server.ended.load(Ordering::SeqCst) < 2 { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the connection stayed open after 断开"); + }); +} + #[test] fn unsupported_sftp_does_not_launch_shell_or_loop_authentication() { runtime().block_on(async { diff --git a/src/sftp/worker.rs b/src/sftp/worker.rs index a6ceb11..a9b8020 100644 --- a/src/sftp/worker.rs +++ b/src/sftp/worker.rs @@ -261,7 +261,33 @@ impl SshSftpTransport { } let mut stop = shutdown.clone(); while !*stop.borrow() { - let operation = tokio::select! { operation = operations.recv() => match operation { Ok(operation) => operation, Err(_) => break }, _ = stop.changed() => break }; + // Weakly: a client dropped on 断开 must still close its connection. + let watched = client + .read() + .await + .as_ref() + .map(|connected| (Arc::downgrade(connected), connected.closed())); + let (watched, closed) = watched.unzip(); + let operation = tokio::select! { + operation = operations.recv() => match operation { + Ok(operation) => operation, + Err(_) => break, + }, + _ = stop.changed() => break, + // An idle connection that drops says so at once, as a + // terminal on the same host does, not at the next request. + _ = async { + match closed { + Some(closed) => closed.await, + None => std::future::pending().await, + } + } => { + if let Some(connected) = watched.and_then(|weak| weak.upgrade()) { + forget(&client, &connected, &events).await; + } + continue; + } + }; if client.read().await.is_none() { match control .run(self.connect(prompts.clone(), &events, &shutdown, &client)) @@ -514,17 +540,25 @@ impl SshSftpTransport { } } -/// A request failed because the connection broke: forget that client (unless -/// a newer one already replaced it) and tell the panel once. +/// A request failed: if the connection broke, `forget` it. async fn drop_broken( shared: &RwLock>>, connected: &Arc, events: &Sender, error: &anyhow::Error, ) { - if !is_network_error(error) { - return; + if is_network_error(error) { + forget(shared, connected, events).await; } +} + +/// The connection is gone: forget that client (unless a newer one already +/// replaced it) and tell the panel once. +async fn forget( + shared: &RwLock>>, + connected: &Arc, + events: &Sender, +) { let mut current = shared.write().await; if current.as_ref().is_some_and(|c| Arc::ptr_eq(c, connected)) { *current = None; diff --git a/src/workspace/status_bar.rs b/src/workspace/status_bar.rs index 5493c87..2d27a85 100644 --- a/src/workspace/status_bar.rs +++ b/src/workspace/status_bar.rs @@ -6,12 +6,15 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::CatalogIcon; +use crate::explorer::ExplorerStatus; use crate::session::{ConnectionState, Session}; use crate::terminal::{TerminalLifecycle, TerminalStatus}; pub enum WorkspaceStatusSource { Session(Option), Local(TerminalStatus), + /// An SFTP tab: its own connection and what went wrong in it. + Explorer(Option, ExplorerStatus), } /// The active connection/process state on the left and terminal facts on the @@ -33,29 +36,43 @@ impl WorkspaceStatus { source: WorkspaceStatusSource::Local(status), } } + + pub fn explorer(session: Option, status: ExplorerStatus) -> Self { + Self { + source: WorkspaceStatusSource::Explorer(session, status), + } + } +} + +/// The icon for a connection state; a dropped one, like a problem, in red. +fn state_icon(state: ConnectionState, cx: &App) -> Icon { + match state { + ConnectionState::Connected => { + Icon::new(IconName::CircleCheck).text_color(cx.theme().success) + } + ConnectionState::Connecting => { + Icon::new(IconName::LoaderCircle).text_color(cx.theme().warning) + } + ConnectionState::Disconnected => { + Icon::new(CatalogIcon::Unplug).text_color(cx.theme().danger) + } + } } impl RenderOnce for WorkspaceStatus { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let muted = cx.theme().muted_foreground; + // Red when something is wrong: a dropped connection, or an SFTP + // tab's directory that could not be read. + let mut alarming = false; let (text, icon, address, cursor): (SharedString, Icon, Option, String) = match self.source { WorkspaceStatusSource::Session(active) => match active { Some(session) => { - let icon = match session.state { - ConnectionState::Connected => { - Icon::new(IconName::CircleCheck).text_color(cx.theme().success) - } - ConnectionState::Connecting => { - Icon::new(IconName::LoaderCircle).text_color(cx.theme().warning) - } - ConnectionState::Disconnected => { - Icon::new(CatalogIcon::Unplug).text_color(muted) - } - }; + alarming = session.state == ConnectionState::Disconnected; ( format!("{} {}", session.state.label(), session.name).into(), - icon, + state_icon(session.state, cx), Some(session.address()), "行 1,列 1".into(), ) @@ -67,6 +84,36 @@ impl RenderOnce for WorkspaceStatus { "行 1,列 1".into(), ), }, + WorkspaceStatusSource::Explorer(session, status) => { + let name = session + .as_ref() + .map_or_else(|| "SFTP".into(), |session| session.name.clone()); + let state = format!("{} {name}", status.state.label()); + let (text, icon) = match (status.state, status.problem) { + (ConnectionState::Disconnected, Some(problem)) => { + alarming = true; + (format!("{state}:{problem}"), state_icon(status.state, cx)) + } + // Connected, but a directory could not be read. + (ConnectionState::Connected, Some(problem)) => { + alarming = true; + ( + problem.to_string(), + Icon::new(IconName::CircleX).text_color(cx.theme().danger), + ) + } + (now, _) => { + alarming = now == ConnectionState::Disconnected; + (state, state_icon(now, cx)) + } + }; + ( + text.into(), + icon, + session.map(|session| session.address()), + "行 1,列 1".into(), + ) + } WorkspaceStatusSource::Local(status) => { let lifecycle = status.lifecycle(); let icon = match lifecycle { @@ -95,13 +142,20 @@ impl RenderOnce for WorkspaceStatus { } }; + let danger = cx.theme().danger; StatusBar::new() .left( div() .id("status-connection") .test_support() .aria_label(text.clone()) - .child(h_flex().gap_1().child(icon.small()).child(text)), + .child( + h_flex() + .gap_1() + .when(alarming, |this| this.text_color(danger)) + .child(icon.small()) + .child(text), + ), ) .when_some(address, |bar, address| { bar.left(Separator::vertical()).left(address) diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 54e111d..05406e0 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -2006,6 +2006,11 @@ fn new_explorer_panel( } this.refresh_session_connection_state(*session_id, cx); }, + ExplorerPanelEvent::StatusChanged(id) => { + if this.active_tab == Some(CenterTab::Explorer(*id)) { + cx.notify(); + } + } ExplorerPanelEvent::PromptRequested(id, session_id, generation, prompt) => this.enqueue_prompt( PromptOwner::Sftp(*id, *generation), *session_id, @@ -2057,6 +2062,16 @@ impl Render for Workspace { .get(&id) .map(|panel| WorkspaceStatus::local(panel.read(cx).status(cx))) .unwrap_or_else(|| WorkspaceStatus::session(active)), + // An SFTP tab tells its own connection, not the session's, and + // what went wrong in it. + Some(CenterTab::Explorer(id)) => match self.explorers.get(&id) { + Some(panel) => { + let panel = panel.read(cx); + let session = self.store.read(cx).session(panel.session_id()).cloned(); + WorkspaceStatus::explorer(session, panel.status(cx)) + } + None => WorkspaceStatus::session(active), + }, _ => WorkspaceStatus::session(active), }; let sessions_visible = self.dock_area.read(cx).is_dock_open(DockPlacement::Left); diff --git a/tests/ui.rs b/tests/ui.rs index 637752b..6d98b5a 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -3908,6 +3908,8 @@ struct FakeSftpProvider { events: Arc>>>, /// When set, the next connection waits for a message before it is up. hold_connection: Arc>>>, + /// Connections made again on 重新连接 (a bare 继续). + reconnects: Arc>, } impl SftpTransportProvider for FakeSftpProvider { fn create(&self, _: &shellrs::session::Session) -> Box { @@ -3917,6 +3919,7 @@ impl SftpTransportProvider for FakeSftpProvider { operations: self.operations.clone(), events: self.events.clone(), hold: self.hold_connection.lock().unwrap().take(), + reconnects: self.reconnects.clone(), }) } } @@ -3926,6 +3929,7 @@ struct FakeSftpTransport { operations: Arc>>, events: Arc>>>, hold: Option>, + reconnects: Arc>, } impl SftpTransport for FakeSftpTransport { fn run( @@ -3941,6 +3945,9 @@ impl SftpTransport for FakeSftpTransport { events.send_blocking(SftpEvent::Connected { home: RemotePath::new("/home/tester")?, })?; + // As in the real engine, 继续 goes on with a stopped batch, and with + // none it only reconnects. + let mut stopped = false; while let Ok(command) = commands.recv_blocking() { match command { SftpCommand::List { request_id, path } => { @@ -3983,23 +3990,34 @@ impl SftpTransport for FakeSftpTransport { // The real engine says Idle once a batch stops or ends, // which is when the queue may move on. SftpCommand::Cancel => { + stopped = true; events.send_blocking(SftpEvent::Progress(TransferProgress::new( TransferPhase::Stopped, )))?; events.send_blocking(SftpEvent::Idle)?; } SftpCommand::Discard => { + stopped = false; events.send_blocking(SftpEvent::Progress(TransferProgress::new( TransferPhase::Completed, )))?; events.send_blocking(SftpEvent::Idle)?; } - SftpCommand::Resume => { + SftpCommand::Resume if stopped => { + stopped = false; events.send_blocking(SftpEvent::Progress(TransferProgress::new( TransferPhase::Transferring, )))?; } + SftpCommand::Resume => { + *self.reconnects.lock().unwrap() += 1; + events.send_blocking(SftpEvent::Connected { + home: RemotePath::new("/home/tester")?, + })?; + events.send_blocking(SftpEvent::Idle)?; + } SftpCommand::Answer { answer, .. } => { + stopped = answer.choice() == TransferChoice::Cancel; events.send_blocking(SftpEvent::Progress(TransferProgress::new( if answer.choice() == TransferChoice::Cancel { TransferPhase::Stopped @@ -4933,9 +4951,10 @@ async fn sftp_toolbar_goes_up_root_home_back_and_forward(cx: &mut TestAppContext ); }) .unwrap(); + // In red at the window's bottom left, where the connection shows. cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); - window.try_find("directory-error").is_some() + window.find("status-connection").label() == Some("权限不足") }) .await; cx.update(|cx| { @@ -5357,42 +5376,96 @@ async fn sftp_path_label_folds_the_middle_of_a_long_path(cx: &mut TestAppContext .unwrap(); } +/// An SFTP tab has a terminal tab's buttons: 打开 SFTP opens another tab of +/// the session, and 重新连接 connects again, from a dropped connection or a +/// live one. Its connection, and why it dropped, show in red at the window's +/// bottom left, so the list is never pushed around. #[gpui_kit::test] -async fn sftp_disconnected_remote_pane_offers_to_reconnect(cx: &mut TestAppContext) { +async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left( + cx: &mut TestAppContext, +) { let provider = Arc::new(FakeSftpProvider::default()); - let (handle, _) = open_workspace_with_sftp(cx, provider.clone()); + let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone()); open_test_explorer(cx, handle).await; - cx.update_window(handle.into(), |_, window, cx| { - window.render_frame(cx); - assert!(window.try_find("reconnect-sftp").is_none()); - }) - .unwrap(); + let reconnect = ("reconnect-sftp", SFTP_TAB); + let status_is = |expected: &'static str| { + move |window: &mut gpui_kit::Window, cx: &mut App| { + window.render_frame(cx); + window.find("status-connection").label() == Some(expected) + } + }; + let table = cx + .update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!( + window.find("status-connection").label(), + Some("已连接 db-01") + ); + window + .within(("remote-pane", SFTP_TAB)) + .find("table") + .bounds() + }) + .unwrap(); + // Dropped: the reason goes to the window's status line. let events = provider.events.lock().unwrap()[0].clone(); events .send_blocking(SftpEvent::Disconnected("连接已断开".into())) .unwrap(); - cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { - window.render_frame(cx); - window - .within(("remote-pane", SFTP_TAB)) - .try_find("reconnect-sftp") - .is_some() - }) + cx.wait_for( + handle.into(), + Duration::from_secs(2), + status_is("未连接 db-01:连接已断开"), + ) .await; cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - let mut remote = window.within(("remote-pane", SFTP_TAB)); - assert!(remote.find("directory-error").visible()); + let remote = window.within(("remote-pane", SFTP_TAB)); + assert_eq!(remote.find("table").bounds(), table); assert!(!enabled(&remote.find("refresh"))); - remote.click("reconnect-sftp", cx); + assert_ne!(remote.find("pane-status").label(), Some("连接已断开")); + window.click(reconnect, cx); }) .unwrap(); - cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + cx.wait_for( + handle.into(), + Duration::from_secs(2), + status_is("已连接 db-01"), + ) + .await; + assert_eq!(*provider.reconnects.lock().unwrap(), 1); + + // Connected, it drops the connection and makes a new one. + cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.try_find("reconnect-sftp").is_none() + window.click(reconnect, cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| { + *provider.reconnects.lock().unwrap() == 2 }) .await; + cx.wait_for( + handle.into(), + Duration::from_secs(2), + status_is("已连接 db-01"), + ) + .await; + + // 打开 SFTP opens another tab of the same session. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click(("open-sftp", SFTP_TAB), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + workspace.read(cx).explorers_of(SessionId(DB_01), cx).len(), + 2 + ) + }); } /// Click a row in one pane, then press a key with the list focused.