From 27822e73152f027cb813f9aa62882a8dc6489856 Mon Sep 17 00:00:00 2001 From: Xuz Date: Sat, 10 Oct 2026 13:46:54 +0800 Subject: [PATCH] =?UTF-8?q?SFTP=20=E6=96=87=E4=BB=B6=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=82=B9=E5=88=97=E5=90=8D=E5=8D=B3=E5=8F=AF=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=EF=BC=8C=E6=8D=A2=E7=9B=AE=E5=BD=95=E5=90=8E=E5=88=97=E5=AE=BD?= =?UTF-8?q?=E5=92=8C=E6=8E=92=E5=BA=8F=E7=AE=AD=E5=A4=B4=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=A4=8D=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpui-kit 只给列标题右边的箭头挂了排序,标题本身的点击用来选列,而文件列表关掉了选列, 所以只能点箭头。现在点列名和点箭头一样:降序、升序、恢复按名称。 表格每次 refresh(每读一次目录)都从委托重新取列,原来拖宽的列会弹回默认宽度,箭头也回到 「未排序」而行仍按原列排着。现在 FileListing 记下拖动后的列宽(ColumnWidthsChanged)和 当前排序,refresh 后照旧。 Co-Authored-By: Claude Opus 5.5 --- docs/manual.en.md | 2 +- docs/manual.md | 2 +- src/explorer/file_listing.rs | 58 ++++++++++++--- src/explorer/file_pane.rs | 7 +- tests/ui/sftp.rs | 133 +++++++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 11 deletions(-) diff --git a/docs/manual.en.md b/docs/manual.en.md index 63ca857..d5b81f1 100644 --- a/docs/manual.en.md +++ b/docs/manual.en.md @@ -281,7 +281,7 @@ Works like WinSCP’s Open directory dialog. Open it from the path label, the bo The status bar below the list normally shows the number of items and how many are selected; while connecting, or when reading a folder takes longer than 0.3 seconds, it shows “Connecting to SFTP…” or “Reading folder…”. When a folder can’t be read or the connection drops, the reason appears in red at the far bottom left of the window (where “Connected to” and the host’s name normally are). A dropped connection is noticed even while idle, without waiting for a refresh, as soon as the same host’s terminal would notice. To reconnect, use the “Reconnect” button in the tab bar or the tab menu. While disconnected, any action in the remote list (refreshing, opening a folder, deleting, uploading, downloading and so on) brings up a “Lost the SFTP connection to …” dialog that explains why and can “Reconnect” right away. None of this makes the list move. -The columns are the same as in WinSCP: Name, Size, Type and Modified on the local side; Name, Size, Modified, Permissions and Owner on the remote side. +The columns are the same as in WinSCP: Name, Size, Type and Modified on the local side; Name, Size, Modified, Permissions and Owner on the remote side. Click a column header (or the arrow at its right) to sort by that column: descending, then ascending, then back to by name; folders always come before files. The order and any column widths you dragged stay when you open another folder or refresh. - Sizes are shown in KB by default (`4,008,960 KB`, rounded up; only empty files are 0 KB), as in WinSCP. Right-click the “Size” column header to switch to “Bytes” (`4,105,175,040 B`), “Kilobytes (KB)” or “Short (B, KB, MB, GB)”, which uses B, KB, MB or GB depending on the size, such as `3.8 GB`; the choice applies to every SFTP tab and is saved in the settings. Sorting by size always uses the exact number of bytes. - Times look like `2026/4/22 12:44:53`. diff --git a/docs/manual.md b/docs/manual.md index b4303b4..acd4db7 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -287,7 +287,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以 列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 主机的名称」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单;断开时在远程列表里做任何操作(刷新、打开目录、删除、上传下载等)会弹出「SFTP 连接已断开」对话框,说明原因,并可直接「重新连接」。这些都不会让列表移动。 -列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。 +列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。点列标题(或它右边的箭头)按这一列排序,依次是降序、升序、恢复按名称;目录始终排在文件前面。换目录、刷新后排序和拖动过的列宽都保持不变。 - 大小默认以 KB 显示(`4,008,960 KB`,向上取整,只有空文件是 0 KB),与 WinSCP 相同。右键「大小」列标题可切换为「字节」(`4,105,175,040 B`)、「千字节(KB)」或「简短格式」(按大小用 B、KB、MB、GB,如 `3.8 GB`),对所有 SFTP 标签生效并保存在设置里。按大小排序始终按实际字节数。 - 时间形如 `2026/4/22 12:44:53`。 diff --git a/src/explorer/file_listing.rs b/src/explorer/file_listing.rs index 26b727a..7b9eae6 100644 --- a/src/explorer/file_listing.rs +++ b/src/explorer/file_listing.rs @@ -199,6 +199,43 @@ impl FileListing { } } + /// Keep the widths the columns were dragged to: the table takes the + /// columns again on each `refresh`, every listing among them. + pub(super) fn set_widths(&mut self, widths: &[Pixels]) { + for (column, width) in self.columns.iter_mut().zip(widths) { + column.width = *width; + } + } + + /// A click on a column's title sorts as one on its arrow does: + /// descending, then ascending, then back to by name. + fn cycle_sort(&mut self, col_ix: usize) { + let sort = match self.sort { + Some((ix, ColumnSort::Descending)) if ix == col_ix => ColumnSort::Ascending, + Some((ix, ColumnSort::Ascending)) if ix == col_ix => ColumnSort::Default, + _ => ColumnSort::Descending, + }; + self.set_sort(col_ix, sort); + } + + /// Sort by a column, marking it on the columns too, which the table + /// takes again on each `refresh`: otherwise the arrows would go back to + /// unsorted with the next listing while the rows stayed sorted. + fn set_sort(&mut self, col_ix: usize, sort: ColumnSort) { + self.sort = match sort { + ColumnSort::Default => None, + sort => Some((col_ix, sort)), + }; + for (ix, column) in self.columns.iter_mut().enumerate() { + column.sort = Some(if ix == col_ix { + sort + } else { + ColumnSort::Default + }); + } + self.apply_sort(); + } + /// The rows shown, in display order. pub fn rows(&self) -> &[FileEntry] { &self.rows @@ -406,13 +443,14 @@ impl TableDelegate for FileListing { } /// `Column::text_right` is not applied by `DataTable`, so the size - /// column aligns itself. A right-click on a title is recorded for the - /// list's menu, which offers the size formats on 大小's, as WinSCP does. + /// column aligns itself. A click on a title sorts by the column, as in + /// WinSCP, rather than only one on the arrow. A right-click is recorded + /// for the list's menu, which offers the size formats on 大小's. fn render_th( &mut self, col_ix: usize, _: &mut Window, - _: &mut Context>, + cx: &mut Context>, ) -> impl IntoElement { let column = &self.columns[col_ix]; let key = column.key.clone(); @@ -428,6 +466,14 @@ impl TableDelegate for FileListing { *menu_hit.borrow_mut() = Some(MenuHit::Column(key.clone())); }) .on_any_mouse_down(move |_, _, _| geometry.pressed.set(Pressed::Title)) + // The table keeps the arrows in its columns, which only a + // `refresh` gives it again. + .on_click(cx.listener(move |table, _, _, cx| { + cx.stop_propagation(); + table.delegate_mut().cycle_sort(col_ix); + table.refresh(cx); + cx.notify(); + })) .child(column.name.clone()) } @@ -593,11 +639,7 @@ impl TableDelegate for FileListing { _: &mut Window, cx: &mut Context>, ) { - self.sort = match sort { - ColumnSort::Default => None, - sort => Some((col_ix, sort)), - }; - self.apply_sort(); + self.set_sort(col_ix, sort); cx.notify(); } diff --git a/src/explorer/file_pane.rs b/src/explorer/file_pane.rs index 2ea29af..5b860d9 100644 --- a/src/explorer/file_pane.rs +++ b/src/explorer/file_pane.rs @@ -28,7 +28,7 @@ use gpui_kit::component::{ searchable_list::{SearchableGroup, SearchableListItem, SearchableVec}, select::{Select, SelectEvent, SelectState}, separator::Separator, - table::{DataTable, TableDelegate as _, TableState}, + table::{DataTable, TableDelegate as _, TableEvent, TableState}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; @@ -272,6 +272,11 @@ impl FilePane { } }, ), + cx.subscribe(&table, |_, table, event: &TableEvent, cx| { + if let TableEvent::ColumnWidthsChanged(widths) = event { + table.update(cx, |table, _| table.delegate_mut().set_widths(widths)); + } + }), cx.observe(&store, |_, _, cx| cx.notify()), cx.observe_global::(|pane, cx| pane.sync_hidden_files(cx)), // The column titles and the select's groups are kept by diff --git a/tests/ui/sftp.rs b/tests/ui/sftp.rs index 6c6ae22..e06a5dc 100644 --- a/tests/ui/sftp.rs +++ b/tests/ui/sftp.rs @@ -1558,6 +1558,139 @@ async fn sftp_dragging_outside_the_names_selects_the_rows_crossed(cx: &mut TestA cx.update(|cx| assert_eq!(pane_selection(&workspace, true, cx), ["目录", "链接目录"])); } +/// A click on a column title sorts by the column, as one on its arrow does: +/// descending, ascending, then back to by name. The order outlives a +/// refresh, after which the next click carries on from it. +#[gpui_kit::test] +async fn sftp_clicking_a_column_title_sorts_by_it(cx: &mut TestAppContext) { + use shellrs::app::{ExplorerAction, ExplorerCommand}; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, workspace) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + let pane = ("local-pane", SFTP_TAB); + let names = |cx: &mut TestAppContext| { + cx.run_until_parked(); + cx.update(|cx| { + let explorer = workspace.read(cx).explorer(ExplorerId(SFTP_TAB)).unwrap(); + let entries = explorer.read(cx).local().read(cx).entries(cx); + entries + .into_iter() + .map(|entry| entry.name.to_string()) + .collect::>() + }) + }; + let click_size = |cx: &mut TestAppContext| { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.within(pane).click("column:size", cx); + }) + .unwrap(); + }; + let by_name = ["..", "目录", "链接目录", "文件 乙.txt", "文件 甲.txt"]; + assert_eq!(names(cx), by_name); + + // Folders stay ahead of files whichever way. + click_size(cx); + let descending = ["..", "链接目录", "目录", "文件 乙.txt", "文件 甲.txt"]; + assert_eq!(names(cx), descending); + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + ExplorerId(SFTP_TAB), + ExplorerCommand::Refresh { remote: false }, + )), + cx, + ); + }) + .unwrap(); + assert_eq!(names(cx), descending); + + click_size(cx); + assert_eq!( + names(cx), + ["..", "目录", "链接目录", "文件 甲.txt", "文件 乙.txt"] + ); + click_size(cx); + assert_eq!(names(cx), by_name); +} + +/// A column dragged wider stays so with the next listing, which gives the +/// table its columns again. +#[gpui_kit::test] +async fn sftp_column_widths_outlive_a_refresh(cx: &mut TestAppContext) { + use gpui_kit::{Modifiers, MouseDownEvent, MouseUpEvent}; + use shellrs::app::{ExplorerAction, ExplorerCommand}; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, _workspace) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + let pane = ("local-pane", SFTP_TAB); + let width = |cx: &mut TestAppContext| { + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within(pane) + .find(("col-header", 1usize)) + .bounds() + .size + .width + }) + .unwrap() + }; + let before = width(cx); + // Drag the boundary right of 大小 by 60 pixels. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let bounds = window.within(pane).find(("col-header", 1usize)).bounds(); + let start = point(bounds.right() - px(1.), bounds.center().y); + let event = |x: f32| MouseMoveEvent { + position: point(start.x + px(x), start.y), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }; + window.dispatch_event( + MouseDownEvent { + button: MouseButton::Left, + position: start, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + } + .to_platform_input(), + cx, + ); + for x in [10., 30., 60.] { + window.dispatch_event(event(x).to_platform_input(), cx); + window.render_frame(cx); + } + window.dispatch_event( + MouseUpEvent { + button: MouseButton::Left, + position: point(start.x + px(60.), start.y), + modifiers: Modifiers::default(), + click_count: 1, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + let dragged = width(cx); + assert!(dragged > before + px(40.), "{before:?} -> {dragged:?}"); + + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + ExplorerId(SFTP_TAB), + ExplorerCommand::Refresh { remote: false }, + )), + cx, + ); + }) + .unwrap(); + assert_eq!(width(cx), dragged); +} + /// As in WinSCP without full row select, only the name cell is the item: a /// click on the rest of a row, or below the rows, is a click on empty space /// and clears the selection. A ⌘ click there, or a click on a column title,