SFTP:断线时操作远程侧弹出重连对话框,工具栏不换行,分栏对半并按标签记住

- 断线后对远程侧的操作(导航、刷新、删改、属性、所有传输)不再没有
  反应:弹出「与 会话名 的 SFTP 连接已断开」,说明原因,可直接「重新
  连接」;队列里有停止的批次或正在自动重连时只说明该怎么办。远程工具栏
  和菜单在未连接时保持可点,只有连接中才禁用
- 列表还没读到目录时显示「正在连接 SFTP…」「正在读取目录…」,不再
  先显示「空目录」
- 工具栏固定一行高,放不下的按钮从右往左整个隐藏,不换行,两侧列表
  始终对齐
- 新开的 SFTP 标签本地、远程对半分;分隔线位置(含队列)由标签自己
  持有,切换标签后不再被重置(不持久化)
- 去掉当前面板路径标签的蓝色下划线
- 双击任一中间区标签的标题,显示 / 隐藏左侧会话栏(同 ⌘B)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
XuzandClaude committed 2026-09-30 18:06:07 +08:00
1 parent bf72546c64
commit 1e81f85343
8 files changed
+559 -55

No files matched your search

+7 -4
View File
@@ -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<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 一次)。
- **读目录不许挪动列表,出了什么问题写在窗口左下角。** 路径标签和列表之间什么都不放。「正在连接 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<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`、列表为空时的占位文字 `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-direcLine truncated
+3 -3
View File
@@ -48,7 +48,7 @@ shellrs download <ID> <远程路径> <本地路径>
## 标签页
远程终端标签和 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 <ID> <远程路径> <本地路径>
### 工具栏
每一侧面板都有两行工具栏,下方是路径标签:
每一侧面板都有两行工具栏,下方是路径标签。面板太窄放不下时,按钮从右往左整个隐藏,不会换行(对应的命令在右键菜单和快捷键里仍然可用)。新开的 SFTP 标签本地、远程两侧各占一半;两侧和队列之间的分隔线拖到哪里,在这个标签关闭之前都会记住,切到别的标签再回来也不变:
- 第一行(导航):
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。
@@ -94,7 +94,7 @@ shellrs download <ID> <远程路径> <本地路径>
- 书签的改动立即保存,按「取消」也不会撤销,与 WinSCP 相同。
- 回车或「打开」前往目录;Esc 或「取消」关闭。
列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 会话名」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单。这些都不会让列表移动。
列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 会话名」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单;断开时在远程列表里做任何操作(刷新、打开目录、删除、上传下载等)会弹出「SFTP 连接已断开」对话框,说明原因,并可直接「重新连接」。这些都不会让列表移动。
列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。
+162 -3
View File
@@ -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<SharedString>,
/// 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<ResizableState>,
queue_split: Entity<ResizableState>,
_subscriptions: Vec<Subscription>,
_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<Self>) {
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<Self>) {
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<Self>,
) {
// 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<SftpCommand> {
.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));
}
}
+33 -22
View File
@@ -32,6 +32,8 @@ pub(super) struct ListingContext {
pub menu_hit: Rc<RefCell<Option<MenuHit>>>,
/// Where the rows are, for the selection rectangle.
pub geometry: Rc<ListGeometry>,
/// 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<Column> {
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<FileEntry>) -> 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<TableState<Self>>,
) -> 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)
}
}
+49 -8
View File
@@ -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<String>,
/// 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<Self>,
@@ -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()
+6 -9
View File
@@ -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
+14 -3
View File
@@ -9,6 +9,8 @@ use gpui_kit::component::{
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::ToggleSessionPanel;
type MenuBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
/// A dock tab title: a leading mark, the label and, for closable panels, a
@@ -20,9 +22,12 @@ type MenuBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) ->
/// 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 {
+285 -3
View File
@@ -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<Mutex<Option<mpsc::Receiver<()>>>>,
/// Connections made again on 重新连接 (a bare 继续).
reconnects: Arc<Mutex<usize>>,
/// 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<dyn SftpTransport> {
@@ -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<Mutex<Vec<async_channel::Sender<SftpEvent>>>>,
hold: Option<mpsc::Receiver<()>>,
reconnects: Arc<Mutex<usize>>,
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()