From a375d0209911f8fb020dcf879c491ffb7c81a7e4 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Tue, 16 Jun 2026 16:56:37 +0800 Subject: [PATCH] feat(workspaces): bulk-clear unmapped folders + clearer approval opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX gaps on the Workspaces tab and the meta-tool approval prompt. Clear-all-unmapped: The Workspaces tab lists folders connected clients have reported but that aren't mapped to a FeatureSet yet (amber "Unmapped" cards). There was no way to dismiss them in bulk. Adds a "Clear unmapped" action that forgets every unmapped reported root in one go: - SessionRootsRegistry::forget_unmapped_roots() drops the unmapped roots from the in-memory registry. A session left with no roots is removed entirely (with its resolution snapshot + probe throttle) so its next tools/list re-probes the peer and the resolver re-fires WorkspaceNeedsBinding — i.e. the "map this folder?" prompt is offered again. Sessions still holding a mapped root are untouched. - clear_unmapped_reported_roots Tauri command snapshots the bound roots (same exact-match rule the UI uses to label a card "Unmapped"), clears the rest, and emits SessionRootsChanged so the tab refreshes. - Workspaces tab gains a "Clear unmapped" button (shown only while counts.unmapped > 0) behind a confirm, with a toast that sets the "you'll be asked again" expectation. Approval opt-out visibility: The "manage approval prompts" escape hatch on the meta-tool approval dialog was an 11px muted link that was easy to miss. Promotes it to a bordered, icon-led button above a divider so the path to turning these prompts off is actually noticeable. Behavior is unchanged (deny + route to Built-in tab). Tests: registry unit tests for forget_unmapped_roots (clears unmapped sessions, keeps mixed sessions); a Workspaces test covering the button's visibility and the confirm -> clear flow. Signed-off-by: Mohammod Al Amin Ashik --- .../src/commands/workspace_binding.rs | 51 ++++++++++ apps/desktop/src-tauri/src/lib.rs | 1 + .../metaTools/MetaToolApprovalDialog.tsx | 9 +- .../features/workspaces/WorkspacesPage.tsx | 44 +++++++++ apps/desktop/src/lib/api/workspaceBindings.ts | 11 +++ .../src/services/session_roots.rs | 99 +++++++++++++++++++ .../WorkspacesClearUnmapped.test.tsx | 79 +++++++++++++++ 7 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 tests/ts/components/WorkspacesClearUnmapped.test.tsx diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index c82c0bd3..111e1d76 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -123,6 +123,57 @@ pub async fn list_reported_workspace_roots( .unwrap_or_default()) } +/// Forget every reported workspace root that has no binding ("unmapped"). +/// +/// The Workspaces tab surfaces folders connected clients reported but that +/// aren't mapped to a FeatureSet yet. This drops them from the in-memory +/// session-roots registry so the "Unmapped" list clears in one action; the +/// next time those sessions report a root (or reconnect) the resolver lands +/// on `Deny` again and re-fires the "map this folder?" prompt. Mapped roots +/// are left untouched. +/// +/// Returns the number of distinct roots cleared. A not-running gateway has +/// nothing reported, so it returns `0` rather than erroring. +#[tauri::command] +pub async fn clear_unmapped_reported_roots( + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + // Snapshot the bound roots (case-folded) so we can tell a mapped folder + // from an unmapped one — the same exact-match rule the Workspaces tab + // uses to label a card "Unmapped". + let bound: HashSet = state + .workspace_binding_repository + .list() + .await + .map_err(|e| { + error!("[workspace_binding::clear_unmapped] {e}"); + e.to_string() + })? + .into_iter() + .map(|b| b.workspace_root.to_lowercase()) + .collect(); + + let guard = gateway_state.read().await; + let Some(reg) = guard.session_roots.as_ref() else { + // Gateway not running — nothing has been reported. + return Ok(0); + }; + let dropped = reg.forget_unmapped_roots(|root| bound.contains(&root.to_lowercase())); + let count = dropped.len(); + + if count > 0 { + info!(count, roots = ?dropped, "[workspace_binding] cleared unmapped reported roots"); + // Nudge the Workspaces tab to re-read `list_reported_workspace_roots`. + if let Some(ref gw) = guard.gateway_state { + gw.read() + .await + .emit_domain_event(DomainEvent::SessionRootsChanged); + } + } + Ok(count) +} + /// List every binding (sorted by workspace_root). #[tauri::command] pub async fn list_workspace_bindings( diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index bab8479c..0fdc9e39 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -901,6 +901,7 @@ pub fn run() { commands::list_workspace_bindings, commands::list_workspace_bindings_for_space, commands::list_reported_workspace_roots, + commands::clear_unmapped_reported_roots, commands::create_workspace_binding, commands::update_workspace_binding, commands::delete_workspace_binding, diff --git a/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx index 99ce9cb3..3ebfab03 100644 --- a/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx +++ b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; -import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, SlidersHorizontal, XCircle } from 'lucide-react'; import { Button, Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui'; import { useNavigateTo } from '@/stores'; @@ -218,15 +218,16 @@ export function MetaToolApprovalDialog() { -
+
{queue.length > 1 && ( diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index f9e8e6cf..14631f70 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -32,6 +32,7 @@ import { useConfirm, } from '@mcpmux/ui'; import { + clearUnmappedReportedRoots, createWorkspaceBinding, deleteWorkspaceBinding, getWorkspaceEffectiveFeatures, @@ -272,6 +273,36 @@ export function WorkspacesPage() { } }; + // Bulk "clear" for the unmapped (amber) folders. These are live-reported + // roots with no binding — clearing drops them from the gateway's in-memory + // session-roots registry so this list empties in one action, and the + // "map this folder?" prompt is offered again next time those apps report a + // folder. Mapped folders are untouched. + const handleClearUnmapped = async () => { + const n = counts.unmapped; + const ok = await confirm({ + title: 'Clear unmapped folders', + message: `Remove ${n} unmapped folder${n === 1 ? '' : 's'} from this list. McpMux will offer to map ${n === 1 ? 'it' : 'them'} again the next time those apps report the folder.`, + confirmLabel: 'Clear all', + }); + if (!ok) return; + try { + const cleared = await clearUnmappedReportedRoots(); + await loadData(); + success( + cleared > 0 + ? `Cleared ${cleared} unmapped folder${cleared === 1 ? '' : 's'}` + : 'Nothing to clear', + cleared > 0 ? "You'll be asked to map them again next time." : undefined + ); + } catch (e) { + showError( + 'Could not clear unmapped folders', + e instanceof Error ? e.message : String(e) + ); + } + }; + return (
@@ -334,6 +365,19 @@ export function WorkspacesPage() { { value: 'unmapped', label: 'Unmapped', count: counts.unmapped }, ]} /> + {counts.unmapped > 0 && ( + + )}
diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts index cc23dbfd..f1441662 100644 --- a/apps/desktop/src/lib/api/workspaceBindings.ts +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -43,6 +43,17 @@ export async function listReportedWorkspaceRoots(): Promise { return invoke('list_reported_workspace_roots'); } +/** + * Forget every reported workspace root that has no binding ("unmapped"). + * Clears them from the Workspaces tab in one action; the gateway then offers + * the "map this folder?" prompt again the next time those apps report a + * folder (or reconnect). Mapped folders are left untouched. Resolves with + * the number of roots cleared. + */ +export async function clearUnmappedReportedRoots(): Promise { + return invoke('clear_unmapped_reported_roots'); +} + /** * Live path validation for the manual-add form. Runs the SAME rules the * create/update commands apply so "validates in UI → saves OK" is a diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index 753a16cd..858c8652 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -183,6 +183,58 @@ impl SessionRootsRegistry { out } + /// Forget every reported root that is **not** currently mapped, so the + /// Workspaces tab's "Unmapped" list clears and the gateway re-offers the + /// "map this folder?" prompt the next time those sessions report a root. + /// + /// `is_mapped(root)` returns `true` for roots that have a binding — those + /// are kept. For each tracked session the unmapped roots are dropped; a + /// session left with no roots is removed from the registry entirely (along + /// with its last-resolution snapshot and probe throttle) so its next + /// `tools/list` re-probes the peer and the resolver fires + /// `WorkspaceNeedsBinding` again. Sessions that still hold a mapped root + /// keep their entry untouched (they route via their binding and never + /// prompt). Returns the dropped roots (sorted, deduped) for logging. + pub fn forget_unmapped_roots(&self, is_mapped: F) -> Vec + where + F: Fn(&str) -> bool, + { + let mut dropped: Vec = Vec::new(); + let mut emptied: Vec = Vec::new(); + + for mut entry in self.map.iter_mut() { + let mut removed_any = false; + entry.value_mut().retain(|root| { + if is_mapped(root) { + true + } else { + dropped.push(root.clone()); + removed_any = true; + false + } + }); + if removed_any && entry.value().is_empty() { + emptied.push(entry.key().clone()); + } + } + + // Remove emptied sessions AFTER the iterator above is released — a + // `map.remove()` while iterating would request a write lock on a shard + // the iterator still read-locks (self-deadlock). Dropping the roots + // entry (rather than leaving an empty Vec) is what makes the next + // request re-probe: `ensure_roots_probed` early-returns while + // `get(sid)` is `Some(_)`, even for an empty Vec. + for sid in emptied { + self.map.remove(&sid); + self.last_resolution.remove(&sid); + self.last_probe.remove(&sid); + } + + dropped.sort(); + dropped.dedup(); + dropped + } + /// Current number of tracked sessions. Test helper; cheap to call but /// not useful in hot paths. #[cfg(test)] @@ -247,6 +299,53 @@ mod tests { assert!(!reg.record_resolution("sess-1", None)); } + #[test] + fn test_forget_unmapped_roots_clears_unmapped_sessions() { + let reg = SessionRootsRegistry::default(); + #[cfg(windows)] + let (mapped_in, unmapped_in) = ("file:///D:/mapped/", "file:///D:/unmapped/"); + #[cfg(not(windows))] + let (mapped_in, unmapped_in) = ("file:///home/u/mapped/", "file:///home/u/unmapped/"); + + reg.set("sess-mapped", [mapped_in]); + reg.set("sess-unmapped", [unmapped_in]); + reg.record_resolution("sess-unmapped", Some("fs-x")); + + // Treat only the first session's (normalized) root as mapped. + let mapped_norm = reg.get("sess-mapped").unwrap()[0].clone(); + let dropped = reg.forget_unmapped_roots(|root| root == mapped_norm); + + // Exactly the unmapped root was dropped. + assert_eq!(dropped.len(), 1); + assert_ne!(dropped[0], mapped_norm); + // The mapped session is untouched. + assert_eq!(reg.get("sess-mapped"), Some(vec![mapped_norm])); + // The unmapped session is removed entirely so the next request + // re-probes the peer and the binding prompt fires again. + assert!(reg.get("sess-unmapped").is_none()); + // ...and its resolution snapshot was cleared (fresh = counts as change). + assert!(reg.record_resolution("sess-unmapped", Some("fs-x"))); + } + + #[test] + fn test_forget_unmapped_roots_keeps_mixed_session() { + let reg = SessionRootsRegistry::default(); + #[cfg(windows)] + let (mapped_in, unmapped_in) = ("file:///D:/keep/", "file:///D:/drop/"); + #[cfg(not(windows))] + let (mapped_in, unmapped_in) = ("file:///home/u/keep/", "file:///home/u/drop/"); + + reg.set("sess-mixed", [mapped_in, unmapped_in]); + let roots = reg.get("sess-mixed").unwrap(); + let keep = roots[0].clone(); + + let dropped = reg.forget_unmapped_roots(|root| root == keep); + + // The unmapped root went; the session survives with its mapped root. + assert_eq!(dropped.len(), 1); + assert_eq!(reg.get("sess-mixed"), Some(vec![keep])); + } + #[test] fn test_remove_clears_resolution_too() { let reg = SessionRootsRegistry::default(); diff --git a/tests/ts/components/WorkspacesClearUnmapped.test.tsx b/tests/ts/components/WorkspacesClearUnmapped.test.tsx new file mode 100644 index 00000000..12ef074a --- /dev/null +++ b/tests/ts/components/WorkspacesClearUnmapped.test.tsx @@ -0,0 +1,79 @@ +/** + * Workspaces tab — "Clear unmapped" bulk action. + * + * Unmapped (amber) cards are live-reported roots with no binding. The bulk + * "Clear unmapped" button forgets them all in one go (so the gateway offers + * the "map this folder?" prompt again next time). These tests cover: + * - the button only appears while there are unmapped folders, and + * - confirming it calls `clearUnmappedReportedRoots`. + * + * `@mcpmux/ui` is aliased to the real source in vitest.config, so the real + * `useConfirm` dialog renders — we drive it via `confirm-dialog-confirm`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const { + listWorkspaceBindingsMock, + listReportedWorkspaceRootsMock, + clearUnmappedReportedRootsMock, +} = vi.hoisted(() => ({ + listWorkspaceBindingsMock: vi.fn(), + listReportedWorkspaceRootsMock: vi.fn(), + clearUnmappedReportedRootsMock: vi.fn(), +})); + +vi.mock('@/lib/api/workspaceBindings', () => ({ + listWorkspaceBindings: listWorkspaceBindingsMock, + listReportedWorkspaceRoots: listReportedWorkspaceRootsMock, + clearUnmappedReportedRoots: clearUnmappedReportedRootsMock, + createWorkspaceBinding: vi.fn(), + updateWorkspaceBinding: vi.fn(), + deleteWorkspaceBinding: vi.fn(), + getWorkspaceEffectiveFeatures: vi.fn(), + validateWorkspaceRoot: vi.fn(), +})); + +vi.mock('@/lib/api/featureSets', () => ({ + listFeatureSets: vi.fn().mockResolvedValue([]), + isStarterFeatureSet: vi.fn(() => false), +})); + +vi.mock('@/stores', () => ({ + useSpaces: () => [], +})); + +import { WorkspacesPage } from '@/features/workspaces/WorkspacesPage'; + +describe('WorkspacesPage – clear unmapped', () => { + beforeEach(() => { + listWorkspaceBindingsMock.mockResolvedValue([]); + listReportedWorkspaceRootsMock.mockResolvedValue([]); + clearUnmappedReportedRootsMock.mockResolvedValue(0); + }); + + it('hides the "Clear unmapped" button when nothing is unmapped', async () => { + render(); + await waitFor(() => expect(listReportedWorkspaceRootsMock).toHaveBeenCalled()); + expect(screen.queryByTestId('workspaces-clear-unmapped')).toBeNull(); + }); + + it('shows the button and clears unmapped roots after confirming', async () => { + listReportedWorkspaceRootsMock.mockResolvedValue(['/home/u/unbound-folder']); + clearUnmappedReportedRootsMock.mockResolvedValue(1); + const user = userEvent.setup(); + + render(); + + // Button shows because one live-reported root has no binding. + const clearBtn = await screen.findByTestId('workspaces-clear-unmapped'); + await user.click(clearBtn); + + // Real confirm dialog — accept it. + await user.click(await screen.findByTestId('confirm-dialog-confirm')); + + await waitFor(() => expect(clearUnmappedReportedRootsMock).toHaveBeenCalledTimes(1)); + }); +});