Skip to content

Commit cf8b59c

Browse files
committed
feat(spaces): commands + UI to configure per-space base directories
- AppState gains the SpaceBaseDirRepository; Tauri commands list/add/remove_space_base_dir (add validates + normalizes the path and rejects a folder already owned by another space). - TS API: SpaceBaseDir + listSpaceBaseDirs/addSpaceBaseDir/removeSpaceBaseDir. - Spaces page: a folder-tree button per space opens a "Base directories" modal (multi-folder picker + list with remove). Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 76bdf19 commit cf8b59c

6 files changed

Lines changed: 320 additions & 6 deletions

File tree

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//! built-in fallback. The desktop UI tracks which space the user is
77
//! viewing in its own Zustand store (frontend-only state).
88
9-
use mcpmux_core::Space;
9+
use mcpmux_core::{validate_workspace_root, Space, SpaceBaseDir, WorkspaceRootValidation};
1010
use std::sync::Arc;
1111
use tauri::{AppHandle, State};
1212
use tokio::sync::RwLock;
@@ -249,3 +249,62 @@ pub async fn refresh_tray_menu(app: AppHandle, state: State<'_, AppState>) -> Re
249249
.await
250250
.map_err(|e| format!("Failed to update tray menu: {}", e))
251251
}
252+
253+
// ---------------------------------------------------------------------------
254+
// Space base directories — scope a workspace root to a Space by folder prefix.
255+
// A reported root at or under a base dir falls back to that Space's Starter
256+
// (and scopes the meta-tools / mapping popup to it). Takes effect on a
257+
// connected client's next request.
258+
// ---------------------------------------------------------------------------
259+
260+
/// List a Space's configured base directories.
261+
#[tauri::command]
262+
pub async fn list_space_base_dirs(
263+
space_id: String,
264+
state: State<'_, AppState>,
265+
) -> Result<Vec<SpaceBaseDir>, String> {
266+
let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?;
267+
state
268+
.space_base_dir_repository
269+
.list_by_space(&uuid)
270+
.await
271+
.map_err(|e| e.to_string())
272+
}
273+
274+
/// Add a base directory to a Space. The path is validated (must be an absolute
275+
/// folder) and normalized before storing; an error is returned if it's already
276+
/// claimed by another Space.
277+
#[tauri::command]
278+
pub async fn add_space_base_dir(
279+
space_id: String,
280+
path: String,
281+
state: State<'_, AppState>,
282+
) -> Result<SpaceBaseDir, String> {
283+
let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?;
284+
285+
let normalized = match validate_workspace_root(&path) {
286+
WorkspaceRootValidation::Ok { normalized } => normalized,
287+
WorkspaceRootValidation::Empty => return Err("Pick a folder first.".to_string()),
288+
WorkspaceRootValidation::Invalid { reason } => return Err(reason),
289+
};
290+
291+
info!(
292+
"[add_space_base_dir] space={} path={} (normalized {})",
293+
space_id, path, normalized
294+
);
295+
state
296+
.space_base_dir_repository
297+
.add(&uuid, &normalized)
298+
.await
299+
.map_err(|e| e.to_string())
300+
}
301+
302+
/// Remove a base directory (by its row id).
303+
#[tauri::command]
304+
pub async fn remove_space_base_dir(id: String, state: State<'_, AppState>) -> Result<(), String> {
305+
state
306+
.space_base_dir_repository
307+
.remove(&id)
308+
.await
309+
.map_err(|e| e.to_string())
310+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,9 @@ pub fn run() {
860860
commands::get_space,
861861
commands::create_space,
862862
commands::delete_space,
863+
commands::list_space_base_dirs,
864+
commands::add_space_base_dir,
865+
commands::remove_space_base_dir,
863866
commands::open_space_config_file,
864867
commands::read_space_config,
865868
commands::save_space_config,

apps/desktop/src-tauri/src/state/mod.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ use mcpmux_core::{
88
GatewayPortService, InboundMcpClientRepository, InstalledServerRepository, LogConfig,
99
OutboundOAuthRepository, ServerDiscoveryService,
1010
ServerFeatureRepository as CoreServerFeatureRepository, ServerLogManager,
11-
SpaceBuiltinConfigRepository, SpaceRepository, SpaceService, WorkspaceBindingRepository,
11+
SpaceBaseDirRepository, SpaceBuiltinConfigRepository, SpaceRepository, SpaceService,
12+
WorkspaceBindingRepository,
1213
};
1314
use mcpmux_storage::{
1415
Database, FieldEncryptor, SqliteAppSettingsRepository, SqliteCredentialRepository,
1516
SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository,
16-
SqliteOutboundOAuthRepository, SqliteServerFeatureRepository,
17+
SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, SqliteSpaceBaseDirRepository,
1718
SqliteSpaceBuiltinConfigRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository,
1819
};
1920
use std::path::PathBuf;
@@ -49,6 +50,8 @@ pub struct AppState {
4950
pub client_repository: Arc<dyn InboundMcpClientRepository>,
5051
/// Workspace-root -> FeatureSet bindings (resolver v2)
5152
pub workspace_binding_repository: Arc<dyn WorkspaceBindingRepository>,
53+
/// Per-Space base directories (scope a workspace root to a Space by prefix)
54+
pub space_base_dir_repository: Arc<dyn SpaceBaseDirRepository>,
5255
/// Per-Space built-in server config (Tool Optimization enablement + tool toggles)
5356
pub space_builtin_config_repository: Arc<dyn SpaceBuiltinConfigRepository>,
5457
/// Server feature repository for discovered MCP features (implements core trait)
@@ -109,6 +112,9 @@ impl AppState {
109112
let workspace_binding_repository: Arc<dyn WorkspaceBindingRepository> =
110113
Arc::new(SqliteWorkspaceBindingRepository::new(db.clone()));
111114

115+
let space_base_dir_repository: Arc<dyn SpaceBaseDirRepository> =
116+
Arc::new(SqliteSpaceBaseDirRepository::new(db.clone()));
117+
112118
let space_builtin_config_repository: Arc<dyn SpaceBuiltinConfigRepository> =
113119
Arc::new(SqliteSpaceBuiltinConfigRepository::new(db.clone()));
114120

@@ -169,6 +175,7 @@ impl AppState {
169175
feature_set_repository,
170176
client_repository,
171177
workspace_binding_repository,
178+
space_base_dir_repository,
172179
space_builtin_config_repository,
173180
server_feature_repository,
174181
server_feature_repository_core,
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
import { open as openDialog } from '@tauri-apps/plugin-dialog';
3+
import { FolderPlus, FolderOpen, Loader2, Trash2, X } from 'lucide-react';
4+
import { Button, useToast, ToastContainer } from '@mcpmux/ui';
5+
import {
6+
addSpaceBaseDir,
7+
listSpaceBaseDirs,
8+
removeSpaceBaseDir,
9+
type Space,
10+
type SpaceBaseDir,
11+
} from '@/lib/api/spaces';
12+
13+
/**
14+
* Manage a Space's base directories.
15+
*
16+
* A base dir scopes any workspace root opened at or under it to this Space:
17+
* an unmapped folder there falls back to this Space's Starter set, and the
18+
* self-optimize meta-tools + mapping popup restrict to this Space. Longest
19+
* match wins when base dirs nest across Spaces, and a folder can belong to
20+
* only one Space.
21+
*/
22+
export function SpaceBaseDirsModal({
23+
space,
24+
onClose,
25+
}: {
26+
space: Space | null;
27+
onClose: () => void;
28+
}) {
29+
const [dirs, setDirs] = useState<SpaceBaseDir[]>([]);
30+
const [loading, setLoading] = useState(false);
31+
const [busy, setBusy] = useState(false);
32+
const { toasts, success, error: showError, dismiss } = useToast();
33+
34+
const spaceId = space?.id ?? null;
35+
36+
const load = useCallback(async () => {
37+
if (!spaceId) return;
38+
setLoading(true);
39+
try {
40+
setDirs(await listSpaceBaseDirs(spaceId));
41+
} catch (e) {
42+
showError('Could not load base directories', e instanceof Error ? e.message : String(e));
43+
} finally {
44+
setLoading(false);
45+
}
46+
}, [spaceId, showError]);
47+
48+
useEffect(() => {
49+
void load();
50+
}, [load]);
51+
52+
useEffect(() => {
53+
const onKey = (e: KeyboardEvent) => {
54+
if (e.key === 'Escape') onClose();
55+
};
56+
window.addEventListener('keydown', onKey);
57+
return () => window.removeEventListener('keydown', onKey);
58+
}, [onClose]);
59+
60+
const handleAdd = async () => {
61+
if (!spaceId || busy) return;
62+
let picked: string | string[] | null;
63+
try {
64+
picked = await openDialog({ directory: true, multiple: true, title: 'Add base directory' });
65+
} catch {
66+
return;
67+
}
68+
const paths = Array.isArray(picked) ? picked : picked ? [picked] : [];
69+
if (paths.length === 0) return;
70+
71+
setBusy(true);
72+
let added = 0;
73+
for (const p of paths) {
74+
try {
75+
await addSpaceBaseDir(spaceId, p);
76+
added++;
77+
} catch (e) {
78+
showError('Could not add folder', e instanceof Error ? e.message : String(e));
79+
}
80+
}
81+
await load();
82+
setBusy(false);
83+
if (added > 0) {
84+
success(
85+
added === 1 ? 'Base directory added' : `${added} base directories added`,
86+
'Folders here are now scoped to this space.'
87+
);
88+
}
89+
};
90+
91+
const handleRemove = async (dir: SpaceBaseDir) => {
92+
if (busy) return;
93+
setBusy(true);
94+
try {
95+
await removeSpaceBaseDir(dir.id);
96+
setDirs((prev) => prev.filter((d) => d.id !== dir.id));
97+
} catch (e) {
98+
showError('Could not remove folder', e instanceof Error ? e.message : String(e));
99+
} finally {
100+
setBusy(false);
101+
}
102+
};
103+
104+
if (!space) return null;
105+
106+
return (
107+
<div
108+
className="animate-fade-in fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
109+
onClick={onClose}
110+
>
111+
<div
112+
className="animate-slide-up flex max-h-[80vh] w-full max-w-lg flex-col rounded-2xl border border-[rgb(var(--border))] bg-[rgb(var(--background))] shadow-2xl"
113+
onClick={(e) => e.stopPropagation()}
114+
data-testid="space-base-dirs-modal"
115+
>
116+
<div className="flex items-start justify-between border-b border-[rgb(var(--border-subtle))] p-5">
117+
<div className="flex items-center gap-3">
118+
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] text-xl">
119+
{space.icon || '🌐'}
120+
</div>
121+
<div>
122+
<h2 className="text-lg font-semibold">Base directories</h2>
123+
<p className="text-xs text-[rgb(var(--muted))]">
124+
Folders scoped to <span className="font-medium">{space.name}</span>
125+
</p>
126+
</div>
127+
</div>
128+
<button
129+
onClick={onClose}
130+
className="rounded-lg p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface))] hover:text-[rgb(var(--foreground))]"
131+
aria-label="Close"
132+
>
133+
<X className="h-5 w-5" />
134+
</button>
135+
</div>
136+
137+
<div className="min-h-0 flex-1 overflow-y-auto p-5">
138+
<p className="mb-4 text-sm text-[rgb(var(--muted))]">
139+
Any folder you open here (or under it) is scoped to this space — it uses this
140+
space&apos;s tools by default, and self-optimize only sees this space. The most specific
141+
base directory wins, and a folder can belong to only one space.
142+
</p>
143+
144+
{loading ? (
145+
<div className="flex items-center justify-center py-10 text-[rgb(var(--muted))]">
146+
<Loader2 className="h-5 w-5 animate-spin" />
147+
</div>
148+
) : dirs.length === 0 ? (
149+
<div className="rounded-xl border border-dashed border-[rgb(var(--border))] px-4 py-8 text-center text-sm text-[rgb(var(--muted))]">
150+
No base directories yet. Add one to scope its folders to this space.
151+
</div>
152+
) : (
153+
<ul className="space-y-2" data-testid="space-base-dirs-list">
154+
{dirs.map((dir) => (
155+
<li
156+
key={dir.id}
157+
className="flex items-center gap-3 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] px-3 py-2.5"
158+
>
159+
<FolderOpen className="text-primary-500 h-4 w-4 flex-shrink-0" />
160+
<span
161+
className="min-w-0 flex-1 truncate font-mono text-xs text-[rgb(var(--foreground))]"
162+
title={dir.path}
163+
>
164+
{dir.path}
165+
</span>
166+
<button
167+
onClick={() => handleRemove(dir)}
168+
disabled={busy}
169+
className="flex-shrink-0 rounded-lg p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50 dark:hover:bg-red-900/20"
170+
title="Remove base directory"
171+
data-testid={`remove-base-dir-${dir.id}`}
172+
>
173+
<Trash2 className="h-4 w-4" />
174+
</button>
175+
</li>
176+
))}
177+
</ul>
178+
)}
179+
</div>
180+
181+
<div className="border-t border-[rgb(var(--border-subtle))] p-5">
182+
<Button
183+
variant="primary"
184+
className="w-full"
185+
onClick={handleAdd}
186+
disabled={busy}
187+
data-testid="add-base-dir-btn"
188+
>
189+
{busy ? (
190+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
191+
) : (
192+
<FolderPlus className="mr-2 h-4 w-4" />
193+
)}
194+
Add folder…
195+
</Button>
196+
</div>
197+
</div>
198+
<ToastContainer toasts={toasts} onClose={dismiss} />
199+
</div>
200+
);
201+
}

apps/desktop/src/features/spaces/SpacesPage.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useState } from 'react';
2-
import { Plus, Trash2, Loader2, Search, Layout, AlertCircle } from 'lucide-react';
2+
import { Plus, Trash2, Loader2, Search, Layout, AlertCircle, FolderTree } from 'lucide-react';
33
import { Card, CardContent, Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui';
44
import { useAppStore, useSpaces, useIsLoading } from '@/stores';
5-
import { deleteSpace } from '@/lib/api/spaces';
5+
import { deleteSpace, type Space } from '@/lib/api/spaces';
66
import { CreateSpaceModal } from './CreateSpaceModal';
7+
import { SpaceBaseDirsModal } from './SpaceBaseDirsModal';
78

89
export function SpacesPage() {
910
const spaces = useSpaces();
@@ -21,6 +22,8 @@ export function SpacesPage() {
2122

2223
// Create Modal State (creation logic lives in the shared CreateSpaceModal)
2324
const [showCreateModal, setShowCreateModal] = useState(false);
25+
// The space whose base directories are being managed (null = closed).
26+
const [baseDirsSpace, setBaseDirsSpace] = useState<Space | null>(null);
2427

2528
const handleDelete = async (id: string) => {
2629
const spaceName = spaces.find((s) => s.id === id)?.name || 'this space';
@@ -160,7 +163,15 @@ export function SpacesPage() {
160163
{space.description || 'No description'}
161164
</p>
162165
</div>
163-
<div className="flex flex-shrink-0 gap-1">
166+
<div className="flex flex-shrink-0 items-center gap-1">
167+
<button
168+
onClick={() => setBaseDirsSpace(space)}
169+
className="rounded-lg p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface))] hover:text-[rgb(var(--foreground))]"
170+
title="Base directories — scope folders to this space"
171+
data-testid={`space-base-dirs-${space.id}`}
172+
>
173+
<FolderTree className="h-4 w-4" />
174+
</button>
164175
{space.is_default && (
165176
<span
166177
className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
@@ -192,6 +203,7 @@ export function SpacesPage() {
192203
</div>
193204

194205
<CreateSpaceModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
206+
<SpaceBaseDirsModal space={baseDirsSpace} onClose={() => setBaseDirsSpace(null)} />
195207
</div>
196208
</>
197209
);

0 commit comments

Comments
 (0)