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
54 changes: 54 additions & 0 deletions apps/desktop/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,48 @@ pub async fn set_update_channel(
Ok(normalized.to_string())
}

/// App-settings key for the "ask to map new folders" prompt switch.
const WORKSPACE_MAPPING_PROMPT_KEY: &str = "workspaces.mapping_prompt_enabled";

/// Interpret a stored value for the workspace mapping-prompt toggle. Missing or
/// any non-`"false"` value means **enabled** — the prompt is on by default, so
/// only an explicit opt-out turns it off.
fn mapping_prompt_enabled_from(stored: Option<&str>) -> bool {
stored.map(|v| v != "false").unwrap_or(true)
}

/// Whether McpMux pops the "map this folder?" sheet when a connected client
/// opens a folder that has no explicit binding (it's on the default Starter
/// set). Default **true**. Users who find the prompt noisy can turn it off
/// here or via the link in the sheet itself.
#[tauri::command]
pub async fn get_workspace_mapping_prompt_enabled(
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let stored = app_state
.settings_repository
.get(WORKSPACE_MAPPING_PROMPT_KEY)
.await
.map_err(|e| e.to_string())?;
Ok(mapping_prompt_enabled_from(stored.as_deref()))
}

/// Enable/disable the "map this folder?" prompt. Persisted; returns the value
/// actually saved.
#[tauri::command]
pub async fn set_workspace_mapping_prompt_enabled(
enabled: bool,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state
.settings_repository
.set(WORKSPACE_MAPPING_PROMPT_KEY, &enabled.to_string())
.await
.map_err(|e| e.to_string())?;
info!("[Settings] Workspace mapping prompt set to {}", enabled);
Ok(enabled)
}

/// Check if app should start hidden (for auto-launch with --hidden flag)
pub fn should_start_hidden() -> bool {
let args: Vec<String> = std::env::args().collect();
Expand Down Expand Up @@ -332,4 +374,16 @@ mod tests {
assert!(out == UPDATE_CHANNEL_STABLE || out == UPDATE_CHANNEL_PRERELEASE);
}
}

#[test]
fn test_mapping_prompt_enabled_defaults_on() {
// Missing setting → on by default.
assert!(mapping_prompt_enabled_from(None));
// Only an explicit "false" disables it.
assert!(!mapping_prompt_enabled_from(Some("false")));
assert!(mapping_prompt_enabled_from(Some("true")));
// Any unexpected value is treated as enabled (fail-open to the default).
assert!(mapping_prompt_enabled_from(Some("")));
assert!(mapping_prompt_enabled_from(Some("garbage")));
}
}
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,8 @@ pub fn run() {
commands::set_auto_install_updates,
commands::get_update_channel,
commands::set_update_channel,
commands::get_workspace_mapping_prompt_enabled,
commands::set_workspace_mapping_prompt_enabled,
])
.build(tauri::generate_context!())
.expect("error while building McpMux application")
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ export function SettingsPage() {
const [logRetentionDays, setLogRetentionDays] = useState<number>(30);
const [savingRetention, setSavingRetention] = useState(false);

// Workspace mapping prompt — pops the "map this folder?" sheet when a client
// opens an unmapped folder. On by default.
const [mappingPromptEnabled, setMappingPromptEnabled] = useState(true);
const [savingMappingPrompt, setSavingMappingPrompt] = useState(false);

// Meta-tools master switch — gates the entire `mcpmux_*` namespace.

// Gateway port — persisted user override, the default the app ships
Expand Down Expand Up @@ -212,6 +217,34 @@ export function SettingsPage() {
loadStartupSettings();
}, []);

// Load workspace mapping-prompt setting on mount.
useEffect(() => {
invoke<boolean>('get_workspace_mapping_prompt_enabled')
.then(setMappingPromptEnabled)
.catch((err) => console.error('Failed to load mapping prompt setting:', err));
}, []);

const updateMappingPrompt = async (enabled: boolean) => {
const prev = mappingPromptEnabled;
setMappingPromptEnabled(enabled);
setSavingMappingPrompt(true);
try {
await invoke('set_workspace_mapping_prompt_enabled', { enabled });
success(
'Settings saved',
enabled
? "You'll be asked to map new folders."
: 'New-folder prompts are off — unmapped folders still use your default Starter set.'
);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
error('Failed to save setting', msg);
setMappingPromptEnabled(prev);
} finally {
setSavingMappingPrompt(false);
}
};

// Save startup settings when they change
const updateStartupSetting = async (key: keyof StartupSettings, value: boolean) => {
console.log(`[Settings] Updating ${key} to ${value}`);
Expand Down Expand Up @@ -524,6 +557,40 @@ export function SettingsPage() {
</CardContent>
</Card>

{/* Workspaces Section */}
<Card data-testid="settings-workspaces-section">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FolderOpen className="h-5 w-5" />
Workspaces
</CardTitle>
<CardDescription>
How McpMux handles folders your connected apps open.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-start gap-3">
<FolderOpen className="mt-0.5 h-5 w-5 flex-shrink-0 text-[rgb(var(--muted))]" />
<div>
<label className="text-sm font-medium">Ask to map new folders</label>
<p className="mt-1 text-xs text-[rgb(var(--muted))]">
When a connected app opens a folder you haven't mapped, show a prompt to give
it a specific feature set. The folder already works with your default Starter
set either way.
</p>
</div>
</div>
<Switch
checked={mappingPromptEnabled}
onCheckedChange={updateMappingPrompt}
disabled={savingMappingPrompt}
data-testid="workspace-mapping-prompt-switch"
/>
</div>
</CardContent>
</Card>

{/* Appearance Section */}
<Card>
<CardHeader>
Expand Down
40 changes: 39 additions & 1 deletion apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/

import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { Check, ChevronDown, FolderOpen, Loader2, Sparkles, X } from 'lucide-react';
import { Button } from '@mcpmux/ui';
Expand Down Expand Up @@ -73,12 +74,24 @@ export function WorkspaceBindingSheet() {
useEffect(() => {
const un = listen<WorkspaceNeedsBindingPayload>(
'workspace-needs-binding',
(event) => {
async (event) => {
// Swallow only while a sheet is already showing — the user is
// mid-decision, a second emit would stack a new sheet on top. Once
// the current sheet closes (Modify or Close), the next emit from
// any fresh session on an unbound root opens the sheet again.
if (currentSessionRef.current !== null) return;
// Respect the "ask to map new folders" setting (on by default). Read
// it fresh each time so toggling it — from Settings or the in-sheet
// "stop asking" link — takes effect immediately, with no re-subscribe.
try {
const enabled = await invoke<boolean>('get_workspace_mapping_prompt_enabled');
if (!enabled) return;
} catch {
// If the setting can't be read, fall back to showing (default on).
}
// Re-check after the await: another emit may have opened a sheet while
// we were reading the setting.
if (currentSessionRef.current !== null) return;
const p = event.payload;
setPayload(p);
setSelectedSpaceId(p.space_id);
Expand Down Expand Up @@ -171,6 +184,19 @@ export function WorkspaceBindingSheet() {
markSeenAndClose(payload);
};

// "Stop asking" escape hatch — turns the prompt off globally (it's on by
// default) and closes. Best-effort: if the write fails we still close so the
// click isn't a dead end. Re-enable lives in Settings → Workspaces.
const handleDisablePrompt = async () => {
if (!payload || saving) return;
try {
await invoke('set_workspace_mapping_prompt_enabled', { enabled: false });
} catch {
/* best-effort — close regardless */
}
markSeenAndClose(payload);
};

if (!payload) return null;

return (
Expand Down Expand Up @@ -315,6 +341,18 @@ export function WorkspaceBindingSheet() {
<p className="mt-3 text-center text-[11px] text-[rgb(var(--muted))]">
You can change this anytime in Workspaces.
</p>
<div className="mt-1.5 text-center">
<button
type="button"
onClick={handleDisablePrompt}
disabled={saving}
title="Turn off the new-folder prompt. Re-enable it anytime in Settings → Workspaces."
className="text-[11px] text-[rgb(var(--muted))] underline-offset-2 transition-colors hover:text-[rgb(var(--foreground))] hover:underline disabled:opacity-50"
data-testid="workspace-binding-disable-prompt"
>
Asked too often? Stop asking about new folders
</button>
</div>
</div>
</div>
</div>
Expand Down
89 changes: 89 additions & 0 deletions tests/ts/components/WorkspaceBindingPrompt.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* WorkspaceBindingSheet — the "map this folder?" prompt and its disable switch.
*
* The sheet pops on a `workspace-needs-binding` event, but only when the
* "Ask to map new folders" setting is on (default). These tests drive the
* event through the (globally mocked) Tauri `listen` and assert the sheet
* honors the setting, plus that the in-sheet "stop asking" link turns it off.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

vi.mock('@/lib/api/workspaceBindings', () => ({
createWorkspaceBinding: vi.fn(),
}));

vi.mock('@/lib/api/spaces', () => ({
listSpaces: vi.fn().mockResolvedValue([{ id: 's1', name: 'Default', is_default: true }]),
}));

vi.mock('@/lib/api/featureSets', () => ({
isStarterFeatureSet: vi.fn(() => true),
listFeatureSetsBySpace: vi
.fn()
.mockResolvedValue([
{ id: 'fs1', name: 'Starter', feature_set_type: 'starter', is_deleted: false },
]),
}));

import { WorkspaceBindingSheet } from '@/features/workspaces/WorkspaceBindingSheet';

const TITLE = /This folder is using your Starter set/i;

/** Invoke the captured `workspace-needs-binding` listener with a payload. */
function fireNeedsBinding() {
const call = vi.mocked(listen).mock.calls.find((c) => c[0] === 'workspace-needs-binding');
if (!call) throw new Error('workspace-needs-binding listener was not registered');
const cb = call[1] as (e: { payload: unknown }) => unknown | Promise<unknown>;
return cb({
payload: { client_id: 'c', session_id: 's', space_id: 's1', workspace_root: '/home/u/proj' },
});
}

function mockPromptEnabled(enabled: boolean) {
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
if (cmd === 'get_workspace_mapping_prompt_enabled') return enabled;
return undefined;
});
}

describe('WorkspaceBindingSheet – mapping prompt toggle', () => {
beforeEach(() => {
vi.mocked(invoke).mockReset();
});

it('shows the sheet when the prompt setting is enabled', async () => {
mockPromptEnabled(true);
render(<WorkspaceBindingSheet />);
await fireNeedsBinding();
expect(await screen.findByText(TITLE)).toBeTruthy();
});

it('does NOT show the sheet when the prompt setting is disabled', async () => {
mockPromptEnabled(false);
render(<WorkspaceBindingSheet />);
await fireNeedsBinding();
await waitFor(() => expect(screen.queryByText(TITLE)).toBeNull());
});

it('the in-sheet "stop asking" link disables the setting and closes', async () => {
const user = userEvent.setup();
mockPromptEnabled(true);
render(<WorkspaceBindingSheet />);
await fireNeedsBinding();
await screen.findByText(TITLE);

await user.click(screen.getByTestId('workspace-binding-disable-prompt'));

await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('set_workspace_mapping_prompt_enabled', {
enabled: false,
})
);
await waitFor(() => expect(screen.queryByText(TITLE)).toBeNull());
});
});
Loading