SFTP:断线时操作远程侧弹出重连对话框,工具栏不换行,分栏对半并按标签记住
- 断线后对远程侧的操作(导航、刷新、删改、属性、所有传输)不再没有 反应:弹出「与 会话名 的 SFTP 连接已断开」,说明原因,可直接「重新 连接」;队列里有停止的批次或正在自动重连时只说明该怎么办。远程工具栏 和菜单在未连接时保持可点,只有连接中才禁用 - 列表还没读到目录时显示「正在连接 SFTP…」「正在读取目录…」,不再 先显示「空目录」 - 工具栏固定一行高,放不下的按钮从右往左整个隐藏,不换行,两侧列表 始终对齐 - 新开的 SFTP 标签本地、远程对半分;分隔线位置(含队列)由标签自己 持有,切换标签后不再被重置(不持久化) - 去掉当前面板路径标签的蓝色下划线 - 双击任一中间区标签的标题,显示 / 隐藏左侧会话栏(同 ⌘B) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
bf72546c64
commit
1e81f85343
8 files changed
+559
-55
No files matched your search
@@ -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
|
||||
@@ -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 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user