SFTP 本地目录列表在 Windows 上列出各个驱动器

Windows 上每个盘是一个独立的根,目录列表原来只有当前路径的各级和主目录、桌面、文稿、下载,
离不开起始的盘(只能在「打开目录/书签」里手动输入 D:\)。现在「位置」里按 WinSCP 的样子
列出所有盘符:「C: 本地磁盘」「E: 可移动磁盘」「Z: 网络驱动器」「F: CD 驱动器」。

盘符用 GetLogicalDrives 和 GetDriveTypeW 取,不读卷标。位置改在后台随每次读本地目录
一起重新取,所以新插上的 U 盘刷新一下就出现,也不在 UI 线程上碰网络驱动器。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-10 13:46:54 +08:00
1 parent 5be3f56b0c
commit e1600ce05c
10 files changed
+184 -24

No files matched your search

+1
View File
@@ -113,6 +113,7 @@ windows-sys = { version = "0.61", features = [
"Win32_System_IO",
"Win32_System_Pipes",
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
"Win32_UI_WindowsAndMessaging",
] }
+1 -1
View File
@@ -251,7 +251,7 @@ Open a host’s “SFTP” tab to browse local and remote folders. As with termi
Each pane has two rows of toolbar, with the path label below them. When a pane is too narrow, buttons hide whole, from right to left, rather than wrapping (their commands are still in the context menus and shortcuts). A new SFTP tab splits evenly between the local and remote sides; wherever you drag the dividers between the sides and the queue, they stay there until the tab closes, even when you switch to another tab and back:
- First row (navigation):
- The folder list: lists every level from the root to the current folder, plus Home, Desktop, Documents and Downloads on the local side; choose one to go there.
- The folder list: lists every level from the root to the current folder, plus Home, Desktop, Documents and Downloads on the local side, and on Windows every drive (such as “C: Local disk” and “D: Local disk”); choose one to go there.
- Bookmarks: the button opens the “Open folder” dialog (see below); the drop-down arrow next to it lists this host’s bookmarks for this side, which you click to go there, and can also add or remove the current path. Bookmarks are saved in the local database and deleted along with the host.
- Parent folder, Root folder, Home folder, Refresh, Back, Forward.
- Show / hide hidden files: files and folders whose names start with `.`. An open eye means they’re shown, a crossed-out eye that they’re hidden. Local and remote each have their own switch, independent of each other; each side’s choice applies to every SFTP tab and is saved in the settings; both are hidden by default. While they’re hidden, the bottom of the pane says “N hidden”; while they’re shown, hidden files’ text is lighter.
+1 -1
View File
@@ -257,7 +257,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
每一侧面板都有两行工具栏,下方是路径标签。面板太窄放不下时,按钮从右往左整个隐藏,不会换行(对应的命令在右键菜单和快捷键里仍然可用)。新开的 SFTP 标签本地、远程两侧各占一半;两侧和队列之间的分隔线拖到哪里,在这个标签关闭之前都会记住,切到别的标签再回来也不变:
- 第一行(导航):
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,Windows 上还有各个驱动器(如「C: 本地磁盘」「D: 本地磁盘」),选中即前往。
- 书签:点按钮打开「打开目录/书签」(见下);旁边的下拉箭头列出这台主机这一侧的书签,点击即前往,也可添加或移除当前路径。书签保存在本地数据库,删除主机时一并删除。
- 上级目录、根目录、主目录、刷新、后退、前进。
- 显示 / 不显示隐藏文件:名称以 `.` 开头的文件和目录。睁开的眼睛表示正在显示,划掉的眼睛表示不显示。本地和远程各自一个开关,互不影响;每一侧的选择对所有 SFTP 标签生效,保存在设置里,默认都不显示。不显示时面板底部写明「隐藏 N 项」;显示时隐藏文件的文字颜色浅一些。
+12
View File
@@ -15,6 +15,18 @@ sftp.place.documents:
sftp.place.downloads:
zh-CN: 下载
en: Downloads
sftp.place.drive_local:
zh-CN: "%{drive} 本地磁盘"
en: "%{drive} Local disk"
sftp.place.drive_removable:
zh-CN: "%{drive} 可移动磁盘"
en: "%{drive} Removable disk"
sftp.place.drive_network:
zh-CN: "%{drive} 网络驱动器"
en: "%{drive} Network drive"
sftp.place.drive_optical:
zh-CN: "%{drive} CD 驱动器"
en: "%{drive} CD drive"
sftp.error.remote_path_invalid:
zh-CN: 远程路径不能为空或包含空字符
-7
View File
@@ -117,18 +117,12 @@ impl ExplorerPanel {
.expect("workspace checked host");
let endpoint = login.endpoint();
let home = local_provider.home().to_string_lossy().into_owned();
let places = local_provider
.places()
.into_iter()
.map(|(place, path)| (place, path.to_string_lossy().into_owned()))
.collect();
let local = cx.new(|cx| {
FilePane::new(
PaneSide::Local,
id,
host_id,
home.clone(),
places,
store.clone(),
dispatch.clone(),
window,
@@ -141,7 +135,6 @@ impl ExplorerPanel {
id,
host_id,
String::new(),
Vec::new(),
store.clone(),
dispatch.clone(),
window,
+24 -13
View File
@@ -132,7 +132,7 @@ pub struct PathChoice {
title: SharedString,
path: SharedString,
depth: usize,
home: bool,
place: Option<Place>,
side: PaneSide,
}
impl SearchableListItem for PathChoice {
@@ -144,10 +144,10 @@ impl SearchableListItem for PathChoice {
&self.path
}
fn render(&self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let icon = if self.home {
Icon::new(CatalogIcon::House)
} else {
Icon::new(IconName::Folder)
let icon = match self.place {
Some(Place::Home) => Icon::new(CatalogIcon::House),
Some(Place::Drive(..)) => Icon::new(IconName::HardDrive),
_ => Icon::new(IconName::Folder),
};
h_flex()
.gap_2()
@@ -182,7 +182,8 @@ pub struct FilePane {
host_id: HostId,
pub(super) path: String,
home: String,
/// Well-known local places for the 目录列表 select; empty for remote.
/// Well-known local places for the 目录列表 select, read with each
/// local listing; empty for remote.
places: Vec<(Place, String)>,
table: Entity<TableState<FileListing>>,
/// The path label part under the pointer, by the directory it opens.
@@ -236,7 +237,6 @@ impl FilePane {
explorer: ExplorerId,
host_id: HostId,
home: String,
places: Vec<(Place, String)>,
store: Entity<HostStore>,
dispatch: FocusHandle,
window: &mut Window,
@@ -290,7 +290,7 @@ impl FilePane {
host_id,
path: home.clone(),
home,
places,
places: Vec::new(),
table,
hovered_part: None,
label_width: Rc::default(),
@@ -496,6 +496,10 @@ impl FilePane {
pub fn is_busy(&self) -> bool {
self.busy
}
/// The places the 目录列表 select offers, as of the last local listing.
pub fn places(&self) -> &[(Place, String)] {
&self.places
}
/// Select this row once the next listing arrives.
pub fn select_after_load(&mut self, name: String) {
self.select_after_load = Some(name);
@@ -935,7 +939,7 @@ impl FilePane {
title: title.into(),
path: path.into(),
depth,
home: false,
place: None,
side,
}),
),
@@ -947,7 +951,7 @@ impl FilePane {
title: place.title(),
path: path.clone().into(),
depth: 0,
home: *place == Place::Home,
place: Some(*place),
side,
}),
),
@@ -969,14 +973,21 @@ impl FilePane {
) {
let id = self.begin_load(intent, cx);
self.load_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = cx
let (result, places) = cx
.background_spawn(async move {
provider
let result = provider
.list(&PathBuf::from(path))
.map_err(|e| t!("explorer.list.read_failed", error = e).to_string())
.map_err(|e| t!("explorer.list.read_failed", error = e).to_string());
let places = provider
.places()
.into_iter()
.map(|(place, path)| (place, path.to_string_lossy().into_owned()))
.collect();
(result, places)
})
.await;
let _ = this.update_in(cx, |this, window, cx| {
this.places = places;
this.apply_listing(id, result, window, cx)
});
}));
+61 -2
View File
@@ -693,6 +693,18 @@ pub enum Place {
Desktop,
Documents,
Downloads,
/// A Windows drive by its letter, as WinSCP lists them: `C: 本地磁盘`.
Drive(char, DriveKind),
}
/// What a Windows drive is, which its title says.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DriveKind {
Local,
Removable,
Network,
Optical,
Other,
}
impl Place {
@@ -702,6 +714,16 @@ impl Place {
Self::Desktop => t!("sftp.place.desktop"),
Self::Documents => t!("sftp.place.documents"),
Self::Downloads => t!("sftp.place.downloads"),
Self::Drive(letter, kind) => {
let drive = format!("{letter}:");
match kind {
DriveKind::Local => t!("sftp.place.drive_local", drive = drive),
DriveKind::Removable => t!("sftp.place.drive_removable", drive = drive),
DriveKind::Network => t!("sftp.place.drive_network", drive = drive),
DriveKind::Optical => t!("sftp.place.drive_optical", drive = drive),
DriveKind::Other => drive.into(),
}
}
}
}
}
@@ -710,8 +732,9 @@ impl Place {
pub trait LocalDirectoryProvider: Send + Sync + 'static {
fn home(&self) -> PathBuf;
fn list(&self, path: &Path) -> Result<DirectoryListing>;
/// Well-known folders for the 目录列表 select, home first. Looked up
/// from the platform, without touching the file system.
/// Well-known folders for the 目录列表 select, home first, then the
/// drives on Windows. Read again with each listing, on the same
/// background executor, so a drive plugged in since shows up.
fn places(&self) -> Vec<(Place, PathBuf)> {
Vec::new()
}
@@ -746,6 +769,41 @@ pub trait LocalDirectoryProvider: Send + Sync + 'static {
) -> Result<FileStamp>;
}
pub type SharedLocalDirectoryProvider = std::sync::Arc<dyn LocalDirectoryProvider>;
/// Every drive letter in use, `A:\` to `Z:\`: without them the local pane
/// could not leave the drive it started on.
#[cfg(windows)]
fn drives() -> Vec<(Place, PathBuf)> {
use windows_sys::Win32::{
Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives},
System::WindowsProgramming::{
DRIVE_CDROM, DRIVE_FIXED, DRIVE_RAMDISK, DRIVE_REMOTE, DRIVE_REMOVABLE,
},
};
// SAFETY: takes nothing and returns a bit mask, bit 0 for `A:`.
let letters = unsafe { GetLogicalDrives() };
(b'A'..=b'Z')
.filter(|letter| letters & (1 << (letter - b'A')) != 0)
.map(|letter| {
let root = format!("{}:\\", char::from(letter));
let wide: Vec<u16> = root.encode_utf16().chain([0]).collect();
// SAFETY: `wide` is a null-terminated UTF-16 string that outlives
// the call.
let kind = match unsafe { GetDriveTypeW(wide.as_ptr()) } {
DRIVE_FIXED | DRIVE_RAMDISK => DriveKind::Local,
DRIVE_REMOVABLE => DriveKind::Removable,
DRIVE_REMOTE => DriveKind::Network,
DRIVE_CDROM => DriveKind::Optical,
_ => DriveKind::Other,
};
(Place::Drive(char::from(letter), kind), PathBuf::from(root))
})
.collect()
}
#[cfg(not(windows))]
fn drives() -> Vec<(Place, PathBuf)> {
Vec::new()
}
#[derive(Default)]
pub struct SystemLocalDirectoryProvider;
impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
@@ -761,6 +819,7 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
]
.into_iter()
.filter_map(|(place, path)| Some((place, path?)))
.chain(drives())
.collect()
}
fn list(&self, path: &Path) -> Result<DirectoryListing> {
+35
View File
@@ -926,6 +926,41 @@ fn paths_and_journals_reject_unsafe_identity() {
});
}
/// Drives are titled as WinSCP does: the letter, then what kind it is.
#[test]
fn drives_are_titled_by_letter_and_kind() {
assert_eq!(Place::Drive('C', DriveKind::Local).title(), "C: 本地磁盘");
assert_eq!(
Place::Drive('E', DriveKind::Removable).title(),
"E: 可移动磁盘"
);
assert_eq!(
Place::Drive('Z', DriveKind::Network).title(),
"Z: 网络驱动器"
);
assert_eq!(
Place::Drive('F', DriveKind::Optical).title(),
"F: CD 驱动器"
);
assert_eq!(Place::Drive('G', DriveKind::Other).title(), "G:");
}
/// Without the drives, the local pane could not leave the one it started on.
#[cfg(windows)]
#[test]
fn local_listing_places_include_the_system_drive() {
let system = std::env::var("SystemDrive").unwrap();
let letter = system.chars().next().unwrap().to_ascii_uppercase();
let places = SystemLocalDirectoryProvider.places();
assert!(
places.contains(&(
Place::Drive(letter, DriveKind::Local),
std::path::PathBuf::from(format!("{letter}:\\"))
)),
"{places:?}"
);
}
/// On Windows std canonicalizes to `\\?\C:\…`, which the pane showed as is.
#[test]
fn local_listing_reports_the_path_people_type() {
+44
View File
@@ -1271,6 +1271,50 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left(
cx.update(|cx| assert_eq!(workspace.read(cx).explorers_of(HostId(DB_01), cx).len(), 2));
}
/// The 目录列表 select's places, Windows drives among them, are read again
/// with each local listing: a drive plugged in shows up on the next refresh.
#[gpui_kit::test]
async fn local_places_are_read_again_with_each_listing(cx: &mut TestAppContext) {
use shellrs::app::{ExplorerAction, ExplorerCommand};
use shellrs::sftp::{DriveKind, Place};
let local = FakeLocalDirectory::default();
let system = (Place::Drive('C', DriveKind::Local), "/c".to_string());
local
.places
.lock()
.unwrap()
.push((system.0, system.1.clone().into()));
let (handle, workspace) =
open_workspace_with_services(cx, Arc::new(FakeSftpProvider::default()), local.clone());
open_test_explorer(cx, handle).await;
let places = |cx: &mut TestAppContext| {
cx.update(|cx| {
let explorer = workspace.read(cx).explorer(ExplorerId(SFTP_TAB)).unwrap();
explorer.read(cx).local().read(cx).places().to_vec()
})
};
assert_eq!(places(cx), std::slice::from_ref(&system));
let stick = (Place::Drive('E', DriveKind::Removable), "/e".to_string());
local
.places
.lock()
.unwrap()
.push((stick.0, stick.1.clone().into()));
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();
assert_eq!(places(cx), [system, stick]);
}
#[gpui_kit::test]
async fn sftp_file_commands_confirm_validate_and_send_one_operation(cx: &mut TestAppContext) {
use shellrs::app::{ExplorerAction, ExplorerCommand};
+5
View File
@@ -271,6 +271,8 @@ pub struct FakeLocalDirectory {
pub calls: Arc<Mutex<Vec<String>>>,
/// The files the editor reads and writes, in memory.
pub files: Arc<Mutex<std::collections::BTreeMap<std::path::PathBuf, Vec<u8>>>>,
/// The places it offers, such as a drive plugged in.
pub places: Arc<Mutex<Vec<(shellrs::sftp::Place, std::path::PathBuf)>>>,
}
impl FakeLocalDirectory {
@@ -287,6 +289,9 @@ impl LocalDirectoryProvider for FakeLocalDirectory {
fn list(&self, path: &std::path::Path) -> anyhow::Result<DirectoryListing> {
Ok(fake_listing(path.to_str().unwrap()))
}
fn places(&self) -> Vec<(shellrs::sftp::Place, std::path::PathBuf)> {
self.places.lock().unwrap().clone()
}
fn trash(&self, paths: &[std::path::PathBuf]) -> anyhow::Result<()> {
self.record(format!("trash {paths:?}"))
}