From 1e81f85343bbe21acfc4d5fdecc4e4726d999064 Mon Sep 17 00:00:00 2001 From: Xuz Date: Wed, 30 Sep 2026 18:06:07 +0800 Subject: [PATCH] =?UTF-8?q?SFTP=EF=BC=9A=E6=96=AD=E7=BA=BF=E6=97=B6?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E8=BF=9C=E7=A8=8B=E4=BE=A7=E5=BC=B9=E5=87=BA?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E5=AF=B9=E8=AF=9D=E6=A1=86=EF=BC=8C=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=A0=8F=E4=B8=8D=E6=8D=A2=E8=A1=8C=EF=BC=8C=E5=88=86?= =?UTF-8?q?=E6=A0=8F=E5=AF=B9=E5=8D=8A=E5=B9=B6=E6=8C=89=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E8=AE=B0=E4=BD=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 断线后对远程侧的操作(导航、刷新、删改、属性、所有传输)不再没有 反应:弹出「与 会话名 的 SFTP 连接已断开」,说明原因,可直接「重新 连接」;队列里有停止的批次或正在自动重连时只说明该怎么办。远程工具栏 和菜单在未连接时保持可点,只有连接中才禁用 - 列表还没读到目录时显示「正在连接 SFTP…」「正在读取目录…」,不再 先显示「空目录」 - 工具栏固定一行高,放不下的按钮从右往左整个隐藏,不换行,两侧列表 始终对齐 - 新开的 SFTP 标签本地、远程对半分;分隔线位置(含队列)由标签自己 持有,切换标签后不再被重置(不持久化) - 去掉当前面板路径标签的蓝色下划线 - 双击任一中间区标签的标题,显示 / 隐藏左侧会话栏(同 ⌘B) Co-Authored-By: Claude --- CLAUDE.md | 11 +- README.md | 6 +- src/explorer/explorer_panel.rs | 165 ++++++++++++++++++- src/explorer/file_listing.rs | 55 ++++--- src/explorer/file_pane.rs | 57 ++++++- src/explorer/path_label.rs | 15 +- src/shared/closable_tab.rs | 17 +- tests/ui.rs | 288 ++++++++++++++++++++++++++++++++- 8 files changed, 559 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 903790e..1386a2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,10 +68,13 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **框选同 WinSCP 关掉「整行选择」。** 拖文件去另一侧的 `on_drag` 只挂在名称单元格(图标加文件名,`FileListing::drag_from`)上,不挂在行上;名称上不带 ⌘ / Shift 的左键按下会在按下时就把未选中的项单独选中(`FilePane::select_pressed`,右键按下也用它),所以拖走的就是高亮的:未选中的项单独走,已选中的项带着整个选择走(拖动的载荷在 render 时按选择算好,按下前后算出的都是这一项,不会不一致)。拖动中只有指针在接收它的另一侧列表上(且可以传输)时才显示「上传 / 下载 N 个项目」并用 `DragCopy` 光标,其余地方(包括自己的列表)不显示预览、光标是 `OperationNotAllowed`(WinSCP 同):载荷带一个 `DropSpot`,接收侧容器的 `on_drag_move` 在指针落在自己范围内时记下指针位置,预览在 render 时比较它和 `window.mouse_position()`。存位置而不是布尔值,是因为 `on_drag_move` 会通知所有监听这个类型的元素(并排的另一个 SFTP 标签也会收到),「不在我这儿就清掉」会被后调用的覆盖。光标由预览里一个 canvas 在 paint 时 `set_window_cursor_style`:拖动层最后绘制,所以它的光标请求最后一个入栈、生效;测试平台不暴露光标,这点只能肉眼确认,测试只查预览 `file-drag-preview`(`aria_label` 是显示的文字)。列表容器 `("file-list", side)` 自己挂一个 `MarqueeDrag`,它带着开启它的面板的 `EntityId`:GPUI 的 `on_drag_move` 会把同一类型的拖动通知给**所有**挂了监听的元素,不管是谁开启的,所以监听里要核对 `event.drag(cx).pane == cx.entity_id()`,否则另一侧面板(以及别的 SFTP 标签)会跟着画框、改选择。所以从名称以外按下拖动就是框选(GPUI 的拖动由最上层先达到阈值的元素开启并停止冒泡,名称的拖动因此优先;滚动条在按下时就停止冒泡,不会触发框选)。容器在按下时记下位置,从列标题(`Pressed::Title`)开始的拖动不画框,其他地方都画(名称的图标和文件名有自己的文件拖动,GPUI 由它先开启,所以轮不到框选)。行高固定,每行用绝对定位的 `canvas`(**必须** `top_0().left_0()`)在 prepaint 记下「第一行顶端的位置和行高」(`ListGeometry::rows`),选框的起点按「距第一行顶端」保存,所以列表滚动时起点跟着内容走;框住哪些行由纯函数 `selection::swept_rows` / `row_at` 算,`Selection::sweep` 合并(开始时按住 ⌘ 或 Shift 则加到原有选择上)。拖到边缘用 `gpui_kit::base::AutoScroll` 滚动表格的 `vertical_scroll_handle`。拖动中 GPUI 每画一帧都会清掉各元素挂起的按下(`div.rs` 里 `has_active_drag` 时清 `pending_mouse_down`),所以松开时起点那个名称单元格不会再收到单击;拖动开始和松开落在同一帧里(快速一甩)时这条不成立,名称单元格的单击因此还用 `ends_a_drag`(按下和松开相距超过 GPUI 的 2 px 拖动阈值)排除掉;松开(`on_mouse_up` / `on_mouse_up_out`)时清掉选框并停止滚动。选框是容器里的绝对定位 `selection-rectangle`,夹在列表范围内。 - **列表右键菜单挂在列表容器 `("file-list", side)` 上**,和会话树一样:名称的右键处理器先把没选中的项选中、把 `MenuHit::Item(名称)` 写进 `menu_hit`(右键空白处则清空选择,给空白处菜单),列标题(委托的 `render_th`)写 `MenuHit::Column(列键)`,容器在捕获阶段清空它;容器的菜单构造器按命中给出文件菜单、「大小」标题的「文件大小显示为」(其他标题给空菜单,不弹出)或空白处菜单。**不要在标题或行上另挂 `context_menu`**:菜单的右键监听不阻止事件冒泡,外层容器的菜单会一起弹出。大小格式 `explorer::FileSizeFormat` 存在 `AppSettings::file_size_format`(`settings.json`),由 `settings::apply` 设成全局量(explorer 不依赖 settings,同终端字体),菜单项派发 `SetFileSizeFormat`,工作区写设置;委托的 `context_menu` 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。 - **`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` 下边框。 +- **SFTP 面板的工具栏永远一行高,放不下的按钮整个藏起来。** 折行会让这一侧的列表比另一侧低一截,所以工具栏行是 `flex_wrap` 加固定一行高(`h_6`,和 small 按钮、下拉框一样高)加 `overflow_hidden`:放不下的按钮折到被裁掉的第二行,整个隐藏(不会只露半个),从最右边的开始;命令在菜单和快捷键里都还有。边框和内边距在外层 `frame` 上,行高才不含它们。`sftp_controls_fit_small_window_in_light_dark_and_zoom` 查小窗口大字号下每个按钮要么完整可见要么不可见、两侧列表顶端对齐。新标签的两侧对半分(两个 `resizable_panel` 都 `flex_basis(relative(0.5))`,只管第一帧,之后分栏状态记下实际宽度),默认窗口(1280 px)开着会话栏时每侧约 500 px,第一行(目录下拉框 `rems(10.)`)约 404 px,放得下(`sftp_panes_open_half_and_half` 查按钮都可见、两侧列表顶端对齐)。改工具栏按钮或下拉框宽度时要重新算这笔账。 +- **断开时对远程侧的操作要有回应。** `ExplorerPanel::execute` 一进来先看 `needs_connection(command)`(远程侧的导航、打开、删改、属性,以及所有传输;选择、书签、复制路径、队列按钮不算):标签是「未连接」时不执行,弹出「与 会话名 的 SFTP 连接已断开」(说明是远程面板的 `problem`),确定按钮「重新连接」派发 `ExplorerCommand::Reconnect`;`can_reconnect` 为假(队列里有停止的批次,或传输正在自动重连)时只有「知道了」并说明该怎么办。为了让这些命令发得出来,远程面板在「未连接」时不禁用工具栏、菜单和路径标签(`FilePane::takes_commands`:只有「连接中」才禁用),传输按钮和拖放在「未连接」时也可用(`sync_available` 的 `transfer`)。 +- **SFTP 标签自己持有分栏状态。** 双栏之间和双栏与队列之间的分隔线位置是 `ExplorerPanel` 的 `panes_split` / `queue_split`(`ResizableState` 实体,`with_state` 交给 `h_resizable` / `v_resizable`)。不给 `with_state` 时分栏用 keyed element state,标签一不渲染(切到别的标签)就丢,切回来回到初始宽度(`sftp_panes_keep_their_split_across_tab_switches`)。和 Dock 布局一样只活在内存里,不持久化。 +- **路径标签按上一帧的宽度折叠。** render 时拿不到布局宽度,所以 `path-parts` 里放一个 `canvas`,在 prepaint 记下宽度,变了就用 `on_next_frame` 通知面板重画(绘制阶段里 `notify` 会丢)。各段宽度在 render 里用 `text_system().shape_line` 按 `text_sm` 量,和实际布局一致(各段不加内边距,否则 `/ home/ tester/` 看起来像多了空格)。折叠规则是纯函数 `folded_parts`:留住根,中段换成 `…`,末尾尽量多留几级;`…` 点了去它藏起的最后一级(WinSCP 同)。「当前面板」是最近获得焦点的一侧(`on_focus_in`,必须 `defer_in`,见下条),标签重新显示时焦点回到它;界面上不标出来(路径标签两侧一样是 `list_active` 底色加普通下边框):以前给当前面板加一条 `list_active_border` 下划线,用户看它像「这一侧处于激活状态」,而它和命令实际作用的一侧对不上。 - **焦点监听器里改渲染状态要 `defer_in`。** `on_focus_in` / `on_focus` / `on_blur` 在 `draw` 的焦点阶段同步执行,这时 `cx.notify()` 只记下脏视图、不置窗口脏也不排新帧,改动要等下一次别的原因重绘才看得见。点列表空白处(GPUI 对 `track_focus` 元素在按下时自动聚焦,没有别的处理器触发重绘)因此要点两下才切换当前面板;改为 `cx.defer_in` 在绘制结束后执行就好。测试 `sftp_clicking_empty_list_space_makes_that_pane_current_at_once` 不自己 `render_frame`,只看点击本身触发的帧。 - **SFTP 空闲时也要发现断网,和终端同时。** 终端靠主循环一直 `channel.wait()`,russh 的 keepalive(`SshConnector` 里 30 s × 3,所有 SSH 连接共用)一关掉连接它就返回。SFTP 连接同样会被关掉,但 russh-sftp 只在下一次请求时才报 `session closed`,而且不公开关闭状态。所以 `SftpClient` 把通道流包一层 `WatchedStream`:读到结尾或出错就置位一个 `watch`,`SftpClient::closed()` 等它;工作线程空闲等命令时同时等它,一触发就 `forget` 这个连接并发 `Disconnected`。等待时只持有 `Weak` 和那个 `watch` 接收端,**不能持有 `Arc`**:主动「断开」靠丢掉最后一个引用来关连接,监听攥着强引用会让 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 一次)。 +- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 `pane-status`(`FilePane::render_status`)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(`SLOW_LOAD`)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。列表没有行时表格中央的占位文字(委托的 `render_empty`,id `list-placeholder`,文字来自 `FilePane::placeholder`,经 `ListingContext::placeholder` 传进去)也按阶段说:连接中「正在连接 SFTP…」,还没读到过目录(`listed` 为假)时「正在读取目录…」,读到过之后才是「空目录」(重读一个空目录时同样等 `SLOW_LOAD` 才改口);没读成功过时是「未连接」或「无法读取目录」。非根目录总有 `..` 一行,所以「空目录」实际只出现在空的根目录。改加载状态的地方都要 `sync_listing`,否则委托里的占位文字不更新。读不了目录、远程侧断开的原因是面板的 `problem`,显示在窗口状态栏最左边、平时写「已连接 会话名」的 `status-connection`:SFTP 标签在前时,工作区用 `ExplorerPanel::status`(这个标签自己的连接状态,不是会话汇总的,加上 `problem`,远程侧优先)生成 `WorkspaceStatus::explorer`,未连接时写「未连接 会话名:原因」,已连接但读不了目录时只写原因,两种都是 `danger` 红字;会话(终端标签)断开时同样是红字。`problem` 变化时面板发 `FilePaneEvent::ProblemChanged`,标签转成 `ExplorerPanelEvent::StatusChanged`,工作区只在那个标签在前时 `notify`(不观察整个标签,传输进度每 50 ms 一次)。 - **「打开目录/书签」的选中项是推导出来的。** 列表里被选中的就是与目录框(按面板规则展开 `~`、相对路径并去掉末尾分隔符后)相同的书签,没有另存选中状态,和 WinSCP 的 `DirectoryEditChange` 一致:单击书签只是把它写进目录框。书签的增删和排序都派发 `ExplorerCommand`(`AddBookmark { path: Some(..) }`、`RemoveBookmark`、`MoveBookmark`),改动立即写库,取消对话框也不回滚(WinSCP 同)。对话框打开后在 `open_dialog` 同步返回之后立刻 `focus` 目录框并全选,这样能生效(`Root::open_dialog` 是同步聚焦对话框宿主的,之后同一更新里的聚焦会覆盖它);延迟到下一帧的聚焦才会失效。 - **书签按会话、按侧持久化。** `bookmarks` 表(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` 延后派发,避免重入实体。 @@ -111,7 +114,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 标签的菜单(`explorer_panel.rs` 的 `TabMenu`)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑会话…」;标签栏按钮和远程终端一样是「SFTP」(`("open-sftp", id)`)和「重新连接」(`("reconnect-sftp", id)`)。SFTP 的「重新连接」是 `ExplorerCommand::Reconnect`:已连接时先 `disconnect` 再发 `Resume`(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。 +- **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。双击标题派发 `ToggleSessionPanel`(显示 / 隐藏左侧会话栏,同 ⌘B),也挂在标题上,所有中间区标签共用;第一下单击照常选中标签,关闭按钮的单击停止冒泡,双击它不会切换侧栏。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。中间区各面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 `custom_title`)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(`RenamableTab::default_title`:终端是会话名,SFTP 是「会话名 · SFTP」)时回到默认,默认名随会话改名。SFTP 标签的菜单(`explorer_panel.rs` 的 `TabMenu`)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑会话…」;标签栏按钮和远程终端一样是「SFTP」(`("open-sftp", id)`)和「重新连接」(`("reconnect-sftp", id)`)。SFTP 的「重新连接」是 `ExplorerCommand::Reconnect`:已连接时先 `disconnect` 再发 `Resume`(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。 - **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效,要预先聚焦某个字段只能在 `open_dialog` 返回后的同一次更新里同步调用(见 `open_directory_dialog.rs`)。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_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 随之偏下将近一行。 @@ -124,4 +127,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`、列表下方的状态栏 `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` 初始为已连接并已打开终端标签。 +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`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。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 b008462..568cfa4 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ shellrs download <远程路径> <本地路径> ## 标签页 -远程终端标签和 SFTP 标签都以主机系统徽章为图标(与会话列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「会话名称 · SFTP」)、打开 SFTP、复制 IP 地址 / 主机名、重新连接加关闭命令,标签栏右侧和远程终端一样有「SFTP」和「重新连接」按钮;本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。远程终端运行时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。批量关闭时,正在上传的 SFTP 标签仍会先确认。 +远程终端标签和 SFTP 标签都以主机系统徽章为图标(与会话列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「会话名称 · SFTP」)、打开 SFTP、复制 IP 地址 / 主机名、重新连接加关闭命令,标签栏右侧和远程终端一样有「SFTP」和「重新连接」按钮;本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。双击任一标签的标题可隐藏或显示左侧的会话列表(同 ⌘B 和标题栏的按钮)。远程终端运行时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。批量关闭时,正在上传的 SFTP 标签仍会先确认。 ## 终端 @@ -63,7 +63,7 @@ shellrs download <远程路径> <本地路径> ### 工具栏 -每一侧面板都有两行工具栏,下方是路径标签: +每一侧面板都有两行工具栏,下方是路径标签。面板太窄放不下时,按钮从右往左整个隐藏,不会换行(对应的命令在右键菜单和快捷键里仍然可用)。新开的 SFTP 标签本地、远程两侧各占一半;两侧和队列之间的分隔线拖到哪里,在这个标签关闭之前都会记住,切到别的标签再回来也不变: - 第一行(导航): - 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。 @@ -94,7 +94,7 @@ shellrs download <远程路径> <本地路径> - 书签的改动立即保存,按「取消」也不会撤销,与 WinSCP 相同。 - 回车或「打开」前往目录;Esc 或「取消」关闭。 -列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 会话名」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单。这些都不会让列表移动。 +列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 会话名」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单;断开时在远程列表里做任何操作(刷新、打开目录、删除、上传下载等)会弹出「SFTP 连接已断开」对话框,说明原因,并可直接「重新连接」。这些都不会让列表移动。 列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。 diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index 804197c..b9875cd 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -21,10 +21,11 @@ use crate::{ use gpui_kit::component::{ Disableable as _, Icon, IconName, Sizable as _, WindowExt as _, button::Button, + dialog::DialogButtonProps, dock::{BasePanel, Panel, PanelEvent, TabGroup}, menu::PopupMenu, notification::Notification, - resizable::{h_resizable, resizable_panel, v_resizable}, + resizable::{ResizableState, h_resizable, resizable_panel, v_resizable}, v_flex, }; use gpui_kit::*; @@ -77,6 +78,11 @@ pub struct ExplorerPanel { custom_title: Option, /// The pane that held focus last, which takes it back on activation. last_remote: bool, + /// Where the user put the divider between the panes, and the one above + /// the queue. Kept here, not in element state, which goes the first + /// frame another tab is shown instead: coming back would reset them. + panes_split: Entity, + queue_split: Entity, _subscriptions: Vec, _events: Task<()>, } @@ -217,6 +223,8 @@ impl ExplorerPanel { tab_group: None, custom_title: None, last_remote: true, + panes_split: cx.new(|_| ResizableState::default()), + queue_split: cx.new(|_| ResizableState::default()), _subscriptions: subscriptions, _events: events_task, } @@ -276,6 +284,75 @@ impl ExplorerPanel { pub fn can_reconnect(&self) -> bool { self.state != ConnectionState::Connecting && !self.engine_busy && !self.head_stopped() } + /// Tell the user the remote side is disconnected, and why, with 重新连接 + /// right there. When the queue owns reconnecting (a stopped batch waits + /// for 继续, or a transfer is reconnecting by itself), say so instead. + fn offer_reconnect(&mut self, window: &mut Window, cx: &mut Context) { + if window.has_active_dialog(cx) { + return; + } + let name = self + .store + .read(cx) + .session(self.session_id) + .map_or_else(|| "服务器".into(), |session| session.name.clone()); + let reason = self + .remote + .read(cx) + .problem() + .unwrap_or("SFTP 已断开") + .to_string(); + let can_reconnect = self.can_reconnect(); + let description = if can_reconnect { + format!("{reason}。重新连接后再操作远程文件。") + } else if self.head_stopped() { + format!("{reason}。传输队列里有停止的批次,点它的「继续」会重新连接并接着传输。") + } else { + format!("{reason}。正在自动重新连接,请稍候。") + }; + let (dispatch, sid, generation) = (self.dispatch.clone(), self.id, self.generation); + let focus = window.focused(cx); + window.open_alert_dialog(cx, move |dialog, _, _| { + let restore = { + let focus = focus.clone(); + move |_: &ClickEvent, window: &mut Window, cx: &mut App| { + if let Some(focus) = &focus { + window.focus(focus, cx); + } + true + } + }; + let dialog = dialog + .title(format!("与 {name} 的 SFTP 连接已断开")) + .description(description.clone()); + if can_reconnect { + dialog + .button_props( + DialogButtonProps::default() + .ok_text("重新连接") + .cancel_text("取消"), + ) + .show_cancel(true) + .on_cancel(restore.clone()) + .on_ok({ + let (dispatch, restore) = (dispatch.clone(), restore.clone()); + move |event, window, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(sid, ExplorerCommand::Reconnect) + .with_generation(generation), + window, + cx, + ); + restore(event, window, cx) + } + }) + } else { + dialog + .button_props(DialogButtonProps::default().ok_text("知道了")) + .on_ok(restore) + } + }); + } /// 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) @@ -359,7 +436,9 @@ impl ExplorerPanel { fn sync_available(&mut self, cx: &mut Context) { let state = self.state; // A batch confirmed while another runs waits its turn in the queue. - let transfer = state == ConnectionState::Connected; + // Disconnected, transfers are still asked for, and answered with the + // offer to reconnect; only while connecting do they wait. + let transfer = state != ConnectionState::Connecting; self.local.update(cx, |pane, cx| { pane.set_available(ConnectionState::Connected, transfer, cx) }); @@ -516,6 +595,12 @@ impl ExplorerPanel { window: &mut Window, cx: &mut Context, ) { + // Disconnected, a command for the remote side says why nothing + // happens and offers to reconnect, rather than doing nothing. + if self.state == ConnectionState::Disconnected && needs_connection(command) { + self.offer_reconnect(window, cx); + return; + } match command { ExplorerCommand::Navigate { remote, path } => { self.navigate(*remote, path.clone(), LoadIntent::Visit, window, cx) @@ -882,6 +967,39 @@ impl RenamableTab for ExplorerPanel { cx.notify(); } } +/// Whether a command needs the SFTP connection: whatever reads or changes +/// the remote side, and every transfer. Selecting, bookmarks, copying a +/// path and the queue's own buttons do not. +fn needs_connection(command: &ExplorerCommand) -> bool { + use ExplorerCommand as C; + match command { + C::Navigate { remote, .. } + | C::Up { remote } + | C::Refresh { remote } + | C::Root { remote } + | C::Home { remote } + | C::Back { remote } + | C::Forward { remote } + | C::Open { remote } + | C::OpenDirectory { remote } + | C::Delete { remote } + | C::BeginDelete { remote, .. } + | C::Rename { remote } + | C::CommitRename { remote, .. } + | C::New { remote, .. } + | C::CommitNew { remote, .. } + | C::Properties { remote } + | C::ApplyPermissions { remote, .. } => *remote, + C::Transfer { .. } + | C::DownloadPaths { .. } + | C::BeginDownload { .. } + | C::ChooseFiles + | C::UploadPaths { .. } + | C::BeginUpload { .. } => true, + _ => false, + } +} + /// The status of an SFTP tab for the window's status line. pub struct ExplorerStatus { pub state: ConnectionState, @@ -974,17 +1092,23 @@ impl Render for ExplorerPanel { .child( div().flex_1().min_h_0().child( v_resizable(("explorer-queue", sid.0)) + .with_state(&self.queue_split) .child( resizable_panel().child( h_resizable(("explorer-panes", sid.0)) + .with_state(&self.panes_split) .child( + // Half and half when the tab opens; the + // state then keeps where the user puts + // the divider. resizable_panel() - .size(px(480.)) + .flex_basis(relative(0.5)) .size_range(px(320.)..Pixels::MAX) .child(self.local.clone()), ) .child( resizable_panel() + .flex_basis(relative(0.5)) .size_range(px(280.)..Pixels::MAX) .child(self.remote.clone()), ), @@ -1016,3 +1140,38 @@ fn transfer_command(job: &TransferJob) -> anyhow::Result { .map(SftpCommand::Download), } } + +#[cfg(test)] +mod tests { + use super::needs_connection; + use crate::app::ExplorerCommand; + + #[test] + fn remote_side_and_transfers_need_the_connection() { + assert!(needs_connection(&ExplorerCommand::Refresh { remote: true })); + assert!(!needs_connection(&ExplorerCommand::Refresh { + remote: false + })); + assert!(needs_connection(&ExplorerCommand::Delete { remote: true })); + assert!(!needs_connection(&ExplorerCommand::Delete { + remote: false + })); + // Either way a transfer goes over it. + assert!(needs_connection(&ExplorerCommand::Transfer { + remote: false + })); + assert!(needs_connection(&ExplorerCommand::ChooseFiles)); + // Choosing, bookmarks and the queue do not. + assert!(!needs_connection(&ExplorerCommand::SelectAll { + remote: true + })); + assert!(!needs_connection(&ExplorerCommand::CopyPath { + remote: true + })); + assert!(!needs_connection(&ExplorerCommand::AddBookmark { + remote: true, + path: None + })); + assert!(!needs_connection(&ExplorerCommand::Reconnect)); + } +} diff --git a/src/explorer/file_listing.rs b/src/explorer/file_listing.rs index e40c44d..c818b79 100644 --- a/src/explorer/file_listing.rs +++ b/src/explorer/file_listing.rs @@ -32,6 +32,8 @@ pub(super) struct ListingContext { pub menu_hit: Rc>>, /// Where the rows are, for the selection rectangle. pub geometry: Rc, + /// What the list says while it has no rows. + pub placeholder: SharedString, } /// Where the list and its rows are on screen, recorded as they paint, so a @@ -103,6 +105,7 @@ impl Default for ListingContext { pane: None, menu_hit: Rc::default(), geometry: Rc::default(), + placeholder: SharedString::default(), } } } @@ -117,30 +120,34 @@ pub struct FileListing { context: ListingContext, } +/// WinSCP's columns for each side. Column widths are an API boundary that +/// takes `Pixels`. +fn columns(side: PaneSide) -> Vec { + match side { + PaneSide::Local => vec![ + name_column(px(260.)), + Column::new("size", "大小").width(px(100.)).sortable(), + Column::new("type", "类型").width(px(100.)).sortable(), + Column::new("modified", "修改时间") + .width(px(150.)) + .sortable(), + ], + PaneSide::Remote => vec![ + name_column(px(240.)), + Column::new("size", "大小").width(px(100.)).sortable(), + Column::new("modified", "修改时间") + .width(px(150.)) + .sortable(), + Column::new("rights", "权限").width(px(96.)).sortable(), + Column::new("owner", "所有者").width(px(80.)).sortable(), + ], + } +} + impl FileListing { pub fn new(side: PaneSide, rows: Vec) -> Self { - // Column widths are an API boundary that takes `Pixels`. - let columns = match side { - PaneSide::Local => vec![ - name_column(px(260.)), - Column::new("size", "大小").width(px(100.)).sortable(), - Column::new("type", "类型").width(px(100.)).sortable(), - Column::new("modified", "修改时间") - .width(px(150.)) - .sortable(), - ], - PaneSide::Remote => vec![ - name_column(px(240.)), - Column::new("size", "大小").width(px(100.)).sortable(), - Column::new("modified", "修改时间") - .width(px(150.)) - .sortable(), - Column::new("rights", "权限").width(px(96.)).sortable(), - Column::new("owner", "所有者").width(px(80.)).sortable(), - ], - }; let mut listing = Self { - columns, + columns: columns(side), rows: Vec::new(), sort: None, side, @@ -602,14 +609,18 @@ impl TableDelegate for FileListing { _: &mut Window, cx: &mut Context>, ) -> impl IntoElement { + let text = self.context.placeholder.clone(); div() + .id("list-placeholder") .size_full() .flex() .items_center() .justify_center() .text_sm() .text_color(cx.theme().muted_foreground) - .child("空目录") + .child(text.clone()) + .test_support() + .aria_label(text) } } diff --git a/src/explorer/file_pane.rs b/src/explorer/file_pane.rs index b00321a..ee95f6a 100644 --- a/src/explorer/file_pane.rs +++ b/src/explorer/file_pane.rs @@ -198,6 +198,9 @@ pub struct FilePane { /// 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, + /// A directory has been read into the list at least once, so an empty + /// list is an empty directory. + listed: bool, connection: ConnectionState, transfer_enabled: bool, /// A file operation on this pane is running. @@ -276,6 +279,7 @@ impl FilePane { slow_load: false, slow_load_timer: None, error: None, + listed: false, connection: if side == PaneSide::Local { ConnectionState::Connected } else { @@ -306,6 +310,12 @@ impl FilePane { pub fn is_connected(&self) -> bool { self.connection == ConnectionState::Connected } + /// Whether the toolbar, menus and path label take commands. Disconnected + /// they do: the tab answers with the offer to reconnect. Only while + /// connecting is there nothing to do but wait. + pub fn takes_commands(&self) -> bool { + self.connection != ConnectionState::Connecting + } pub fn is_current(&self) -> bool { self.current } @@ -466,12 +476,34 @@ impl FilePane { pane: Some(cx.entity().downgrade()), menu_hit: self.menu_hit.clone(), geometry: self.geometry.clone(), + placeholder: self.placeholder().into(), }; self.table.update(cx, |table, cx| { table.delegate_mut().configure(context); cx.notify(); }); } + /// What the list says while it has no rows: 空目录 only once a directory + /// has been read, what is going on until then, in the status line's + /// words. An empty directory being read again keeps saying 空目录 until + /// the read is slow, like the status line, so a quick refresh does not + /// flicker. + fn placeholder(&self) -> &'static str { + if self.connection == ConnectionState::Connecting { + "正在连接 SFTP…" + } else if self.loading && (!self.listed || self.slow_load) { + "正在读取目录…" + } else if self.listed { + "空目录" + } else if self.connection == ConnectionState::Disconnected { + "未连接" + } else if self.error.is_some() { + "无法读取目录" + } else { + // Before the first read is asked for. + "正在读取目录…" + } + } fn update_selection( &mut self, cx: &mut Context, @@ -727,6 +759,7 @@ impl FilePane { self.pending = None; self.finish_loading(); self.set_error(Some(message), cx); + self.sync_listing(cx); cx.notify(); } /// What went wrong reading the directory, or why the remote side @@ -745,6 +778,7 @@ impl FilePane { self.pending = Some((self.request_id, intent, self.path.clone())); self.loading = true; self.set_error(None, cx); + self.sync_listing(cx); // Most directories arrive at once; saying 正在读取 for those only // flickers, so it waits. let id = self.request_id; @@ -754,6 +788,7 @@ impl FilePane { let _ = this.update(cx, |this, cx| { if this.loading && this.request_id == id { this.slow_load = true; + this.sync_listing(cx); cx.notify(); } }); @@ -792,6 +827,7 @@ impl FilePane { self.hovered_part = None; } self.path = listing.path().into(); + self.listed = true; self.set_error(None, cx); let mut rows: Vec<_> = listing .entries() @@ -814,6 +850,7 @@ impl FilePane { Err(error) => { self.select_after_load = None; self.set_error(Some(error), cx); + self.sync_listing(cx); } } cx.notify(); @@ -890,7 +927,7 @@ impl FilePane { can_go_forward: self.history.forward_target().is_some(), path: self.path.clone(), bookmarks: self.bookmarks(cx), - can_modify: self.is_connected() && !self.busy, + can_modify: self.takes_commands() && !self.busy, can_transfer: self.transfer_enabled, } } @@ -904,7 +941,7 @@ impl Render for FilePane { let context = self.side.key_context(); let state = self.menu_state(cx); let selected = state.targets.len(); - let navigable = self.is_connected(); + let navigable = self.takes_commands(); // A toolbar button dispatches the same command as its key binding, // and its tooltip shows that binding. let tool = |id: &'static str, icon: Icon, tip: &'static str, command: ExplorerCommand| { @@ -932,14 +969,18 @@ impl Render for FilePane { .iter() .filter(|e| !e.is_parent()) .count(); - let toolbar = || { - h_flex() - .flex_wrap() - .gap_1() + // A row is one line high whatever the pane's width: what does not + // fit wraps onto a second line that the height cuts off, so buttons + // go whole, from the right, and the list never moves down. Their + // commands stay on the menus and keys. + let toolbar = || h_flex().flex_wrap().gap_1().h_6().overflow_hidden(); + let frame = |row: Div| { + div() .px_2() .py_1() .border_b_1() .border_color(cx.theme().border) + .child(row) }; let navigation = toolbar() // `Select` fills its parent (`size_full`), so it needs a sized @@ -1124,8 +1165,8 @@ impl Render for FilePane { .test_support() .size_full() .min_w_0() - .child(navigation) - .child(operations) + .child(frame(navigation)) + .child(frame(operations)) .child(self.render_path_label(window, cx)) .child( div() diff --git a/src/explorer/path_label.rs b/src/explorer/path_label.rs index 85ad9ab..50f4d55 100644 --- a/src/explorer/path_label.rs +++ b/src/explorer/path_label.rs @@ -68,19 +68,16 @@ impl FilePane { ) -> AnyElement { let remote = self.side == PaneSide::Remote; let theme = cx.theme(); - // The current pane's label is underlined, as WinSCP marks its - // active one. + // Both labels look alike: an underline for the current pane read as + // "this side is active" and fell behind where commands went. let background = theme.list_active; - let border = if self.current { - theme.list_active_border - } else { - theme.border - }; + let border = theme.border; let bar = h_flex() .id(self.side.path_id()) .test_support() .aria_label(format!("{}路径", self.side.label())) - // Marks WinSCP's current pane, the one the underline shows. + // WinSCP's current pane, which takes focus back when the tab + // is shown again; nothing marks it on screen. .aria_selected(self.current) // The directory shown, once its listing is in. .when(!self.loading, |this| this.aria_value(self.path.clone())) @@ -90,7 +87,7 @@ impl FilePane { .border_b_1() .border_color(border); - let enabled = self.is_connected(); + let enabled = self.takes_commands(); let parts = self.shown_parts(window); let hot = self .hovered_part diff --git a/src/shared/closable_tab.rs b/src/shared/closable_tab.rs index f6ab401..9a5b65b 100644 --- a/src/shared/closable_tab.rs +++ b/src/shared/closable_tab.rs @@ -9,6 +9,8 @@ use gpui_kit::component::{ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; +use crate::app::ToggleSessionPanel; + type MenuBuilder = Rc) -> PopupMenu>; /// A dock tab title: a leading mark, the label and, for closable panels, a @@ -20,9 +22,12 @@ type MenuBuilder = Rc) -> /// workspace wants every tab closable (an empty center shows the recent /// sessions instead) and removes the panel from the dock itself. /// -/// The context menu hangs on the title rather than on the dock's `Tab`, which -/// the application does not render. The tab's own horizontal padding is -/// therefore outside it; a right click there does nothing. +/// Double-clicking the title shows or hides the session sidebar, the way a +/// double click on an editor tab gives it the room elsewhere. +/// +/// The context menu and the double click hang on the title rather than on +/// the dock's `Tab`, which the application does not render. The tab's own +/// horizontal padding is therefore outside them; clicks there only select. #[derive(IntoElement)] pub struct ClosableTabTitle { id: ElementId, @@ -89,6 +94,12 @@ impl RenderOnce for ClosableTabTitle { }); div() .id(self.id) + // The first click selects the tab as usual. + .on_click(|event, window, cx| { + if event.click_count() == 2 { + window.dispatch_action(Box::new(ToggleSessionPanel), cx); + } + }) .test_support() .aria_label(self.label) .child(match self.menu { diff --git a/tests/ui.rs b/tests/ui.rs index 6d98b5a..4ab9b90 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -3280,6 +3280,40 @@ fn both_tabs_of_one_session_stay_closable(cx: &mut TestAppContext) { }); } +/// Double-clicking a tab's title shows or hides the session sidebar; a +/// single click only selects the tab. +#[gpui_kit::test] +async fn double_clicking_a_tab_toggles_the_session_sidebar(cx: &mut TestAppContext) { + let (handle, _) = open_workspace(cx); + cx.run_until_parked(); + let sidebar_shown = |cx: &mut TestAppContext| { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .try_find("session-search") + .is_some_and(|search| search.visible()) + }) + .unwrap() + }; + assert!(sidebar_shown(cx)); + for shown in [false, true] { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.double_click(("terminal-tab", INITIAL_WEB_TERMINAL), cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(sidebar_shown(cx), shown); + } + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click(("terminal-tab", INITIAL_STAGING_TERMINAL), cx); + }) + .unwrap(); + cx.run_until_parked(); + assert!(sidebar_shown(cx)); +} + #[gpui_kit::test] async fn a_terminal_tab_can_be_renamed_and_follow_the_session_again(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); @@ -3910,6 +3944,9 @@ struct FakeSftpProvider { hold_connection: Arc>>>, /// Connections made again on 重新连接 (a bare 继续). reconnects: Arc>, + /// The remote home is `/slow`, which never answers: a network too slow + /// for the first directory to arrive. + slow_home: bool, } impl SftpTransportProvider for FakeSftpProvider { fn create(&self, _: &shellrs::session::Session) -> Box { @@ -3920,6 +3957,11 @@ impl SftpTransportProvider for FakeSftpProvider { events: self.events.clone(), hold: self.hold_connection.lock().unwrap().take(), reconnects: self.reconnects.clone(), + home: if self.slow_home { + "/slow" + } else { + "/home/tester" + }, }) } } @@ -3930,6 +3972,7 @@ struct FakeSftpTransport { events: Arc>>>, hold: Option>, reconnects: Arc>, + home: &'static str, } impl SftpTransport for FakeSftpTransport { fn run( @@ -3943,7 +3986,7 @@ impl SftpTransport for FakeSftpTransport { let _ = hold.recv(); } events.send_blocking(SftpEvent::Connected { - home: RemotePath::new("/home/tester")?, + home: RemotePath::new(self.home)?, })?; // As in the real engine, 继续 goes on with a stopped batch, and with // none it only reconnects. @@ -5380,6 +5423,168 @@ async fn sftp_path_label_folds_the_middle_of_a_long_path(cx: &mut TestAppContext /// 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. +/// A new SFTP tab splits its width half and half between the panes, with +/// every toolbar button of each showing and the two lists level. +#[gpui_kit::test] +async fn sftp_panes_open_half_and_half(cx: &mut TestAppContext) { + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, _) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let local = window.find(("local-pane", SFTP_TAB)).bounds(); + let remote = window.find(("remote-pane", SFTP_TAB)).bounds(); + assert!( + (local.size.width - remote.size.width).abs() <= px(1.), + "{local:?} {remote:?}" + ); + for (pane, transfer) in [("local-pane", "upload"), ("remote-pane", "download")] { + let scope = window.within((pane, SFTP_TAB)); + for id in ["path-select", "forward", transfer, "new"] { + assert!(scope.find(id).visible(), "{pane} {id}"); + } + } + let top = |pane: &'static str, window: &mut gpui_kit::Window| { + window.within((pane, SFTP_TAB)).find("table").bounds().top() + }; + assert_eq!(top("local-pane", window), top("remote-pane", window)); + }) + .unwrap(); +} + +/// Where the user drags the divider between the panes stays while another +/// tab is shown and this one comes back. +#[gpui_kit::test] +async fn sftp_panes_keep_their_split_across_tab_switches(cx: &mut TestAppContext) { + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, _) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + let local_width = |cx: &mut TestAppContext| { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.find(("local-pane", SFTP_TAB)).bounds().size.width + }) + .unwrap() + }; + let opened = local_width(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let local = window.find(("local-pane", SFTP_TAB)).bounds(); + let divider = gpui_kit::point(local.right(), local.center().y); + window.drag(divider, divider - gpui_kit::point(px(120.), px(0.)), cx); + }) + .unwrap(); + cx.run_until_parked(); + let dragged = local_width(cx); + assert!(dragged < opened - px(100.), "{opened:?} → {dragged:?}"); + + // Another SFTP tab of the session comes to the front, then this one. + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find(("local-pane", SFTP_TAB)).is_none()); + window.click(("explorer-tab", SFTP_TAB), cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(local_width(cx), dragged); +} + +/// Disconnected, anything asked of the remote side says so in a dialog with +/// 重新连接, instead of doing nothing. 取消 leaves it as it is; local work +/// goes on without asking. +#[gpui_kit::test] +async fn sftp_remote_commands_while_disconnected_offer_to_reconnect(cx: &mut TestAppContext) { + use shellrs::app::{ExplorerAction, ExplorerCommand}; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, _) = open_workspace_with_sftp(cx, provider.clone()); + open_test_explorer(cx, handle).await; + let events = provider.events.lock().unwrap()[0].clone(); + events + .send_blocking(SftpEvent::Disconnected("SFTP 连接中断,请重新连接".into())) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("status-connection").label() == Some("未连接 db-01:SFTP 连接中断,请重新连接") + }) + .await; + + // A toolbar button still answers, with the dialog. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within(("remote-pane", SFTP_TAB)) + .click("refresh", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("ok").visible()); + window.click("cancel", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find("ok").is_none()); + assert!( + window + .find("status-connection") + .label() + .is_some_and(|status| status.starts_with("未连接")) + ); + }) + .unwrap(); + assert_eq!(*provider.reconnects.lock().unwrap(), 0); + + // Local work does not ask. + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + ExplorerId(SFTP_TAB), + ExplorerCommand::Refresh { remote: false }, + )), + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find("ok").is_none()); + }) + .unwrap(); + + // A key or menu command asks too, and 重新连接 there reconnects. + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + ExplorerId(SFTP_TAB), + ExplorerCommand::Up { remote: true }, + )), + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("ok", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("status-connection").label() == Some("已连接 db-01") + }) + .await; + assert_eq!(*provider.reconnects.lock().unwrap(), 1); +} + #[gpui_kit::test] async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left( cx: &mut TestAppContext, @@ -5423,7 +5628,8 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left( window.render_frame(cx); let remote = window.within(("remote-pane", SFTP_TAB)); assert_eq!(remote.find("table").bounds(), table); - assert!(!enabled(&remote.find("refresh"))); + // Still there to click: it answers with the offer to reconnect. + assert!(enabled(&remote.find("refresh"))); assert_ne!(remote.find("pane-status").label(), Some("连接已断开")); window.click(reconnect, cx); }) @@ -6308,6 +6514,69 @@ async fn sftp_connecting_shows_under_the_list_without_moving_it(cx: &mut TestApp .unwrap(); } +/// Until a directory has been read the list does not claim to be empty: it +/// says it is connecting, then reading, as a slow network makes it wait. +/// A directory read with nothing in it lists only `..`. +#[gpui_kit::test] +async fn sftp_list_says_what_it_waits_for_before_saying_empty(cx: &mut TestAppContext) { + use shellrs::app::{ExplorerAction, ExplorerCommand}; + let (release, hold) = mpsc::channel(); + let provider = Arc::new(FakeSftpProvider { + hold_connection: Arc::new(Mutex::new(Some(hold))), + slow_home: true, + ..Default::default() + }); + let (handle, _) = open_workspace_with_sftp(cx, provider); + let placeholder_is = |expected: &'static str| { + move |window: &mut gpui_kit::Window, cx: &mut App| { + window.render_frame(cx); + window + .within(("remote-pane", SFTP_TAB)) + .try_find("list-placeholder") + .is_some_and(|placeholder| placeholder.label() == Some(expected)) + } + }; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert!(placeholder_is("正在连接 SFTP…")(window, cx)); + }) + .unwrap(); + + // Connected; the home directory takes its time. + release.send(()).unwrap(); + cx.wait_for( + handle.into(), + Duration::from_secs(2), + placeholder_is("正在读取目录…"), + ) + .await; + + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + ExplorerId(SFTP_TAB), + ExplorerCommand::Navigate { + remote: true, + path: "/empty".into(), + }, + )), + cx, + ); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + let remote = window.within(("remote-pane", SFTP_TAB)); + remote.try_find("file:..").is_some() && remote.try_find("list-placeholder").is_none() + }) + .await; +} + #[gpui_kit::test] async fn sftp_reading_a_directory_never_moves_the_list(cx: &mut TestAppContext) { use shellrs::app::{ExplorerAction, ExplorerCommand}; @@ -6439,9 +6708,16 @@ async fn sftp_controls_fit_small_window_in_light_dark_and_zoom(cx: &mut TestAppC cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); + // Toolbars stay one line high: the lists start level. + let mut top = |pane: &'static str| { + window.within((pane, SFTP_TAB)).find("table").bounds().top() + }; + let (local, remote) = (top("local-pane"), top("remote-pane")); + assert_eq!(local, remote, "zoom {zoom}"); for (pane, transfer) in [("local-pane", "upload"), ("remote-pane", "download")] { let bounds = window.find((pane, SFTP_TAB)).bounds(); let scope = window.within((pane, SFTP_TAB)); + assert!(scope.find("path-select").visible(), "{pane} at zoom {zoom}"); for id in [ "path-select", "bookmarks", @@ -6457,7 +6733,13 @@ async fn sftp_controls_fit_small_window_in_light_dark_and_zoom(cx: &mut TestAppC "properties", "new", ] { - let button = scope.find(id).bounds(); + // Shown whole, or not at all when the pane is too + // narrow for it. + let button = scope.find(id); + if !button.visible() { + continue; + } + let button = button.bounds(); assert!( button.left() >= bounds.left() && button.right() <= bounds.right()