Skip to content

Commit 0473250

Browse files
committed
SMB: Show connection status in volume picker
- Add `SmbConnectionState` enum (Direct/OsMount) to `LocationInfo` - Add `smb_connection_state()` to `Volume` trait, SmbVolume maps its atomic state - Enrich volume lists: volumes with `fs_type == "smbfs"` without smb2 session show as OsMount - Extract `is_smb_fs_type()` helper in `volumes/mod.rs` - Green/yellow CSS circle indicator in dropdown and breadcrumb - Yellow state shows submenu trigger (CSS right-pointing triangle) with "Connect directly for faster access" item - Breadcrumb: yellow circle + down arrow inside a clickable button, opens popup menu - All arrows are CSS triangles for consistent rendering across fonts - Submenu: fixed positioning to escape dropdown overflow clip, keyboard nav (ArrowRight/Left/Enter/Escape) - Single cursor: main menu highlight suppressed when submenu is open
1 parent c61bbe5 commit 0473250

14 files changed

Lines changed: 483 additions & 9 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,7 @@ fn should_emit_synthetic_diff(volume_id: Option<&str>) -> bool {
862862
None => true, // No volume_id means local filesystem
863863
Some(id) => get_volume_manager()
864864
.get(id)
865-
.map_or(true, |v| v.supports_local_fs_access()),
865+
.is_none_or(|v| v.supports_local_fs_access()),
866866
}
867867
}
868868

apps/desktop/src-tauri/src/commands/volumes.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use serde::Serialize;
44
use tokio::time::Duration;
55

66
use super::util::{TimedOut, blocking_with_timeout_flag};
7+
use crate::file_system::get_volume_manager;
78
use crate::volumes::{self, DEFAULT_VOLUME_ID, LocationCategory, VolumeInfo, VolumeSpaceInfo};
89

910
/// Result of resolving a path to its containing volume.
@@ -19,13 +20,37 @@ pub struct PathVolumeResolution {
1920
const VOLUME_TIMEOUT: Duration = Duration::from_secs(2);
2021

2122
/// Lists all mounted volumes, including connected MTP devices.
23+
/// Enriches SMB volumes with their connection state from the VolumeManager.
2224
#[tauri::command]
2325
pub async fn list_volumes() -> TimedOut<Vec<VolumeInfo>> {
2426
let mut result = blocking_with_timeout_flag(VOLUME_TIMEOUT, vec![], volumes::list_mounted_volumes).await;
2527
append_mtp_volumes(&mut result.data).await;
28+
enrich_smb_connection_state(&mut result.data);
2629
result
2730
}
2831

32+
/// Enriches volume entries with SMB connection state from the VolumeManager.
33+
///
34+
/// For each volume, looks up the registered Volume in VolumeManager and checks
35+
/// if it reports an `smb_connection_state`. This adds the green/yellow indicator
36+
/// for SMB shares in the frontend volume picker.
37+
fn enrich_smb_connection_state(volumes: &mut [VolumeInfo]) {
38+
use crate::volumes::SmbConnectionState;
39+
40+
let manager = get_volume_manager();
41+
for vol in volumes.iter_mut() {
42+
if let Some(registered) = manager.get(&vol.id) {
43+
vol.smb_connection_state = registered.smb_connection_state();
44+
}
45+
46+
// SMB shares without a direct smb2 connection show as OsMount (yellow).
47+
// This covers pre-existing mounts registered as LocalPosixVolume at startup.
48+
if vol.smb_connection_state.is_none() && volumes::is_smb_fs_type(vol.fs_type.as_deref()) {
49+
vol.smb_connection_state = Some(SmbConnectionState::OsMount);
50+
}
51+
}
52+
}
53+
2954
/// Gets the default volume ID (root filesystem).
3055
#[tauri::command]
3156
pub fn get_default_volume_id() -> String {
@@ -74,6 +99,7 @@ pub async fn resolve_path_volume(path: String) -> PathVolumeResolution {
7499
fs_type: Some("smbfs".to_string()),
75100
supports_trash: false,
76101
is_read_only: false,
102+
smb_connection_state: None,
77103
}),
78104
timed_out: false,
79105
};
@@ -132,6 +158,7 @@ async fn append_mtp_volumes(volumes: &mut Vec<VolumeInfo>) {
132158
is_read_only: storage.is_read_only,
133159
fs_type: Some("mtp".to_string()),
134160
supports_trash: false,
161+
smb_connection_state: None,
135162
});
136163
}
137164
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ pub async fn resolve_path_volume(path: String) -> PathVolumeResolution {
7373
fs_type: Some("cifs".to_string()),
7474
supports_trash: false,
7575
is_read_only: false,
76+
smb_connection_state: None,
7677
}),
7778
timed_out: false,
7879
};
@@ -132,6 +133,7 @@ async fn append_mtp_volumes(volumes: &mut Vec<VolumeInfo>) {
132133
is_read_only: storage.is_read_only,
133134
fs_type: Some("mtp".to_string()),
134135
supports_trash: false,
136+
smb_connection_state: None,
135137
});
136138
}
137139
}

apps/desktop/src-tauri/src/file_system/volume/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Optional methods default to `Err(VolumeError::NotSupported)` or `false`, so new
3939
- `supports_streaming()` — enables chunked MTP-to-MTP transfers. Only `MtpVolume` returns `true`.
4040
- `local_path()` — returns `Some` only for local volumes; allows `copyfile(2)` fast-path in copy operations. `SmbVolume` returns `None` so copies go through smb2 instead of the slow OS mount.
4141
- `supports_local_fs_access()` — whether `std::fs` operations (stat, read_dir) work on this volume's paths. Default `true`. `MtpVolume` returns `false`. Used to skip synthetic entry diffs for protocol-only volumes.
42+
- `smb_connection_state()` — returns `Some(SmbConnectionState)` for SMB volumes (green/yellow indicator in volume picker). Default `None`. Only `SmbVolume` implements it.
4243
- `on_unmount()` — lifecycle hook called before unregistration. `SmbVolume` uses it to disconnect its smb2 session. Default is no-op.
4344
- `scanner()` / `watcher()` — drive indexing hooks; `None` by default.
4445

apps/desktop/src-tauri/src/file_system/volume/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,14 @@ pub trait Volume: Send + Sync {
254254
// Lifecycle: Optional, default no-op
255255
// ========================================
256256

257+
/// Returns the SMB connection state if this is an SMB volume.
258+
///
259+
/// Only `SmbVolume` returns `Some`. Used by the frontend to show a connection
260+
/// quality indicator (green = direct smb2, yellow = OS mount fallback).
261+
fn smb_connection_state(&self) -> Option<crate::volumes::SmbConnectionState> {
262+
None
263+
}
264+
257265
/// Called when the volume is about to be unmounted/unregistered.
258266
///
259267
/// Implementations can use this to clean up resources (disconnect network

apps/desktop/src-tauri/src/file_system/volume/smb.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,14 @@ impl Volume for SmbVolume {
369369
Ok(fs_info_to_space_info(&info))
370370
}
371371

372+
fn smb_connection_state(&self) -> Option<crate::volumes::SmbConnectionState> {
373+
match self.connection_state() {
374+
ConnectionState::Direct => Some(crate::volumes::SmbConnectionState::Direct),
375+
ConnectionState::OsMount => Some(crate::volumes::SmbConnectionState::OsMount),
376+
ConnectionState::Disconnected => None,
377+
}
378+
}
379+
372380
fn on_unmount(&self) {
373381
debug!("SmbVolume::on_unmount: disconnecting share={}", self.share_name);
374382

apps/desktop/src-tauri/src/stubs/volumes.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ pub struct VolumeInfo {
3535
pub supports_trash: bool,
3636
/// Whether this location is read-only.
3737
pub is_read_only: bool,
38+
/// SMB connection state indicator. Always `None` on stub platforms.
39+
#[serde(skip_serializing_if = "Option::is_none")]
40+
pub smb_connection_state: Option<String>,
3841
}
3942

4043
/// Information about volume space.
@@ -73,6 +76,7 @@ pub fn list_volumes() -> Vec<VolumeInfo> {
7376
fs_type: None,
7477
supports_trash: true,
7578
is_read_only: false,
79+
smb_connection_state: None,
7680
});
7781
}
7882
}
@@ -88,6 +92,7 @@ pub fn list_volumes() -> Vec<VolumeInfo> {
8892
fs_type: None,
8993
supports_trash: true,
9094
is_read_only: false,
95+
smb_connection_state: None,
9196
});
9297

9398
// Add home directory
@@ -101,6 +106,7 @@ pub fn list_volumes() -> Vec<VolumeInfo> {
101106
fs_type: None,
102107
supports_trash: true,
103108
is_read_only: false,
109+
smb_connection_state: None,
104110
});
105111

106112
locations

apps/desktop/src-tauri/src/volume_broadcast.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ async fn do_emit() {
156156
#[cfg(any(target_os = "macos", target_os = "linux"))]
157157
append_mtp_volumes(&mut volumes).await;
158158

159+
// Enrich SMB volumes with connection state from VolumeManager
160+
#[cfg(target_os = "macos")]
161+
enrich_smb_connection_state(&mut volumes);
162+
159163
debug!(
160164
"Emitting volumes-changed ({} volumes, timed_out={})",
161165
volumes.len(),
@@ -170,6 +174,24 @@ async fn do_emit() {
170174
}
171175
}
172176

177+
/// Enriches volume entries with SMB connection state from the VolumeManager.
178+
#[cfg(target_os = "macos")]
179+
fn enrich_smb_connection_state(volumes: &mut [LocationInfo]) {
180+
use crate::volumes::SmbConnectionState;
181+
182+
let manager = crate::file_system::get_volume_manager();
183+
for vol in volumes.iter_mut() {
184+
if let Some(registered) = manager.get(&vol.id) {
185+
vol.smb_connection_state = registered.smb_connection_state();
186+
}
187+
188+
// SMB shares without a direct smb2 connection show as OsMount (yellow)
189+
if vol.smb_connection_state.is_none() && crate::volumes::is_smb_fs_type(vol.fs_type.as_deref()) {
190+
vol.smb_connection_state = Some(SmbConnectionState::OsMount);
191+
}
192+
}
193+
}
194+
173195
/// Appends connected MTP device storages to the volume list.
174196
#[cfg(any(target_os = "macos", target_os = "linux"))]
175197
async fn append_mtp_volumes(volumes: &mut Vec<LocationInfo>) {
@@ -198,6 +220,7 @@ async fn append_mtp_volumes(volumes: &mut Vec<LocationInfo>) {
198220
is_read_only: storage.is_read_only,
199221
fs_type: Some("mtp".to_string()),
200222
supports_trash: false,
223+
smb_connection_state: None,
201224
});
202225
}
203226
}

apps/desktop/src-tauri/src/volumes/CLAUDE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ macOS volume and location discovery, plus live mount/unmount watching via FSEven
66

77
| File | Purpose |
88
|---|---|
9-
| `mod.rs` | `LocationInfo` type and `VolumeInfo` type alias (`pub use LocationInfo as VolumeInfo` for backwards compatibility), `LocationCategory` enum. `list_locations()`, `get_volume_space()`, `parse_cloud_provider_name()`, `get_mount_point()` (statfs-based mount resolution with APFS firmlink normalization), `resolve_path_volume_fast()` (builds `VolumeInfo` from statfs without enumerating volumes), and private helpers using `objc2`/`objc2_foundation`. |
9+
| `mod.rs` | `LocationInfo` type and `VolumeInfo` type alias (`pub use LocationInfo as VolumeInfo` for backwards compatibility), `LocationCategory` enum, `SmbConnectionState` enum. `list_locations()`, `get_volume_space()`, `parse_cloud_provider_name()`, `get_mount_point()` (statfs-based mount resolution with APFS firmlink normalization), `resolve_path_volume_fast()` (builds `VolumeInfo` from statfs without enumerating volumes), and private helpers using `objc2`/`objc2_foundation`. |
1010
| `watcher.rs` | `notify` (FSEvents) watcher on `/Volumes`. Detects mount/unmount by diffing against `KNOWN_VOLUMES`. Registers/unregisters with `VolumeManager` via `register_volume_with_manager`/`unregister_volume_from_manager` (coupling to `file_system::get_volume_manager()`). Emits `volume-mounted` / `volume-unmounted` Tauri events (still needed — `DualPaneExplorer` uses `volume-unmounted` with the volume path to redirect panes off ejected volumes). Triggers `volume_broadcast::emit_volumes_changed()` on changes. Spawns a mount-settle watcher that polls `fsid` until the volume metadata is ready. |
1111

1212
## Location categories
@@ -52,6 +52,14 @@ KNOWN_VOLUMES: OnceLock<Mutex<HashSet<String>>>
5252
| `pCloud` | pCloud |
5353
| anything else | first `-`-delimited segment |
5454

55+
## Gotchas
56+
57+
**Gotcha**: Use `is_smb_fs_type()` to detect SMB volumes, never raw `"smbfs"` / `"cifs"` string comparisons
58+
**Why**: The helper in `mod.rs` handles both macOS (`smbfs`) and Linux (`cifs`) in one place. Raw string comparisons scatter platform knowledge and are easy to get wrong.
59+
60+
**Gotcha**: `LocationInfo` enrichment with `VolumeManager` data happens in two places
61+
**Why**: `commands/volumes.rs::enrich_smb_connection_state` (for `list_volumes` IPC calls) and `volume_broadcast.rs::enrich_smb_connection_state` (for `volumes-changed` push events). Both must stay in sync. The pattern is: build the base `LocationInfo` from OS APIs, then cross-reference `VolumeManager` to add runtime state (`smb_connection_state`). If new enrichment fields are added, update both call sites.
62+
5563
## Key decisions
5664

5765
**Decision**: Use `OnceLock` for all three watcher statics (`APP_HANDLE`, `WATCHER`, `KNOWN_VOLUMES`)

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ use serde::{Deserialize, Serialize};
1313
use std::collections::HashSet;
1414
use std::path::Path;
1515

16+
/// SMB connection state for the frontend indicator.
17+
///
18+
/// Only set for volumes backed by an `SmbVolume` in the `VolumeManager`.
19+
/// `Direct` means Cmdr's smb2 session is active (fast path).
20+
/// `OsMount` means only the OS mount is alive (fallback path).
21+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22+
#[serde(rename_all = "snake_case")]
23+
pub enum SmbConnectionState {
24+
/// smb2 session active — fast path (green indicator).
25+
Direct,
26+
/// Using OS mount only — slower fallback (yellow indicator).
27+
OsMount,
28+
}
29+
1630
/// Category of a location item.
1731
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1832
#[serde(rename_all = "snake_case")]
@@ -44,6 +58,9 @@ pub struct LocationInfo {
4458
pub supports_trash: bool,
4559
/// Whether this location is read-only (for example, MTP devices with locked storage).
4660
pub is_read_only: bool,
61+
/// SMB connection state indicator. Only set for volumes with an active `SmbVolume`.
62+
#[serde(skip_serializing_if = "Option::is_none")]
63+
pub smb_connection_state: Option<SmbConnectionState>,
4764
}
4865

4966
/// Default volume ID for the root filesystem.
@@ -65,6 +82,11 @@ pub fn supports_trash_for_fs_type(fs_type: Option<&str>) -> bool {
6582
}
6683
}
6784

85+
/// Returns true if the filesystem type is SMB (macOS `smbfs` or Linux `cifs`).
86+
pub fn is_smb_fs_type(fs_type: Option<&str>) -> bool {
87+
matches!(fs_type, Some("smbfs" | "cifs"))
88+
}
89+
6890
/// Resolve a path to its mount point and filesystem type via `statfs()`.
6991
///
7092
/// On APFS firmlinks, normalizes `/System/Volumes/Data` to `/` (because
@@ -155,6 +177,7 @@ pub fn resolve_path_volume_fast(path: &str) -> Option<VolumeInfo> {
155177
fs_type: Some(fs_type),
156178
supports_trash,
157179
is_read_only: false,
180+
smb_connection_state: None,
158181
})
159182
})
160183
}
@@ -284,6 +307,7 @@ fn get_favorites() -> Vec<LocationInfo> {
284307
fs_type,
285308
supports_trash,
286309
is_read_only: false,
310+
smb_connection_state: None,
287311
}
288312
})
289313
.collect()
@@ -324,6 +348,7 @@ fn get_main_volume() -> Option<LocationInfo> {
324348
fs_type,
325349
supports_trash,
326350
is_read_only: false,
351+
smb_connection_state: None,
327352
});
328353
}
329354
}
@@ -403,6 +428,7 @@ pub fn get_attached_volumes() -> Vec<LocationInfo> {
403428
fs_type,
404429
supports_trash,
405430
is_read_only: false,
431+
smb_connection_state: None,
406432
});
407433
}
408434

@@ -433,6 +459,7 @@ pub fn get_cloud_drives() -> Vec<LocationInfo> {
433459
fs_type,
434460
supports_trash,
435461
is_read_only: false,
462+
smb_connection_state: None,
436463
});
437464
}
438465

@@ -460,6 +487,7 @@ pub fn get_cloud_drives() -> Vec<LocationInfo> {
460487
fs_type,
461488
supports_trash,
462489
is_read_only: false,
490+
smb_connection_state: None,
463491
});
464492
}
465493
}
@@ -522,6 +550,7 @@ fn get_network_locations() -> Vec<LocationInfo> {
522550
fs_type: None,
523551
supports_trash: false,
524552
is_read_only: false,
553+
smb_connection_state: None,
525554
});
526555

527556
locations

0 commit comments

Comments
 (0)