Skip to content

Commit 5189de2

Browse files
committed
feat(workspaces): setting to disable the new-folder mapping prompt
Adds an "Ask to map new folders" toggle (on by default) so users who find the "map this folder?" sheet noisy can turn it off — from Settings → Workspaces or a "stop asking" link in the sheet itself. The folder still works on the default Starter set when the prompt is off. - Backend: get/set_workspace_mapping_prompt_enabled commands (key `workspaces.mapping_prompt_enabled`, default true via a testable helper). - Sheet: reads the setting fresh on each `workspace-needs-binding` event and swallows it when disabled (no re-subscribe needed); adds an in-sheet "stop asking about new folders" link that turns it off and closes. - Settings: new Workspaces card with the toggle. - Tests: Rust `test_mapping_prompt_enabled_defaults_on`; TS sheet gating (enabled shows / disabled hides) + the disable link. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 163ee0b commit 5189de2

5 files changed

Lines changed: 251 additions & 1 deletion

File tree

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,48 @@ pub async fn set_update_channel(
203203
Ok(normalized.to_string())
204204
}
205205

206+
/// App-settings key for the "ask to map new folders" prompt switch.
207+
const WORKSPACE_MAPPING_PROMPT_KEY: &str = "workspaces.mapping_prompt_enabled";
208+
209+
/// Interpret a stored value for the workspace mapping-prompt toggle. Missing or
210+
/// any non-`"false"` value means **enabled** — the prompt is on by default, so
211+
/// only an explicit opt-out turns it off.
212+
fn mapping_prompt_enabled_from(stored: Option<&str>) -> bool {
213+
stored.map(|v| v != "false").unwrap_or(true)
214+
}
215+
216+
/// Whether McpMux pops the "map this folder?" sheet when a connected client
217+
/// opens a folder that has no explicit binding (it's on the default Starter
218+
/// set). Default **true**. Users who find the prompt noisy can turn it off
219+
/// here or via the link in the sheet itself.
220+
#[tauri::command]
221+
pub async fn get_workspace_mapping_prompt_enabled(
222+
app_state: State<'_, AppState>,
223+
) -> Result<bool, String> {
224+
let stored = app_state
225+
.settings_repository
226+
.get(WORKSPACE_MAPPING_PROMPT_KEY)
227+
.await
228+
.map_err(|e| e.to_string())?;
229+
Ok(mapping_prompt_enabled_from(stored.as_deref()))
230+
}
231+
232+
/// Enable/disable the "map this folder?" prompt. Persisted; returns the value
233+
/// actually saved.
234+
#[tauri::command]
235+
pub async fn set_workspace_mapping_prompt_enabled(
236+
enabled: bool,
237+
app_state: State<'_, AppState>,
238+
) -> Result<bool, String> {
239+
app_state
240+
.settings_repository
241+
.set(WORKSPACE_MAPPING_PROMPT_KEY, &enabled.to_string())
242+
.await
243+
.map_err(|e| e.to_string())?;
244+
info!("[Settings] Workspace mapping prompt set to {}", enabled);
245+
Ok(enabled)
246+
}
247+
206248
/// Check if app should start hidden (for auto-launch with --hidden flag)
207249
pub fn should_start_hidden() -> bool {
208250
let args: Vec<String> = std::env::args().collect();
@@ -332,4 +374,16 @@ mod tests {
332374
assert!(out == UPDATE_CHANNEL_STABLE || out == UPDATE_CHANNEL_PRERELEASE);
333375
}
334376
}
377+
378+
#[test]
379+
fn test_mapping_prompt_enabled_defaults_on() {
380+
// Missing setting → on by default.
381+
assert!(mapping_prompt_enabled_from(None));
382+
// Only an explicit "false" disables it.
383+
assert!(!mapping_prompt_enabled_from(Some("false")));
384+
assert!(mapping_prompt_enabled_from(Some("true")));
385+
// Any unexpected value is treated as enabled (fail-open to the default).
386+
assert!(mapping_prompt_enabled_from(Some("")));
387+
assert!(mapping_prompt_enabled_from(Some("garbage")));
388+
}
335389
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,8 @@ pub fn run() {
992992
commands::set_auto_install_updates,
993993
commands::get_update_channel,
994994
commands::set_update_channel,
995+
commands::get_workspace_mapping_prompt_enabled,
996+
commands::set_workspace_mapping_prompt_enabled,
995997
])
996998
.build(tauri::generate_context!())
997999
.expect("error while building McpMux application")

apps/desktop/src/features/settings/SettingsPage.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ export function SettingsPage() {
7272
const [logRetentionDays, setLogRetentionDays] = useState<number>(30);
7373
const [savingRetention, setSavingRetention] = useState(false);
7474

75+
// Workspace mapping prompt — pops the "map this folder?" sheet when a client
76+
// opens an unmapped folder. On by default.
77+
const [mappingPromptEnabled, setMappingPromptEnabled] = useState(true);
78+
const [savingMappingPrompt, setSavingMappingPrompt] = useState(false);
79+
7580
// Meta-tools master switch — gates the entire `mcpmux_*` namespace.
7681

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

220+
// Load workspace mapping-prompt setting on mount.
221+
useEffect(() => {
222+
invoke<boolean>('get_workspace_mapping_prompt_enabled')
223+
.then(setMappingPromptEnabled)
224+
.catch((err) => console.error('Failed to load mapping prompt setting:', err));
225+
}, []);
226+
227+
const updateMappingPrompt = async (enabled: boolean) => {
228+
const prev = mappingPromptEnabled;
229+
setMappingPromptEnabled(enabled);
230+
setSavingMappingPrompt(true);
231+
try {
232+
await invoke('set_workspace_mapping_prompt_enabled', { enabled });
233+
success(
234+
'Settings saved',
235+
enabled
236+
? "You'll be asked to map new folders."
237+
: 'New-folder prompts are off — unmapped folders still use your default Starter set.'
238+
);
239+
} catch (err) {
240+
const msg = err instanceof Error ? err.message : 'Unknown error';
241+
error('Failed to save setting', msg);
242+
setMappingPromptEnabled(prev);
243+
} finally {
244+
setSavingMappingPrompt(false);
245+
}
246+
};
247+
215248
// Save startup settings when they change
216249
const updateStartupSetting = async (key: keyof StartupSettings, value: boolean) => {
217250
console.log(`[Settings] Updating ${key} to ${value}`);
@@ -524,6 +557,40 @@ export function SettingsPage() {
524557
</CardContent>
525558
</Card>
526559

560+
{/* Workspaces Section */}
561+
<Card data-testid="settings-workspaces-section">
562+
<CardHeader>
563+
<CardTitle className="flex items-center gap-2">
564+
<FolderOpen className="h-5 w-5" />
565+
Workspaces
566+
</CardTitle>
567+
<CardDescription>
568+
How McpMux handles folders your connected apps open.
569+
</CardDescription>
570+
</CardHeader>
571+
<CardContent>
572+
<div className="flex items-center justify-between gap-4">
573+
<div className="flex min-w-0 flex-1 items-start gap-3">
574+
<FolderOpen className="mt-0.5 h-5 w-5 flex-shrink-0 text-[rgb(var(--muted))]" />
575+
<div>
576+
<label className="text-sm font-medium">Ask to map new folders</label>
577+
<p className="mt-1 text-xs text-[rgb(var(--muted))]">
578+
When a connected app opens a folder you haven't mapped, show a prompt to give
579+
it a specific feature set. The folder already works with your default Starter
580+
set either way.
581+
</p>
582+
</div>
583+
</div>
584+
<Switch
585+
checked={mappingPromptEnabled}
586+
onCheckedChange={updateMappingPrompt}
587+
disabled={savingMappingPrompt}
588+
data-testid="workspace-mapping-prompt-switch"
589+
/>
590+
</div>
591+
</CardContent>
592+
</Card>
593+
527594
{/* Appearance Section */}
528595
<Card>
529596
<CardHeader>

apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
*/
2020

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

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

176202
return (
@@ -315,6 +341,18 @@ export function WorkspaceBindingSheet() {
315341
<p className="mt-3 text-center text-[11px] text-[rgb(var(--muted))]">
316342
You can change this anytime in Workspaces.
317343
</p>
344+
<div className="mt-1.5 text-center">
345+
<button
346+
type="button"
347+
onClick={handleDisablePrompt}
348+
disabled={saving}
349+
title="Turn off the new-folder prompt. Re-enable it anytime in Settings → Workspaces."
350+
className="text-[11px] text-[rgb(var(--muted))] underline-offset-2 transition-colors hover:text-[rgb(var(--foreground))] hover:underline disabled:opacity-50"
351+
data-testid="workspace-binding-disable-prompt"
352+
>
353+
Asked too often? Stop asking about new folders
354+
</button>
355+
</div>
318356
</div>
319357
</div>
320358
</div>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* WorkspaceBindingSheet — the "map this folder?" prompt and its disable switch.
3+
*
4+
* The sheet pops on a `workspace-needs-binding` event, but only when the
5+
* "Ask to map new folders" setting is on (default). These tests drive the
6+
* event through the (globally mocked) Tauri `listen` and assert the sheet
7+
* honors the setting, plus that the in-sheet "stop asking" link turns it off.
8+
*/
9+
10+
import { describe, it, expect, vi, beforeEach } from 'vitest';
11+
import { render, screen, waitFor } from '@testing-library/react';
12+
import userEvent from '@testing-library/user-event';
13+
import { invoke } from '@tauri-apps/api/core';
14+
import { listen } from '@tauri-apps/api/event';
15+
16+
vi.mock('@/lib/api/workspaceBindings', () => ({
17+
createWorkspaceBinding: vi.fn(),
18+
}));
19+
20+
vi.mock('@/lib/api/spaces', () => ({
21+
listSpaces: vi.fn().mockResolvedValue([{ id: 's1', name: 'Default', is_default: true }]),
22+
}));
23+
24+
vi.mock('@/lib/api/featureSets', () => ({
25+
isStarterFeatureSet: vi.fn(() => true),
26+
listFeatureSetsBySpace: vi
27+
.fn()
28+
.mockResolvedValue([
29+
{ id: 'fs1', name: 'Starter', feature_set_type: 'starter', is_deleted: false },
30+
]),
31+
}));
32+
33+
import { WorkspaceBindingSheet } from '@/features/workspaces/WorkspaceBindingSheet';
34+
35+
const TITLE = /This folder is using your Starter set/i;
36+
37+
/** Invoke the captured `workspace-needs-binding` listener with a payload. */
38+
function fireNeedsBinding() {
39+
const call = vi.mocked(listen).mock.calls.find((c) => c[0] === 'workspace-needs-binding');
40+
if (!call) throw new Error('workspace-needs-binding listener was not registered');
41+
const cb = call[1] as (e: { payload: unknown }) => unknown | Promise<unknown>;
42+
return cb({
43+
payload: { client_id: 'c', session_id: 's', space_id: 's1', workspace_root: '/home/u/proj' },
44+
});
45+
}
46+
47+
function mockPromptEnabled(enabled: boolean) {
48+
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
49+
if (cmd === 'get_workspace_mapping_prompt_enabled') return enabled;
50+
return undefined;
51+
});
52+
}
53+
54+
describe('WorkspaceBindingSheet – mapping prompt toggle', () => {
55+
beforeEach(() => {
56+
vi.mocked(invoke).mockReset();
57+
});
58+
59+
it('shows the sheet when the prompt setting is enabled', async () => {
60+
mockPromptEnabled(true);
61+
render(<WorkspaceBindingSheet />);
62+
await fireNeedsBinding();
63+
expect(await screen.findByText(TITLE)).toBeTruthy();
64+
});
65+
66+
it('does NOT show the sheet when the prompt setting is disabled', async () => {
67+
mockPromptEnabled(false);
68+
render(<WorkspaceBindingSheet />);
69+
await fireNeedsBinding();
70+
await waitFor(() => expect(screen.queryByText(TITLE)).toBeNull());
71+
});
72+
73+
it('the in-sheet "stop asking" link disables the setting and closes', async () => {
74+
const user = userEvent.setup();
75+
mockPromptEnabled(true);
76+
render(<WorkspaceBindingSheet />);
77+
await fireNeedsBinding();
78+
await screen.findByText(TITLE);
79+
80+
await user.click(screen.getByTestId('workspace-binding-disable-prompt'));
81+
82+
await waitFor(() =>
83+
expect(invoke).toHaveBeenCalledWith('set_workspace_mapping_prompt_enabled', {
84+
enabled: false,
85+
})
86+
);
87+
await waitFor(() => expect(screen.queryByText(TITLE)).toBeNull());
88+
});
89+
});

0 commit comments

Comments
 (0)