diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index 47d37ff2..bd1adc74 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -696,6 +696,7 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val session_id, space_id, workspace_root, + space_locked, } => ( "workspace-needs-binding", serde_json::json!({ @@ -703,6 +704,7 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "session_id": session_id, "space_id": space_id, "workspace_root": workspace_root, + "space_locked": space_locked, }), ), diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index b92f3747..a63a08d2 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -6,7 +6,7 @@ //! built-in fallback. The desktop UI tracks which space the user is //! viewing in its own Zustand store (frontend-only state). -use mcpmux_core::Space; +use mcpmux_core::{validate_workspace_root, Space, SpaceBaseDir, WorkspaceRootValidation}; use std::sync::Arc; use tauri::{AppHandle, State}; use tokio::sync::RwLock; @@ -249,3 +249,62 @@ pub async fn refresh_tray_menu(app: AppHandle, state: State<'_, AppState>) -> Re .await .map_err(|e| format!("Failed to update tray menu: {}", e)) } + +// --------------------------------------------------------------------------- +// Space base directories β€” scope a workspace root to a Space by folder prefix. +// A reported root at or under a base dir falls back to that Space's Starter +// (and scopes the meta-tools / mapping popup to it). Takes effect on a +// connected client's next request. +// --------------------------------------------------------------------------- + +/// List a Space's configured base directories. +#[tauri::command] +pub async fn list_space_base_dirs( + space_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + state + .space_base_dir_repository + .list_by_space(&uuid) + .await + .map_err(|e| e.to_string()) +} + +/// Add a base directory to a Space. The path is validated (must be an absolute +/// folder) and normalized before storing; an error is returned if it's already +/// claimed by another Space. +#[tauri::command] +pub async fn add_space_base_dir( + space_id: String, + path: String, + state: State<'_, AppState>, +) -> Result { + let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + let normalized = match validate_workspace_root(&path) { + WorkspaceRootValidation::Ok { normalized } => normalized, + WorkspaceRootValidation::Empty => return Err("Pick a folder first.".to_string()), + WorkspaceRootValidation::Invalid { reason } => return Err(reason), + }; + + info!( + "[add_space_base_dir] space={} path={} (normalized {})", + space_id, path, normalized + ); + state + .space_base_dir_repository + .add(&uuid, &normalized) + .await + .map_err(|e| e.to_string()) +} + +/// Remove a base directory (by its row id). +#[tauri::command] +pub async fn remove_space_base_dir(id: String, state: State<'_, AppState>) -> Result<(), String> { + state + .space_base_dir_repository + .remove(&id) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index afd84d05..6cc6b1a7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -860,6 +860,9 @@ pub fn run() { commands::get_space, commands::create_space, commands::delete_space, + commands::list_space_base_dirs, + commands::add_space_base_dir, + commands::remove_space_base_dir, commands::open_space_config_file, commands::read_space_config, commands::save_space_config, diff --git a/apps/desktop/src-tauri/src/state/mod.rs b/apps/desktop/src-tauri/src/state/mod.rs index 262ed981..78050b78 100644 --- a/apps/desktop/src-tauri/src/state/mod.rs +++ b/apps/desktop/src-tauri/src/state/mod.rs @@ -8,12 +8,13 @@ use mcpmux_core::{ GatewayPortService, InboundMcpClientRepository, InstalledServerRepository, LogConfig, OutboundOAuthRepository, ServerDiscoveryService, ServerFeatureRepository as CoreServerFeatureRepository, ServerLogManager, - SpaceBuiltinConfigRepository, SpaceRepository, SpaceService, WorkspaceBindingRepository, + SpaceBaseDirRepository, SpaceBuiltinConfigRepository, SpaceRepository, SpaceService, + WorkspaceBindingRepository, }; use mcpmux_storage::{ Database, FieldEncryptor, SqliteAppSettingsRepository, SqliteCredentialRepository, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, - SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, + SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, SqliteSpaceBaseDirRepository, SqliteSpaceBuiltinConfigRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use std::path::PathBuf; @@ -49,6 +50,8 @@ pub struct AppState { pub client_repository: Arc, /// Workspace-root -> FeatureSet bindings (resolver v2) pub workspace_binding_repository: Arc, + /// Per-Space base directories (scope a workspace root to a Space by prefix) + pub space_base_dir_repository: Arc, /// Per-Space built-in server config (Tool Optimization enablement + tool toggles) pub space_builtin_config_repository: Arc, /// Server feature repository for discovered MCP features (implements core trait) @@ -109,6 +112,9 @@ impl AppState { let workspace_binding_repository: Arc = Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let space_base_dir_repository: Arc = + Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())); + let space_builtin_config_repository: Arc = Arc::new(SqliteSpaceBuiltinConfigRepository::new(db.clone())); @@ -169,6 +175,7 @@ impl AppState { feature_set_repository, client_repository, workspace_binding_repository, + space_base_dir_repository, space_builtin_config_repository, server_feature_repository, server_feature_repository_core, diff --git a/apps/desktop/src/features/spaces/SpaceBaseDirsModal.tsx b/apps/desktop/src/features/spaces/SpaceBaseDirsModal.tsx new file mode 100644 index 00000000..c7468658 --- /dev/null +++ b/apps/desktop/src/features/spaces/SpaceBaseDirsModal.tsx @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useState } from 'react'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { FolderPlus, FolderOpen, Loader2, Trash2, X } from 'lucide-react'; +import { Button, useToast, ToastContainer } from '@mcpmux/ui'; +import { + addSpaceBaseDir, + listSpaceBaseDirs, + removeSpaceBaseDir, + type Space, + type SpaceBaseDir, +} from '@/lib/api/spaces'; + +/** + * Manage a Space's base directories. + * + * A base dir scopes any workspace root opened at or under it to this Space: + * an unmapped folder there falls back to this Space's Starter set, and the + * self-optimize meta-tools + mapping popup restrict to this Space. Longest + * match wins when base dirs nest across Spaces, and a folder can belong to + * only one Space. + */ +export function SpaceBaseDirsModal({ + space, + onClose, +}: { + space: Space | null; + onClose: () => void; +}) { + const [dirs, setDirs] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const { toasts, success, error: showError, dismiss } = useToast(); + + const spaceId = space?.id ?? null; + + const load = useCallback(async () => { + if (!spaceId) return; + setLoading(true); + try { + setDirs(await listSpaceBaseDirs(spaceId)); + } catch (e) { + showError('Could not load base directories', e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [spaceId, showError]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const handleAdd = async () => { + if (!spaceId || busy) return; + let picked: string | string[] | null; + try { + picked = await openDialog({ directory: true, multiple: true, title: 'Add base directory' }); + } catch { + return; + } + const paths = Array.isArray(picked) ? picked : picked ? [picked] : []; + if (paths.length === 0) return; + + setBusy(true); + let added = 0; + for (const p of paths) { + try { + await addSpaceBaseDir(spaceId, p); + added++; + } catch (e) { + showError('Could not add folder', e instanceof Error ? e.message : String(e)); + } + } + await load(); + setBusy(false); + if (added > 0) { + success( + added === 1 ? 'Base directory added' : `${added} base directories added`, + 'Folders here are now scoped to this space.' + ); + } + }; + + const handleRemove = async (dir: SpaceBaseDir) => { + if (busy) return; + setBusy(true); + try { + await removeSpaceBaseDir(dir.id); + setDirs((prev) => prev.filter((d) => d.id !== dir.id)); + } catch (e) { + showError('Could not remove folder', e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + if (!space) return null; + + return ( +
+
e.stopPropagation()} + data-testid="space-base-dirs-modal" + > +
+
+
+ {space.icon || '🌐'} +
+
+

Base directories

+

+ Folders scoped to {space.name} +

+
+
+ +
+ +
+

+ Any folder you open here (or under it) is scoped to this space β€” it uses this + space's tools by default, and self-optimize only sees this space. The most specific + base directory wins, and a folder can belong to only one space. +

+ + {loading ? ( +
+ +
+ ) : dirs.length === 0 ? ( +
+ No base directories yet. Add one to scope its folders to this space. +
+ ) : ( +
    + {dirs.map((dir) => ( +
  • + + + {dir.path} + + +
  • + ))} +
+ )} +
+ +
+ +
+
+ +
+ ); +} diff --git a/apps/desktop/src/features/spaces/SpacesPage.tsx b/apps/desktop/src/features/spaces/SpacesPage.tsx index 35d2f924..09d1e389 100644 --- a/apps/desktop/src/features/spaces/SpacesPage.tsx +++ b/apps/desktop/src/features/spaces/SpacesPage.tsx @@ -1,9 +1,10 @@ import { useState } from 'react'; -import { Plus, Trash2, Loader2, Search, Layout, AlertCircle } from 'lucide-react'; +import { Plus, Trash2, Loader2, Search, Layout, AlertCircle, FolderTree } from 'lucide-react'; import { Card, CardContent, Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import { useAppStore, useSpaces, useIsLoading } from '@/stores'; -import { deleteSpace } from '@/lib/api/spaces'; +import { deleteSpace, type Space } from '@/lib/api/spaces'; import { CreateSpaceModal } from './CreateSpaceModal'; +import { SpaceBaseDirsModal } from './SpaceBaseDirsModal'; export function SpacesPage() { const spaces = useSpaces(); @@ -21,6 +22,8 @@ export function SpacesPage() { // Create Modal State (creation logic lives in the shared CreateSpaceModal) const [showCreateModal, setShowCreateModal] = useState(false); + // The space whose base directories are being managed (null = closed). + const [baseDirsSpace, setBaseDirsSpace] = useState(null); const handleDelete = async (id: string) => { const spaceName = spaces.find((s) => s.id === id)?.name || 'this space'; @@ -160,7 +163,15 @@ export function SpacesPage() { {space.description || 'No description'}

-
+
+ {space.is_default && ( setShowCreateModal(false)} /> + setBaseDirsSpace(null)} />
); diff --git a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx index cd91ff74..c30327e7 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx @@ -36,6 +36,9 @@ interface WorkspaceNeedsBindingPayload { session_id: string; space_id: string; workspace_root: string; + /** The folder is scoped to `space_id` by a Space base directory β€” lock the + * Space field to it (the user only picks the feature set). */ + space_locked?: boolean; } /** @@ -250,14 +253,16 @@ export function WorkspaceBindingSheet() { Space

- A profile that groups MCP servers β€” pick the one this folder draws - its tools from. + {payload.space_locked + ? 'This folder is under a base directory of this space, so it stays in this space β€” just pick the feature set below.' + : 'A profile that groups MCP servers β€” pick the one this folder draws its tools from.'}