Skip to content

Commit 726492e

Browse files
committed
feat(ui): add rename/edit for spaces, feature sets, and workspace bindings
Expose update_space and editable panels for Spaces and Feature Sets. Add optional workspace binding labels with migration 016 so folders can have friendly display names separate from their paths. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 5730b25 commit 726492e

15 files changed

Lines changed: 592 additions & 73 deletions

File tree

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,6 @@ pub async fn update_feature_set(
263263
.map_err(|e| e.to_string())?
264264
.ok_or("Feature set not found")?;
265265

266-
if feature_set.is_builtin {
267-
return Err("Cannot modify builtin feature set".to_string());
268-
}
269-
270266
if let Some(name) = input.name {
271267
feature_set.name = name;
272268
}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
//! viewing in its own Zustand store (frontend-only state).
88
99
use mcpmux_core::Space;
10+
use serde::Deserialize;
1011
use std::sync::Arc;
1112
use tauri::{AppHandle, State};
1213
use tokio::sync::RwLock;
@@ -103,6 +104,59 @@ pub async fn create_space(
103104
Ok(space)
104105
}
105106

107+
/// Partial update payload for a Space (name, icon, description).
108+
#[derive(Debug, Deserialize)]
109+
pub struct UpdateSpaceInput {
110+
pub name: Option<String>,
111+
pub icon: Option<String>,
112+
pub description: Option<String>,
113+
}
114+
115+
/// Update a space's display metadata.
116+
#[tauri::command]
117+
pub async fn update_space(
118+
id: String,
119+
input: UpdateSpaceInput,
120+
app: AppHandle,
121+
state: State<'_, AppState>,
122+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
123+
) -> Result<Space, String> {
124+
let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?;
125+
126+
let name = input
127+
.name
128+
.map(|n| n.trim().to_string())
129+
.filter(|n| !n.is_empty());
130+
let icon = input
131+
.icon
132+
.map(|i| i.trim().to_string())
133+
.filter(|i| !i.is_empty());
134+
let description = input.description.map(|d| d.trim().to_string());
135+
136+
let space = state
137+
.space_service
138+
.update(uuid, name, icon, description)
139+
.await
140+
.map_err(|e| e.to_string())?;
141+
142+
let gw_state = gateway_state.read().await;
143+
if let Some(ref gw) = gw_state.gateway_state {
144+
let gw = gw.read().await;
145+
gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceUpdated {
146+
space_id: space.id,
147+
name: space.name.clone(),
148+
});
149+
}
150+
151+
if let Err(e) = tray::update_tray_spaces(&app, &state).await {
152+
warn!("Failed to update tray menu: {}", e);
153+
}
154+
155+
info!("[update_space] Space '{}' updated successfully", space.name);
156+
157+
Ok(space)
158+
}
159+
106160
/// Delete a space.
107161
#[tauri::command]
108162
pub async fn delete_space(

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ async fn emit_binding_changed(
5454
pub struct WorkspaceBindingDto {
5555
pub id: String,
5656
pub workspace_root: String,
57+
pub label: Option<String>,
5758
pub space_id: String,
5859
pub feature_set_ids: Vec<String>,
5960
pub created_at: String,
@@ -65,6 +66,7 @@ impl From<WorkspaceBinding> for WorkspaceBindingDto {
6566
Self {
6667
id: b.id.to_string(),
6768
workspace_root: b.workspace_root,
69+
label: b.label,
6870
space_id: b.space_id.to_string(),
6971
feature_set_ids: b.feature_set_ids,
7072
created_at: b.created_at.to_rfc3339(),
@@ -80,10 +82,18 @@ impl From<WorkspaceBinding> for WorkspaceBindingDto {
8082
#[derive(Debug, Deserialize)]
8183
pub struct WorkspaceBindingInput {
8284
pub workspace_root: String,
85+
pub label: Option<String>,
8386
pub space_id: String,
8487
pub feature_set_ids: Vec<String>,
8588
}
8689

90+
fn normalize_label(label: &Option<String>) -> Option<String> {
91+
label
92+
.as_ref()
93+
.map(|s| s.trim().to_string())
94+
.filter(|s| !s.is_empty())
95+
}
96+
8797
fn parse_space_id(input: &WorkspaceBindingInput) -> Result<Uuid, String> {
8898
Uuid::parse_str(&input.space_id).map_err(|e| format!("bad space_id: {e}"))
8999
}
@@ -194,7 +204,8 @@ pub async fn create_workspace_binding(
194204
let feature_set_ids = validate_fs_list(&input)?;
195205
let normalized = normalize_and_validate(&input.workspace_root)?;
196206

197-
let binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids);
207+
let mut binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids);
208+
binding.label = normalize_label(&input.label);
198209

199210
state
200211
.workspace_binding_repository
@@ -241,9 +252,16 @@ pub async fn update_workspace_binding(
241252
.ok_or_else(|| format!("binding not found: {}", id))?;
242253
let old_space_id = existing.space_id;
243254

255+
let label = if input.label.is_some() {
256+
normalize_label(&input.label)
257+
} else {
258+
existing.label
259+
};
260+
244261
let updated = WorkspaceBinding {
245262
id: existing.id,
246263
workspace_root: normalized,
264+
label,
247265
space_id,
248266
feature_set_ids,
249267
created_at: existing.created_at,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,7 @@ pub fn run() {
848848
commands::list_spaces,
849849
commands::get_space,
850850
commands::create_space,
851+
commands::update_space,
851852
commands::delete_space,
852853
commands::open_space_config_file,
853854
commands::read_space_config,

apps/desktop/src/features/featuresets/FeatureSetPanel.tsx

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import {
2121
} from 'lucide-react';
2222
import { Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui';
2323
import type { FeatureSet, AddMemberInput } from '@/lib/api/featureSets';
24-
import { isStarterFeatureSet, setFeatureSetMembers } from '@/lib/api/featureSets';
24+
import {
25+
isStarterFeatureSet,
26+
setFeatureSetMembers,
27+
updateFeatureSet,
28+
} from '@/lib/api/featureSets';
2529
import type { ServerFeature } from '@/lib/api/serverFeatures';
2630
import { listServerFeatures } from '@/lib/api/serverFeatures';
2731

@@ -45,6 +49,11 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
4549
const [searchQuery, setSearchQuery] = useState('');
4650
const [isLoading, setIsLoading] = useState(true);
4751
const [isSaving, setIsSaving] = useState(false);
52+
const [isSavingGeneral, setIsSavingGeneral] = useState(false);
53+
const [displayName, setDisplayName] = useState(featureSet.name);
54+
const [editName, setEditName] = useState(featureSet.name);
55+
const [editDescription, setEditDescription] = useState(featureSet.description ?? '');
56+
const [editIcon, setEditIcon] = useState(featureSet.icon ?? '');
4857
const [error, setError] = useState<string | null>(null);
4958
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
5059
const { toasts, success, error: showError, dismiss } = useToast();
@@ -68,6 +77,13 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
6877
const isFeatureSelected = (featureId: string, _feature: ServerFeature) =>
6978
selectedFeatureIds.has(featureId);
7079

80+
useEffect(() => {
81+
setDisplayName(featureSet.name);
82+
setEditName(featureSet.name);
83+
setEditDescription(featureSet.description ?? '');
84+
setEditIcon(featureSet.icon ?? '');
85+
}, [featureSet]);
86+
7187
useEffect(() => {
7288
const loadFeatures = async () => {
7389
setIsLoading(true);
@@ -167,6 +183,44 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
167183
});
168184
};
169185

186+
/**
187+
* Save name, description, and icon from the General Information section.
188+
*/
189+
const handleSaveGeneral = async () => {
190+
const trimmedName = editName.trim();
191+
if (!trimmedName) {
192+
setError('Name is required.');
193+
return;
194+
}
195+
196+
setIsSavingGeneral(true);
197+
setError(null);
198+
try {
199+
const updated = await updateFeatureSet(featureSet.id, {
200+
name: trimmedName,
201+
description: editDescription.trim() || undefined,
202+
icon: editIcon.trim() || undefined,
203+
});
204+
setDisplayName(updated.name);
205+
setEditName(updated.name);
206+
setEditDescription(updated.description ?? '');
207+
setEditIcon(updated.icon ?? '');
208+
success('Feature set updated', `"${updated.name}" has been saved`);
209+
onUpdate?.();
210+
} catch (e) {
211+
const errorMsg = e instanceof Error ? e.message : String(e);
212+
setError(errorMsg);
213+
showError('Failed to save feature set', errorMsg);
214+
} finally {
215+
setIsSavingGeneral(false);
216+
}
217+
};
218+
219+
const hasGeneralChanges =
220+
editName.trim() !== featureSet.name ||
221+
editDescription.trim() !== (featureSet.description ?? '') ||
222+
editIcon.trim() !== (featureSet.icon ?? '');
223+
170224
const handleSave = async () => {
171225
setIsSaving(true);
172226
setError(null);
@@ -251,7 +305,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
251305
</div>
252306
<div className="flex-1 min-w-0">
253307
<h2 className="text-lg font-bold truncate flex items-center gap-2">
254-
{featureSet.name}
308+
{displayName}
255309
</h2>
256310
<div className="flex items-center gap-2 mt-0.5">
257311
<span
@@ -327,13 +381,62 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
327381
<div className="p-4 space-y-4 border-t-2 border-[rgb(var(--border))] bg-white dark:bg-[rgb(var(--background))]">
328382
<div>
329383
<label className="block text-xs font-medium mb-1.5 text-[rgb(var(--muted))]">
330-
Description
384+
Name *
331385
</label>
332-
<p className="text-sm">
333-
{featureSet.description || 'No description provided.'}
334-
</p>
386+
<input
387+
type="text"
388+
value={editName}
389+
onChange={(e) => setEditName(e.target.value)}
390+
className="w-full px-3 py-2 text-sm rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500"
391+
data-testid="featureset-panel-name"
392+
/>
335393
</div>
394+
336395

396+
<div>
397+
<label className="block text-xs font-medium mb-1.5 text-[rgb(var(--muted))]">
398+
Description
399+
</label>
400+
<input
401+
type="text"
402+
value={editDescription}
403+
onChange={(e) => setEditDescription(e.target.value)}
404+
placeholder="What this feature set allows..."
405+
className="w-full px-3 py-2 text-sm rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500"
406+
data-testid="featureset-panel-description"
407+
/>
408+
</div>
409+
410+
<div>
411+
<label className="block text-xs font-medium mb-1.5 text-[rgb(var(--muted))]">
412+
Icon (emoji)
413+
</label>
414+
<input
415+
type="text"
416+
value={editIcon}
417+
onChange={(e) => setEditIcon(e.target.value)}
418+
placeholder="🔧"
419+
maxLength={2}
420+
className="w-full px-3 py-2 text-sm rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500"
421+
data-testid="featureset-panel-icon"
422+
/>
423+
</div>
424+
425+
<Button
426+
variant="primary"
427+
size="sm"
428+
onClick={() => void handleSaveGeneral()}
429+
disabled={isSavingGeneral || !editName.trim() || !hasGeneralChanges}
430+
data-testid="featureset-panel-save-general"
431+
>
432+
{isSavingGeneral ? (
433+
<Loader2 className="h-4 w-4 animate-spin mr-2" />
434+
) : (
435+
<Save className="h-4 w-4 mr-2" />
436+
)}
437+
Save
438+
</Button>
439+
337440
{isStarter && (
338441
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-800 rounded-lg">
339442
<div className="flex gap-2">

0 commit comments

Comments
 (0)