SFTP:断网和读目录错误显示在窗口左下角,标签增加终端的命令,空闲时也能发现断网

- 读不了目录、连接断开的原因不再插在列表上方,改为用红字显示在窗口
  状态栏最左边(平时写「已连接 会话名」处);SFTP 标签在前时那里显示
  这个标签自己的连接状态。终端标签的会话断开时也是红字
- SFTP 标签菜单增加打开 SFTP、复制 IP 地址 / 主机名、重新连接,标签栏
  和远程终端一样有「SFTP」「重新连接」按钮;重新连接在已连接时也可用
  (先断开再连),有批次在跑或停在队首时不可用
- russh-sftp 的「session closed」算作断网,断网后刷新不再停在
  「已连接」、无法重连
- SFTP 通道流包一层 WatchedStream,读到结尾即发出关闭信号;工作线程
  空闲时等它,和终端一样在 keepalive 关掉连接时立刻报告断开,不必等
  下一次请求。等待时不持有客户端,主动断开仍会立即关掉连接

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
XuzandClaude committed 2026-09-26 09:19:07 +08:00
1 parent 59a3ffd1a6
commit bf72546c64
12 files changed
+599 -171

No files matched your search

+6 -5
View File
@@ -44,8 +44,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `<data_dir>/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`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
@@ -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<SftpClient>`**:主动「断开」靠丢掉最后一个引用来关连接,监听攥着强引用会让 SSH 连接一直开着(`an_idle_connection_that_drops_is_reported_without_a_request` 用服务器端的 `ended` 计数守着)。`is_network_error` 也认 russh-sftp 的 `session closed`。
- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status`(`FilePane::render_status`)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。读不了目录、远程侧断开的原因是面板的 `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<SessionStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(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` / `bookmLine truncated
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(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` / `bookmLine truncated
+2 -2
View File
@@ -48,7 +48,7 @@ shellrs download <ID> <远程路径> <本地路径>
## 标签页
远程终端标签和 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 <ID> <远程路径> <本地路径>
- 书签的改动立即保存,按「取消」也不会撤销,与 WinSCP 相同。
- 回车或「打开」前往目录;Esc 或「取消」关闭。
远程侧断开时,原因显示在路径标签下方,旁边有「重新连接」。连接中和读取目录超过 0.3 秒时,列表下方的状态栏显示「正在连接 SFTP…」或「正在读取目录…」,列表本身不会移动。
列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 会话名」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单。这些都不会让列表移动。
列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。
+3
View File
@@ -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.
+145 -27
View File
@@ -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<Self>| {
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<Self>,
) -> 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<Self>) -> Option<Vec<Button>> {
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<SharedString>,
}
/// 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<WeakEntity<TabGroup>>,
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<Self>) -> 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<Self>) -> impl IntoElement {
+52 -81
View File
@@ -51,6 +51,12 @@ struct Marquee {
base: Option<Selection>,
}
/// 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<Task<()>>,
/// What went wrong reading this pane's directory, or why the remote
/// side dropped: the window's status line shows it, see `problem`.
error: Option<String>,
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>,
) {
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<Self>) {
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<String>, cx: &mut Context<Self>) {
if self.error != error {
self.error = error;
cx.emit(FilePaneEvent::ProblemChanged);
}
}
pub fn begin_load(&mut self, intent: LoadIntent, cx: &mut Context<Self>) -> 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<FilePaneEvent> for FilePane {}
impl Render for FilePane {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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))
}
}
+2 -2
View File
@@ -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;
+97 -14
View File
@@ -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<SshHandle>,
/// Turns true once the server side of the SFTP stream has ended.
closed: watch::Receiver<bool>,
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<SshHandle>,
fingerprint: String,
) -> Result<Self> {
async fn initialize<S>(stream: S, ssh: Option<SshHandle>, fingerprint: String) -> Result<Self>
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<Output = ()> + 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<RemotePath> {
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<S> {
inner: S,
closed: watch::Sender<bool>,
}
impl<S: AsyncRead + Unpin> AsyncRead for WatchedStream<S> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
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<S: AsyncWrite + Unpin> AsyncWrite for WatchedStream<S> {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
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::<russh::Error>().is_some_and(|e| {
@@ -390,8 +455,12 @@ pub(crate) fn is_network_error(error: &anyhow::Error) -> bool {
.downcast_ref::<Error>()
.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));
}
}
+77 -1
View File
@@ -24,14 +24,20 @@ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
struct TestServer {
directory: PathBuf,
connections: Arc<AtomicUsize>,
ended: Arc<AtomicUsize>,
shells: Arc<AtomicUsize>,
interrupts: Arc<AtomicUsize>,
hangup: Arc<tokio::sync::Notify>,
unsupported: bool,
}
struct Handler {
directory: PathBuf,
/// Counts connections that have ended, as this drops with its own.
ended: Arc<AtomicUsize>,
shells: Arc<AtomicUsize>,
interrupts: Arc<AtomicUsize>,
/// Ends an SFTP session while nothing is asked of it.
hangup: Arc<tokio::sync::Notify>,
unsupported: bool,
channels: HashMap<ChannelId, russh::Channel<server::Msg>>,
}
@@ -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<AtomicUsize>,
ended: Arc<AtomicUsize>,
shells: Arc<AtomicUsize>,
interrupts: Arc<AtomicUsize>,
hangup: Arc<tokio::sync::Notify>,
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 {
+39 -5
View File
@@ -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<Option<Arc<SftpClient>>>,
connected: &Arc<SftpClient>,
events: &Sender<SftpEvent>,
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<Option<Arc<SftpClient>>>,
connected: &Arc<SftpClient>,
events: &Sender<SftpEvent>,
) {
let mut current = shared.write().await;
if current.as_ref().is_some_and(|c| Arc::ptr_eq(c, connected)) {
*current = None;
+67 -13
View File
@@ -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<Session>),
Local(TerminalStatus),
/// An SFTP tab: its own connection and what went wrong in it.
Explorer(Option<Session>, 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<Session>, 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>, 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)
+15
View File
@@ -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);
+94 -21
View File
@@ -3908,6 +3908,8 @@ struct FakeSftpProvider {
events: Arc<Mutex<Vec<async_channel::Sender<SftpEvent>>>>,
/// When set, the next connection waits for a message before it is up.
hold_connection: Arc<Mutex<Option<mpsc::Receiver<()>>>>,
/// Connections made again on 重新连接 (a bare 继续).
reconnects: Arc<Mutex<usize>>,
}
impl SftpTransportProvider for FakeSftpProvider {
fn create(&self, _: &shellrs::session::Session) -> Box<dyn SftpTransport> {
@@ -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<Mutex<Vec<shellrs::sftp::RemoteOperation>>>,
events: Arc<Mutex<Vec<async_channel::Sender<SftpEvent>>>>,
hold: Option<mpsc::Receiver<()>>,
reconnects: Arc<Mutex<usize>>,
}
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.