Skip to content

Commit 2df24ac

Browse files
committed
SMB: Add "Connect to server" for manual hosts
- "Connect to server..." pseudo-row in network browser with dialog for entering hostname, IP, IP:port, or smb:// URL - Backend: address parsing, TCP reachability check (5s timeout), persistence to `manual-servers.json`, injection into `DISCOVERY_STATE` at startup - `HostSource` enum (`Discovered` | `Manual`) on `NetworkHost` with backward-compatible `#[serde(default)]` - Native context menu on hosts: Disconnect (unmounts via `diskutil`), Forget server, Forget saved password - F8 to remove manual hosts with confirmation - `autoMountShare` on `ShareBrowser`: typing `smb://server/share` auto-mounts the share - MCP tools: `connect_to_server`, `remove_manual_server` - MCP state encodes `source=manual|discovered` for each host - Port now passed through `mountNetworkShare` so `register_smb_volume` uses the correct port for smb2 direct connections - Atomic writes (temp + rename) for `manual-servers.json` and `known-shares.json` - Fix panic on SMB volume mount: `tokio::spawn` → `tauri::async_runtime::spawn` in fsevents watcher thread (no Tokio runtime) - 35 unit tests + 3 integration tests (behind `smb-e2e` feature)
1 parent e72c082 commit 2df24ac

File tree

30 files changed

+1855
-74
lines changed

30 files changed

+1855
-74
lines changed

apps/desktop/coverage-allowlist.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
"file-operations/mkdir/NewFolderDialog.svelte": {
5454
"reason": "UI modal, logic tested in new-folder-utils.test.ts"
5555
},
56+
"file-explorer/network/ConnectToServerDialog.svelte": {
57+
"reason": "Network dialog, depends on Tauri connectToServer command"
58+
},
5659
"file-explorer/network/NetworkBrowser.svelte": { "reason": "Network component, needs Tauri integration" },
5760
"file-explorer/pane/PaneResizer.svelte": { "reason": "Mouse drag UI component, difficult to unit test" },
5861
"file-explorer/network/NetworkLoginForm.svelte": { "reason": "Network component, needs Tauri integration" },

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub async fn resolve_host(host_id: String) -> Option<NetworkHost> {
3434
hostname: info.hostname,
3535
ip_address: info.ip_address,
3636
port: info.port,
37+
source: info.source,
3738
});
3839
}
3940

@@ -487,3 +488,37 @@ pub(crate) async fn get_keychain_password(
487488
.ok()
488489
.flatten()
489490
}
491+
492+
// --- Disconnect Command ---
493+
494+
/// Unmounts all SMB shares mounted from a given server.
495+
/// Returns the list of mount paths that were unmounted.
496+
#[tauri::command]
497+
pub async fn disconnect_network_host(
498+
_host_id: String,
499+
host_name: String,
500+
ip_address: Option<String>,
501+
) -> Result<Vec<String>, String> {
502+
let result =
503+
tokio::task::spawn_blocking(move || mount::unmount_smb_shares_from_host(&host_name, ip_address.as_deref()))
504+
.await
505+
.map_err(|e| format!("Disconnect task failed: {}", e))?;
506+
507+
Ok(result)
508+
}
509+
510+
// --- Manual Server Commands ---
511+
512+
use crate::network::manual_servers::{self, ManualConnectResult};
513+
514+
/// Connects to a manually-specified server: parses, checks reachability, persists, and injects.
515+
#[tauri::command]
516+
pub async fn connect_to_server(address: String, app_handle: tauri::AppHandle) -> Result<ManualConnectResult, String> {
517+
manual_servers::add_manual_server(&address, &app_handle).await
518+
}
519+
520+
/// Removes a manually-added server by ID.
521+
#[tauri::command]
522+
pub fn remove_manual_server(server_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
523+
manual_servers::remove_manual_server(&server_id, &app_handle)
524+
}

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::ignore_poison::IgnorePoison;
22
use crate::menu::{
3-
CLOSE_TAB_ID, CommandScope, MenuState, build_context_menu, build_tab_context_menu, menu_id_to_command,
3+
CLOSE_TAB_ID, CommandScope, MenuState, build_context_menu, build_network_host_context_menu, build_tab_context_menu,
4+
menu_id_to_command,
45
};
56
#[cfg(any(target_os = "macos", target_os = "linux"))]
67
use std::process::Command;
@@ -219,6 +220,34 @@ pub fn show_tab_context_menu(
219220
Ok(())
220221
}
221222

223+
/// Shows a native context menu for a network host (fire-and-forget).
224+
/// The selected action is delivered asynchronously via a `network-host-context-action` Tauri event
225+
/// from `on_menu_event`.
226+
#[tauri::command]
227+
pub fn show_network_host_context_menu(
228+
window: Window<tauri::Wry>,
229+
host_id: String,
230+
host_name: String,
231+
is_manual: bool,
232+
has_credentials: bool,
233+
) -> Result<(), String> {
234+
let app = window.app_handle().clone();
235+
236+
let menu = build_network_host_context_menu(&app, is_manual, has_credentials).map_err(|e| e.to_string())?;
237+
238+
// Store context so on_menu_event can include host info in the emitted event
239+
{
240+
let state = app.state::<MenuState<tauri::Wry>>();
241+
let mut ctx = state.network_host_context.lock_ignore_poison();
242+
ctx.host_id = host_id;
243+
ctx.host_name = host_name;
244+
}
245+
246+
menu.popup(window).map_err(|e| e.to_string())?;
247+
248+
Ok(())
249+
}
250+
222251
/// Updates the File menu "Pin tab" / "Unpin tab" label based on the active tab's pin state.
223252
#[tauri::command]
224253
pub fn update_pin_tab_menu<R: Runtime>(app: AppHandle<R>, is_pinned: bool) -> Result<(), String> {

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -725,15 +725,18 @@ impl Volume for SmbVolume {
725725
}
726726

727727
fn on_unmount(&self) {
728-
debug!("SmbVolume::on_unmount: disconnecting share={}", self.share_name);
729-
730728
// Transition to Disconnected
731729
self.state.store(ConnectionState::Disconnected as u8, Ordering::Relaxed);
732730

733-
// Drop the smb2 session (graceful disconnect)
731+
// Drop the smb2 session. This is fully synchronous — dropping the TCP
732+
// stream calls close() on the socket fd. The server handles abrupt
733+
// disconnects fine, so no async graceful disconnect is needed.
734+
// Important: this runs on the notify-rs fsevents thread (no Tokio runtime).
734735
if let Ok(mut guard) = self.smb.lock() {
735736
*guard = None;
736737
}
738+
739+
debug!("SmbVolume cleanup for {}: smb2 session dropped", self.share_name);
737740
}
738741
}
739742

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

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,9 @@ mod volumes_linux;
117117
mod stubs;
118118

119119
use menu::{
120-
CLOSE_TAB_ID, CommandScope, EDIT_COPY_ID, EDIT_CUT_ID, EDIT_PASTE_ID, MenuState, SHOW_HIDDEN_FILES_ID,
121-
SORT_ASCENDING_ID, SORT_BY_CREATED_ID, SORT_BY_EXTENSION_ID, SORT_BY_MODIFIED_ID, SORT_BY_NAME_ID, SORT_BY_SIZE_ID,
120+
CLOSE_TAB_ID, CommandScope, EDIT_COPY_ID, EDIT_CUT_ID, EDIT_PASTE_ID, MenuState, NETWORK_HOST_DISCONNECT_ID,
121+
NETWORK_HOST_FORGET_PASSWORD_ID, NETWORK_HOST_FORGET_SERVER_ID, SHOW_HIDDEN_FILES_ID, SORT_ASCENDING_ID,
122+
SORT_BY_CREATED_ID, SORT_BY_EXTENSION_ID, SORT_BY_MODIFIED_ID, SORT_BY_NAME_ID, SORT_BY_SIZE_ID,
122123
SORT_DESCENDING_ID, TAB_CLOSE_ID, TAB_CLOSE_OTHERS_ID, TAB_PIN_ID, VIEW_MODE_BRIEF_ID, VIEW_MODE_FULL_ID,
123124
VIEWER_WORD_WRAP_ID, ViewMode, menu_id_to_command,
124125
};
@@ -348,6 +349,10 @@ pub fn run() {
348349
#[cfg(any(target_os = "macos", target_os = "linux"))]
349350
network::known_shares::load_known_shares(app.handle());
350351

352+
// Load manually-added servers and inject into discovery state
353+
#[cfg(any(target_os = "macos", target_os = "linux"))]
354+
network::manual_servers::load_manual_servers(app.handle());
355+
351356
// Drag image detection swizzle is installed in RunEvent::Ready (not here)
352357
// because wry 0.54+ registers the WryWebView ObjC class lazily — it doesn't
353358
// exist in the runtime until the first webview is created, which happens after
@@ -562,6 +567,32 @@ pub fn run() {
562567
return;
563568
}
564569

570+
// === Network host context menu actions ===
571+
if id == NETWORK_HOST_FORGET_SERVER_ID
572+
|| id == NETWORK_HOST_FORGET_PASSWORD_ID
573+
|| id == NETWORK_HOST_DISCONNECT_ID
574+
{
575+
let menu_state = app.state::<MenuState<tauri::Wry>>();
576+
let ctx = menu_state.network_host_context.lock_ignore_poison();
577+
let action = if id == NETWORK_HOST_FORGET_SERVER_ID {
578+
"forget-server"
579+
} else if id == NETWORK_HOST_FORGET_PASSWORD_ID {
580+
"forget-password"
581+
} else {
582+
"disconnect"
583+
};
584+
let _ = app.emit_to(
585+
"main",
586+
"network-host-context-action",
587+
serde_json::json!({
588+
"action": action,
589+
"hostId": ctx.host_id,
590+
"hostName": ctx.host_name,
591+
}),
592+
);
593+
return;
594+
}
595+
565596
// === Clipboard exception: file clipboard in main window, native text clipboard elsewhere ===
566597
// Custom MenuItems for Cut/Copy/Paste route through execute-command in the main window
567598
// so the frontend can decide between file and text clipboard. In non-main windows
@@ -673,6 +704,7 @@ pub fn run() {
673704
commands::icons::clear_directory_icon_cache,
674705
commands::ui::show_file_context_menu,
675706
commands::ui::show_tab_context_menu,
707+
commands::ui::show_network_host_context_menu,
676708
commands::ui::update_pin_tab_menu,
677709
commands::ui::show_main_window,
678710
commands::ui::update_menu_context,
@@ -824,6 +856,12 @@ pub fn run() {
824856
commands::network::mount_network_share,
825857
#[cfg(any(target_os = "macos", target_os = "linux"))]
826858
commands::network::upgrade_to_smb_volume,
859+
#[cfg(any(target_os = "macos", target_os = "linux"))]
860+
commands::network::connect_to_server,
861+
#[cfg(any(target_os = "macos", target_os = "linux"))]
862+
commands::network::remove_manual_server,
863+
#[cfg(any(target_os = "macos", target_os = "linux"))]
864+
commands::network::disconnect_network_host,
827865
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
828866
stubs::network::list_network_hosts,
829867
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
@@ -860,6 +898,12 @@ pub fn run() {
860898
stubs::network::mount_network_share,
861899
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
862900
stubs::network::upgrade_to_smb_volume,
901+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
902+
stubs::network::connect_to_server,
903+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
904+
stubs::network::remove_manual_server,
905+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
906+
stubs::network::disconnect_network_host,
863907
// Accent color command (macOS reads NSColor, Linux reads gsettings, others return fallback)
864908
#[cfg(target_os = "macos")]
865909
accent_color::get_accent_color,

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
2121

2222
### Tools (`tools.rs`)
2323

24-
27 semantic tools grouped by category:
24+
29 semantic tools grouped by category:
2525
- Navigation (6): `select_volume` (also accepts MTP volume names), `nav_to_path` (supports `mtp://` paths, skips filesystem existence check), `nav_to_parent`, `nav_back`, `nav_forward`, `scroll_to`
2626
- Cursor/Selection (3): `move_cursor`, `open_under_cursor`, `select`
2727
- File operations (6): `copy`, `move`, `delete`, `mkdir`, `mkfile`, `refresh`. `copy`/`move` accept optional `autoConfirm` (bool) and `onConflict` (`skip_all`|`overwrite_all`|`rename_all`). `delete` accepts optional `autoConfirm`. When `autoConfirm` is true, the dialog opens and immediately confirms.
@@ -31,6 +31,7 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
3131
- App (3): `switch_pane`, `swap_panes`, `quit`
3232
- Search (2): `search` (structured file search across the drive index, optional `scope` for path/exclude filtering), `ai_search` (natural language search using configured LLM, optional `scope` merged with AI-inferred scope)
3333
- Settings (1): `set_setting` (change a setting value via round-trip to frontend)
34+
- Network (2): `connect_to_server` (add a manual SMB server by address, checks TCP reachability), `remove_manual_server` (remove a manually-added server by host ID)
3435
- Async (1): `await` (poll PaneStateStore until a condition is met — `has_item`, `item_count_gte`, `path`, or `path_contains`. Supports `after_generation` to avoid matching stale state)
3536

3637
### Resources (`resources.rs`)

apps/desktop/src-tauri/src/mcp/executor.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ pub async fn execute_tool<R: Runtime>(app: &AppHandle<R>, name: &str, params: &V
144144
"ai_search" => execute_ai_search(params).await,
145145
// Settings commands
146146
"set_setting" => execute_set_setting(app, params).await,
147+
// Network commands
148+
"connect_to_server" => execute_connect_to_server(app, params).await,
149+
"remove_manual_server" => execute_remove_manual_server(app, params),
147150
// Async wait
148151
"await" => execute_await(app, params).await,
149152
_ => Err(ToolError::invalid_params(format!("Unknown tool: {name}"))),
@@ -1035,6 +1038,37 @@ async fn execute_await<R: Runtime>(app: &AppHandle<R>, params: &Value) -> ToolRe
10351038
}
10361039
}
10371040

1041+
// ── Network tools ────────────────────────────────────────────────────
1042+
1043+
/// Execute `connect_to_server`: parse address, TCP check, persist, inject.
1044+
async fn execute_connect_to_server<R: Runtime>(app: &AppHandle<R>, params: &Value) -> ToolResult {
1045+
let address = params
1046+
.get("address")
1047+
.and_then(|v| v.as_str())
1048+
.ok_or_else(|| ToolError::invalid_params("Missing 'address' parameter"))?;
1049+
1050+
match crate::network::manual_servers::add_manual_server(address, app).await {
1051+
Ok(result) => Ok(json!(format!(
1052+
"OK: Connected to {} (host ID: {})",
1053+
result.host.name, result.host.id
1054+
))),
1055+
Err(e) => Err(ToolError::internal(e)),
1056+
}
1057+
}
1058+
1059+
/// Execute `remove_manual_server`: remove from storage and discovery state.
1060+
fn execute_remove_manual_server<R: Runtime>(app: &AppHandle<R>, params: &Value) -> ToolResult {
1061+
let host_id = params
1062+
.get("host_id")
1063+
.and_then(|v| v.as_str())
1064+
.ok_or_else(|| ToolError::invalid_params("Missing 'host_id' parameter"))?;
1065+
1066+
match crate::network::manual_servers::remove_manual_server(host_id, app) {
1067+
Ok(()) => Ok(json!(format!("OK: Removed server {}", host_id))),
1068+
Err(e) => Err(ToolError::internal(e)),
1069+
}
1070+
}
1071+
10381072
// ── Search tools ──────────────────────────────────────────────────────
10391073

10401074
use crate::search::PatternType;

apps/desktop/src-tauri/src/mcp/tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,11 @@ fn test_tool_input_schemas_are_valid() {
105105
#[test]
106106
fn test_total_tool_count() {
107107
let tools = get_all_tools();
108-
// 6 nav + 2 cursor + 1 select + 6 file_op + 3 view + 1 tab + 1 dialog + 3 app + 2 search + 1 settings + 1 await = 27
108+
// 6 nav + 2 cursor + 1 select + 6 file_op + 3 view + 1 tab + 1 dialog + 3 app + 2 search + 1 settings + 2 network + 1 await = 29
109109
assert_eq!(
110110
tools.len(),
111-
27,
112-
"Expected 27 tools, got {}. Did you add/remove tools?",
111+
29,
112+
"Expected 29 tools, got {}. Did you add/remove tools?",
113113
tools.len()
114114
);
115115
}

apps/desktop/src-tauri/src/mcp/tools.rs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,41 @@ fn get_search_tools() -> Vec<Tool> {
450450
]
451451
}
452452

453+
/// Get network tools.
454+
fn get_network_tools() -> Vec<Tool> {
455+
vec![
456+
Tool {
457+
name: "connect_to_server".to_string(),
458+
description: "Add a manual SMB server by address. Checks TCP reachability then adds to the host list."
459+
.to_string(),
460+
input_schema: json!({
461+
"type": "object",
462+
"properties": {
463+
"address": {
464+
"type": "string",
465+
"description": "Server address: hostname, IP, IP:port, or smb:// URL"
466+
}
467+
},
468+
"required": ["address"]
469+
}),
470+
},
471+
Tool {
472+
name: "remove_manual_server".to_string(),
473+
description: "Remove a manually-added server from the host list.".to_string(),
474+
input_schema: json!({
475+
"type": "object",
476+
"properties": {
477+
"host_id": {
478+
"type": "string",
479+
"description": "Host ID to remove (for example, manual-192-168-1-100-9445)"
480+
}
481+
},
482+
"required": ["host_id"]
483+
}),
484+
},
485+
]
486+
}
487+
453488
/// Get async waiting tools.
454489
fn get_await_tools() -> Vec<Tool> {
455490
vec![Tool {
@@ -520,6 +555,7 @@ pub fn get_all_tools() -> Vec<Tool> {
520555
tools.extend(get_app_tools());
521556
tools.extend(get_search_tools());
522557
tools.extend(get_settings_tools());
558+
tools.extend(get_network_tools());
523559
tools.extend(get_await_tools());
524560
tools
525561
}
@@ -631,11 +667,18 @@ mod tests {
631667
assert!(required.contains(&json!("value")));
632668
}
633669

670+
#[test]
671+
fn test_network_tools_count() {
672+
let tools = get_network_tools();
673+
// connect_to_server, remove_manual_server
674+
assert_eq!(tools.len(), 2);
675+
}
676+
634677
#[test]
635678
fn test_all_tools_count() {
636679
let tools = get_all_tools();
637-
// 6 nav + 2 cursor + 1 selection + 6 file_op + 3 view + 1 tab + 1 dialog + 3 app + 2 search + 1 settings + 1 await = 27
638-
assert_eq!(tools.len(), 27);
680+
// 6 nav + 2 cursor + 1 selection + 6 file_op + 3 view + 1 tab + 1 dialog + 3 app + 2 search + 1 settings + 2 network + 1 await = 29
681+
assert_eq!(tools.len(), 29);
639682
}
640683

641684
#[test]

0 commit comments

Comments
 (0)