Skip to content

Commit afe2609

Browse files
committed
Linux: Fix nw fs detection + a few fixes
- Fix network filesystem detection - Filter snap loopback mounts and other system internals from Linux volume list - Scroll highlighted volume into view during keyboard navigation - Fix volume dropdown clipping by calculating max-height from actual viewport position
1 parent b03f91e commit afe2609

4 files changed

Lines changed: 119 additions & 12 deletions

File tree

apps/desktop/src-tauri/src/file_system/linux_mounts.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,22 @@ pub fn is_network_filesystem_linux(path: &Path) -> bool {
9191
}
9292

9393
/// Returns true if the filesystem type string represents a network filesystem.
94+
///
95+
/// Known network types are matched explicitly. Unknown `fuse.*` subtypes are
96+
/// treated conservatively as network (we can't distinguish `fuse.mycloud`
97+
/// from a local FUSE mount, and chunked copy is the safer default).
9498
pub fn is_network_fs_type(fstype: &str) -> bool {
95-
matches!(
96-
fstype,
97-
"nfs" | "nfs4" | "cifs" | "smbfs" | "fuse.sshfs" | "ncpfs" | "9p"
98-
)
99+
match fstype {
100+
// Kernel-native network filesystems
101+
"nfs" | "nfs4" | "cifs" | "smbfs" | "ncpfs" | "9p" | "afs" => true,
102+
// FUSE-based network filesystems
103+
"fuse.sshfs" | "fuse.rclone" | "fuse.s3fs" | "fuse.gvfsd-fuse" => true,
104+
// FUSE-based local filesystems (known safe)
105+
"fuse.ntfs-3g" | "fuse.exfat" | "fuseblk" => false,
106+
// Unknown FUSE subtypes — could be network, use chunked copy to be safe
107+
s if s.starts_with("fuse.") => true,
108+
_ => false,
109+
}
99110
}
100111

101112
/// Unescapes octal sequences in mount paths (for example, `\040` -> space).
@@ -180,11 +191,30 @@ user@host:/path /mnt/sshfs fuse.sshfs rw,relatime 0 0
180191

181192
#[test]
182193
fn test_is_network_fs_type() {
194+
// Kernel-native network filesystems
183195
assert!(is_network_fs_type("nfs"));
184196
assert!(is_network_fs_type("nfs4"));
185197
assert!(is_network_fs_type("cifs"));
186198
assert!(is_network_fs_type("smbfs"));
199+
assert!(is_network_fs_type("ncpfs"));
200+
assert!(is_network_fs_type("9p"));
201+
assert!(is_network_fs_type("afs"));
202+
203+
// FUSE-based network filesystems
187204
assert!(is_network_fs_type("fuse.sshfs"));
205+
assert!(is_network_fs_type("fuse.rclone"));
206+
assert!(is_network_fs_type("fuse.s3fs"));
207+
assert!(is_network_fs_type("fuse.gvfsd-fuse"));
208+
209+
// Unknown FUSE subtypes — conservatively treated as network
210+
assert!(is_network_fs_type("fuse.mycloud"));
211+
212+
// Known-local FUSE types
213+
assert!(!is_network_fs_type("fuse.ntfs-3g"));
214+
assert!(!is_network_fs_type("fuse.exfat"));
215+
assert!(!is_network_fs_type("fuseblk"));
216+
217+
// Local filesystems
188218
assert!(!is_network_fs_type("ext4"));
189219
assert!(!is_network_fs_type("xfs"));
190220
assert!(!is_network_fs_type("btrfs"));

apps/desktop/src-tauri/src/volumes_linux/mod.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,14 @@ const VIRTUAL_FS_TYPES: &[&str] = &[
9191
pub fn supports_trash_for_fs_type(fs_type: Option<&str>) -> bool {
9292
let Some(fs) = fs_type else { return true };
9393
let fs_lower = fs.to_ascii_lowercase();
94+
95+
// Network filesystems don't support the FreeDesktop trash spec
96+
if linux_mounts::is_network_fs_type(&fs_lower) {
97+
return false;
98+
}
99+
94100
match fs_lower.as_str() {
95101
"ext4" | "ext3" | "ext2" | "btrfs" | "xfs" | "zfs" | "f2fs" | "reiserfs" => true,
96-
"nfs" | "nfs4" | "cifs" | "smbfs" | "fuse.sshfs" | "ncpfs" | "9p" => false,
97102
"vfat" | "exfat" | "msdos" | "ntfs" | "fuseblk" => false,
98103
_ => true,
99104
}
@@ -213,6 +218,9 @@ pub fn get_mounted_volumes(mounts: &[MountEntry]) -> Vec<LocationInfo> {
213218
if entry.mountpoint == "/" {
214219
continue;
215220
}
221+
if is_hidden_mount(&entry.mountpoint) {
222+
continue;
223+
}
216224

217225
let is_removable = is_removable_mount(&entry.mountpoint, &username);
218226
let name = mount_display_name(&entry.mountpoint);
@@ -326,6 +334,21 @@ fn is_virtual_fs(fstype: &str) -> bool {
326334
VIRTUAL_FS_TYPES.contains(&fstype)
327335
}
328336

337+
/// Mount paths that are system internals and should never appear as volumes.
338+
/// These are path prefixes — any mount whose mountpoint starts with one of these is filtered out.
339+
const HIDDEN_MOUNT_PREFIXES: &[&str] = &[
340+
"/snap/", // Ubuntu snap loopback packages (squashfs)
341+
"/run/snapd/", // Snap daemon internals
342+
"/boot/", // EFI system partition, boot loaders
343+
"/run/user/", // Per-user runtime mounts (XDG portals, GVFS)
344+
"/run/credentials/", // systemd credential mounts
345+
];
346+
347+
/// Check if a mount path should be hidden from the volume list.
348+
fn is_hidden_mount(mountpoint: &str) -> bool {
349+
HIDDEN_MOUNT_PREFIXES.iter().any(|prefix| mountpoint.starts_with(prefix))
350+
}
351+
329352
/// Check if a mount point is under a removable media path.
330353
fn is_removable_mount(mountpoint: &str, username: &str) -> bool {
331354
if username.is_empty() {
@@ -429,6 +452,43 @@ tmpfs /tmp tmpfs rw,nosuid,nodev 0 0
429452
assert!(supports_trash_for_fs_type(Some("somefs")));
430453
}
431454

455+
#[test]
456+
fn test_is_hidden_mount() {
457+
assert!(is_hidden_mount("/snap/firefox/7764"));
458+
assert!(is_hidden_mount("/snap/core22/2134"));
459+
assert!(is_hidden_mount("/run/snapd/ns/something.mnt"));
460+
assert!(is_hidden_mount("/boot/efi"));
461+
assert!(is_hidden_mount("/run/user/1000/doc"));
462+
assert!(is_hidden_mount("/run/user/1000/gvfs"));
463+
assert!(is_hidden_mount("/run/credentials/systemd-journald.service"));
464+
assert!(!is_hidden_mount("/mnt/data"));
465+
assert!(!is_hidden_mount("/home"));
466+
assert!(!is_hidden_mount("/media/user/USB"));
467+
assert!(!is_hidden_mount("/run/media/user/USB"));
468+
}
469+
470+
#[test]
471+
fn test_snap_mounts_filtered_from_volumes() {
472+
let mounts_with_snaps = "\
473+
/dev/sda1 / ext4 rw,relatime 0 0
474+
/dev/loop0 /snap/bare/5 squashfs ro,nodev,relatime 0 0
475+
/dev/loop2 /snap/firefox/7764 squashfs ro,nodev,relatime 0 0
476+
/dev/loop8 /snap/snap-store/1271 squashfs ro,nodev,relatime 0 0
477+
/dev/sdb1 /mnt/data xfs rw,relatime 0 0
478+
tmpfs /run/user/1000 tmpfs rw,nosuid,nodev,relatime 0 0
479+
portal /run/user/1000/doc fuse.portal rw 0 0
480+
gvfsd-fuse /run/user/1000/gvfs fuse.gvfsd-fuse rw 0 0
481+
/dev/vda1 /boot/efi vfat rw,relatime 0 0
482+
";
483+
let mounts = linux_mounts::parse_proc_mounts_from_content(mounts_with_snaps);
484+
let volumes = get_mounted_volumes(&mounts);
485+
let paths: Vec<&str> = volumes.iter().map(|v| v.path.as_str()).collect();
486+
assert!(paths.contains(&"/mnt/data"), "Should include real mount");
487+
assert!(!paths.iter().any(|p| p.starts_with("/snap/")), "Should filter snap mounts");
488+
assert!(!paths.iter().any(|p| p.starts_with("/boot/")), "Should filter boot mounts");
489+
assert!(!paths.iter().any(|p| p.starts_with("/run/user/")), "Should filter user runtime mounts");
490+
}
491+
432492
#[test]
433493
fn test_get_mounted_volumes_filters_virtual() {
434494
let mounts = parse_test_mounts();

apps/desktop/src/lib/file-explorer/navigation/VolumeBreadcrumb.svelte

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { onMount, onDestroy } from 'svelte'
2+
import { onMount, onDestroy, tick } from 'svelte'
33
import { listVolumes, findContainingVolume, listen, type UnlistenFn } from '$lib/tauri-commands'
44
import { getVolumeSpace, type VolumeSpaceInfo } from '$lib/tauri-commands/storage'
55
import { SvelteMap } from 'svelte/reactivity'
@@ -70,18 +70,28 @@
7070
// Flat list of all volumes for keyboard navigation
7171
const allVolumes = $derived(groupedVolumes.flatMap((g) => g.items))
7272
73-
// When dropdown opens, initialize highlight to current volume
73+
// When dropdown opens, initialize highlight to current volume and fit to viewport
7474
$effect(() => {
7575
if (isOpen) {
7676
const currentIdx = allVolumes.findIndex((v) => shouldShowCheckmark(v))
7777
highlightedIndex = currentIdx >= 0 ? currentIdx : 0
78+
void fitDropdownToViewport()
7879
} else {
7980
highlightedIndex = -1
8081
isKeyboardMode = false
8182
lastMousePos = null
8283
}
8384
})
8485
86+
async function fitDropdownToViewport() {
87+
await tick()
88+
const dropdown = dropdownRef?.querySelector('.volume-dropdown') as HTMLElement | null
89+
if (dropdown) {
90+
const top = dropdown.getBoundingClientRect().top
91+
dropdown.style.maxHeight = `${window.innerHeight - top - 8}px`
92+
}
93+
}
94+
8595
// Get appropriate icon for a volume (use cloud icon for cloud drives, mobile icon for devices)
8696
function getIconForVolume(volume: VolumeInfo | undefined): string | undefined {
8797
if (!volume) return undefined
@@ -268,6 +278,13 @@
268278
function enterKeyboardMode() {
269279
isKeyboardMode = true
270280
lastMousePos = null // Will be captured on next mousemove
281+
void scrollHighlightedIntoView()
282+
}
283+
284+
async function scrollHighlightedIntoView() {
285+
await tick()
286+
const el = dropdownRef?.querySelector(`.volume-item[data-index="${highlightedIndex}"]`) as HTMLElement | null
287+
el?.scrollIntoView({ block: 'nearest' })
271288
}
272289
273290
// Handle mouse hover to sync with keyboard navigation
@@ -562,7 +579,7 @@
562579
left: 0;
563580
margin-top: 4px;
564581
min-width: 220px;
565-
max-height: calc(100vh - 30px);
582+
max-height: calc(100vh - 30px); /* Fallback — overridden dynamically by fitDropdownToViewport() */
566583
overflow-y: auto;
567584
background-color: var(--color-bg-secondary);
568585
border: 1px solid var(--color-border-strong);

docs/specs/linux-remaining-gaps.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ Each section is self-contained and can be handed to an agent independently.
1616
- [x] 6. Linux-specific error messages (small) — audit and replace macOS terminology
1717
- [x] 7. Credential storage resilience (medium) — Secret Service → keyutils → encrypted file fallback
1818
- [-] 8. ~~Media eject — deferred (not on macOS either)~~
19-
- [ ] 9. High-DPI support — no code changes needed, just verification
20-
- [ ] 10. Trash implementation (medium) — `trash` crate, wire up in lib.rs
21-
- [ ] 11. Network filesystem detection for copy (medium) — `/proc/self/mountinfo` parser, copy strategy
19+
- [x] 9. High-DPI support — no code changes needed, just verification
20+
- [x] 10. Trash implementation (medium) — `trash` crate, wire up in lib.rs
21+
- [x] 11. Network filesystem detection for copy (medium) — `/proc/self/mountinfo` parser, copy strategy
2222
- [x] 12. File watching E2E verification (small) — add inotify E2E test
2323
- [ ] 13. MTP USB permissions (small) — error messages + packaging metadata
2424
- [ ] 14. SMB mounting completion (large) — `smbclient` fallback, auth prompts, cross-DE testing
25-
- [ ] 15. Custom drag image — deferred (no WebKitGTK API)
25+
- [-] 15. Custom drag image — deferred (no WebKitGTK API)
2626
- [ ] 16. Dropbox sync status (medium) — socket protocol + CLI fallback
2727
- [ ] 17. Native file icons (medium) — test existing provider, fix threading if needed
2828

0 commit comments

Comments
 (0)