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
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/commands/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -696,13 +696,15 @@ 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!({
"client_id": client_id,
"session_id": session_id,
"space_id": space_id,
"workspace_root": workspace_root,
"space_locked": space_locked,
}),
),

Expand Down
61 changes: 60 additions & 1 deletion apps/desktop/src-tauri/src/commands/space.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Vec<SpaceBaseDir>, 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<SpaceBaseDir, String> {
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())
}
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src-tauri/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,6 +50,8 @@ pub struct AppState {
pub client_repository: Arc<dyn InboundMcpClientRepository>,
/// Workspace-root -> FeatureSet bindings (resolver v2)
pub workspace_binding_repository: Arc<dyn WorkspaceBindingRepository>,
/// Per-Space base directories (scope a workspace root to a Space by prefix)
pub space_base_dir_repository: Arc<dyn SpaceBaseDirRepository>,
/// Per-Space built-in server config (Tool Optimization enablement + tool toggles)
pub space_builtin_config_repository: Arc<dyn SpaceBuiltinConfigRepository>,
/// Server feature repository for discovered MCP features (implements core trait)
Expand Down Expand Up @@ -109,6 +112,9 @@ impl AppState {
let workspace_binding_repository: Arc<dyn WorkspaceBindingRepository> =
Arc::new(SqliteWorkspaceBindingRepository::new(db.clone()));

let space_base_dir_repository: Arc<dyn SpaceBaseDirRepository> =
Arc::new(SqliteSpaceBaseDirRepository::new(db.clone()));

let space_builtin_config_repository: Arc<dyn SpaceBuiltinConfigRepository> =
Arc::new(SqliteSpaceBuiltinConfigRepository::new(db.clone()));

Expand Down Expand Up @@ -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,
Expand Down
201 changes: 201 additions & 0 deletions apps/desktop/src/features/spaces/SpaceBaseDirsModal.tsx
Original file line number Diff line number Diff line change
@@ -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<SpaceBaseDir[]>([]);
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 (
<div
className="animate-fade-in fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
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"
onClick={(e) => e.stopPropagation()}
data-testid="space-base-dirs-modal"
>
<div className="flex items-start justify-between border-b border-[rgb(var(--border-subtle))] p-5">
<div className="flex items-center gap-3">
<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">
{space.icon || '🌐'}
</div>
<div>
<h2 className="text-lg font-semibold">Base directories</h2>
<p className="text-xs text-[rgb(var(--muted))]">
Folders scoped to <span className="font-medium">{space.name}</span>
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface))] hover:text-[rgb(var(--foreground))]"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>

<div className="min-h-0 flex-1 overflow-y-auto p-5">
<p className="mb-4 text-sm text-[rgb(var(--muted))]">
Any folder you open here (or under it) is scoped to this space — it uses this
space&apos;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.
</p>

{loading ? (
<div className="flex items-center justify-center py-10 text-[rgb(var(--muted))]">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : dirs.length === 0 ? (
<div className="rounded-xl border border-dashed border-[rgb(var(--border))] px-4 py-8 text-center text-sm text-[rgb(var(--muted))]">
No base directories yet. Add one to scope its folders to this space.
</div>
) : (
<ul className="space-y-2" data-testid="space-base-dirs-list">
{dirs.map((dir) => (
<li
key={dir.id}
className="flex items-center gap-3 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] px-3 py-2.5"
>
<FolderOpen className="text-primary-500 h-4 w-4 flex-shrink-0" />
<span
className="min-w-0 flex-1 truncate font-mono text-xs text-[rgb(var(--foreground))]"
title={dir.path}
>
{dir.path}
</span>
<button
onClick={() => handleRemove(dir)}
disabled={busy}
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"
title="Remove base directory"
data-testid={`remove-base-dir-${dir.id}`}
>
<Trash2 className="h-4 w-4" />
</button>
</li>
))}
</ul>
)}
</div>

<div className="border-t border-[rgb(var(--border-subtle))] p-5">
<Button
variant="primary"
className="w-full"
onClick={handleAdd}
disabled={busy}
data-testid="add-base-dir-btn"
>
{busy ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FolderPlus className="mr-2 h-4 w-4" />
)}
Add folder…
</Button>
</div>
</div>
<ToastContainer toasts={toasts} onClose={dismiss} />
</div>
);
}
18 changes: 15 additions & 3 deletions apps/desktop/src/features/spaces/SpacesPage.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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<Space | null>(null);

const handleDelete = async (id: string) => {
const spaceName = spaces.find((s) => s.id === id)?.name || 'this space';
Expand Down Expand Up @@ -160,7 +163,15 @@ export function SpacesPage() {
{space.description || 'No description'}
</p>
</div>
<div className="flex flex-shrink-0 gap-1">
<div className="flex flex-shrink-0 items-center gap-1">
<button
onClick={() => setBaseDirsSpace(space)}
className="rounded-lg p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface))] hover:text-[rgb(var(--foreground))]"
title="Base directories — scope folders to this space"
data-testid={`space-base-dirs-${space.id}`}
>
<FolderTree className="h-4 w-4" />
</button>
{space.is_default && (
<span
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"
Expand Down Expand Up @@ -192,6 +203,7 @@ export function SpacesPage() {
</div>

<CreateSpaceModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
<SpaceBaseDirsModal space={baseDirsSpace} onClose={() => setBaseDirsSpace(null)} />
</div>
</>
);
Expand Down
Loading
Loading