Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/desktop/src-tauri/src/commands/workspace_binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RwLock<GatewayAppState>>>,
) -> Result<usize, String> {
// 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<String> = 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(
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -218,15 +218,16 @@ export function MetaToolApprovalDialog() {
</Button>
</div>

<div className="flex items-center justify-between gap-3 pt-1">
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[rgb(var(--border-subtle))] pt-3">
<button
type="button"
onClick={manageApprovals}
className="text-[11px] text-[rgb(var(--muted))] underline-offset-2 hover:text-[rgb(var(--foreground))] hover:underline"
className="inline-flex items-center gap-1.5 rounded-md border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-2.5 py-1.5 text-xs font-medium text-[rgb(var(--foreground))] transition-colors hover:border-primary-400 hover:bg-primary-50 hover:text-primary-700 dark:hover:bg-primary-900/20 dark:hover:text-primary-300"
title="Deny this request and open the Built-in tab, where you can turn off approval prompts for tool changes"
data-testid="meta-tool-approval-manage-link"
>
Prefer not to be asked? Manage approval prompts →
<SlidersHorizontal className="h-3.5 w-3.5" />
Don&apos;t ask again — manage approval prompts
</button>
{queue.length > 1 && (
<span className="text-[11px] text-[rgb(var(--muted))]">
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/features/workspaces/WorkspacesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
useConfirm,
} from '@mcpmux/ui';
import {
clearUnmappedReportedRoots,
createWorkspaceBinding,
deleteWorkspaceBinding,
getWorkspaceEffectiveFeatures,
Expand Down Expand Up @@ -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 (
<div className="h-full flex flex-col relative" data-testid="workspaces-page">
<header className="flex-shrink-0 p-8 border-b border-[rgb(var(--border-subtle))]">
Expand Down Expand Up @@ -334,6 +365,19 @@ export function WorkspacesPage() {
{ value: 'unmapped', label: 'Unmapped', count: counts.unmapped },
]}
/>
{counts.unmapped > 0 && (
<Button
variant="ghost"
size="md"
onClick={handleClearUnmapped}
title="Forget all unmapped folders. McpMux will offer to map them again next time those apps report a folder."
className="whitespace-nowrap text-amber-600 hover:bg-amber-50 hover:text-amber-700 dark:text-amber-400 dark:hover:bg-amber-900/20"
data-testid="workspaces-clear-unmapped"
>
<Trash2 className="h-4 w-4 mr-2" />
Clear unmapped
</Button>
)}
</div>
</div>
</header>
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/lib/api/workspaceBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ export async function listReportedWorkspaceRoots(): Promise<string[]> {
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<number> {
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
Expand Down
99 changes: 99 additions & 0 deletions crates/mcpmux-gateway/src/services/session_roots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F>(&self, is_mapped: F) -> Vec<String>
where
F: Fn(&str) -> bool,
{
let mut dropped: Vec<String> = Vec::new();
let mut emptied: Vec<String> = 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)]
Expand Down Expand Up @@ -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();
Expand Down
79 changes: 79 additions & 0 deletions tests/ts/components/WorkspacesClearUnmapped.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WorkspacesPage />);
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(<WorkspacesPage />);

// 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));
});
});
Loading