Skip to content

Commit 18a7976

Browse files
committed
feat(workspaces): workspace binding label/icon port (migration 032)
Land label/icon metadata on workspace bindings, appearance commands, and Projects UI ahead of machine-binding work on feat branch. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 4ddc339 commit 18a7976

23 files changed

Lines changed: 472 additions & 73 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ tauri-build = { version = "2", features = [] }
1515
serde_json.workspace = true
1616

1717
[dependencies]
18-
tauri = { version = "2", features = ["tray-icon"] }
18+
tauri = { version = "2", features = ["protocol-asset", "tray-icon"] }
1919
tauri-plugin-opener = "2"
2020
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
2121
tauri-plugin-deep-link = "2"

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,19 @@ pub(crate) async fn maybe_remove_orphaned_icon_file(
9696
.await
9797
.map_err(|e| e.to_string())?;
9898

99-
// ponytail: WorkspaceBinding.icon is added in Phase 7; only check appearances for now.
10099
if appearances.iter().any(|a| a.icon == icon_ref_owned) {
101100
return Ok(());
102101
}
103102

103+
let bindings = state
104+
.workspace_binding_repository
105+
.list()
106+
.await
107+
.map_err(|e| e.to_string())?;
108+
if bindings.iter().any(|b| b.icon.as_deref() == Some(icon_ref)) {
109+
return Ok(());
110+
}
111+
104112
let file_path = state.data_dir().join(WORKSPACE_ICON_DIR).join(file_name);
105113
match tokio::fs::remove_file(&file_path).await {
106114
Ok(()) => {}

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

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ use std::collections::{HashMap, HashSet};
88
use std::sync::Arc;
99

1010
use mcpmux_core::{
11-
validate_workspace_root as validate_root, DomainEvent, FeatureSet, FeatureSetType, MemberMode,
12-
MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation,
11+
normalize_optional_metadata, validate_workspace_root as validate_root, DomainEvent, FeatureSet,
12+
FeatureSetType, MemberMode, MemberType, ServerFeature, WorkspaceBinding,
13+
WorkspaceRootValidation,
1314
};
1415
use serde::{Deserialize, Serialize};
1516
use tauri::State;
@@ -19,6 +20,7 @@ use uuid::Uuid;
1920

2021
use super::gateway::GatewayAppState;
2122
use super::server_manager::ServerManagerState;
23+
use super::workspace_appearance::maybe_remove_orphaned_icon_file;
2224
use crate::state::AppState;
2325

2426
/// Publish `WorkspaceBindingChanged` on the gateway's domain bus so
@@ -54,6 +56,8 @@ async fn emit_binding_changed(
5456
pub struct WorkspaceBindingDto {
5557
pub id: String,
5658
pub workspace_root: String,
59+
pub label: Option<String>,
60+
pub icon: Option<String>,
5761
pub space_id: String,
5862
pub feature_set_ids: Vec<String>,
5963
pub created_at: String,
@@ -65,6 +69,8 @@ impl From<WorkspaceBinding> for WorkspaceBindingDto {
6569
Self {
6670
id: b.id.to_string(),
6771
workspace_root: b.workspace_root,
72+
label: b.label,
73+
icon: b.icon,
6874
space_id: b.space_id.to_string(),
6975
feature_set_ids: b.feature_set_ids,
7076
created_at: b.created_at.to_rfc3339(),
@@ -81,6 +87,8 @@ impl From<WorkspaceBinding> for WorkspaceBindingDto {
8187
#[derive(Debug, Deserialize)]
8288
pub struct WorkspaceBindingInput {
8389
pub workspace_root: String,
90+
pub label: Option<String>,
91+
pub icon: Option<String>,
8492
pub space_id: String,
8593
pub feature_set_ids: Vec<String>,
8694
}
@@ -89,6 +97,64 @@ fn parse_space_id(input: &WorkspaceBindingInput) -> Result<Uuid, String> {
8997
Uuid::parse_str(&input.space_id).map_err(|e| format!("bad space_id: {e}"))
9098
}
9199

100+
/// Resolve label from input, preserving existing on update when omitted.
101+
fn resolve_binding_label(
102+
input: &WorkspaceBindingInput,
103+
existing: Option<&WorkspaceBinding>,
104+
) -> Option<String> {
105+
if input.label.is_some() {
106+
normalize_optional_metadata(&input.label)
107+
} else {
108+
existing.and_then(|b| b.label.clone())
109+
}
110+
}
111+
112+
/// Resolve icon from input, existing row, or unmapped appearance fallback.
113+
async fn resolve_binding_icon(
114+
state: &AppState,
115+
normalized_root: &str,
116+
input: &WorkspaceBindingInput,
117+
existing: Option<&WorkspaceBinding>,
118+
) -> Result<Option<String>, String> {
119+
let mut icon = if input.icon.is_some() {
120+
normalize_optional_metadata(&input.icon)
121+
} else {
122+
existing.and_then(|b| b.icon.clone())
123+
};
124+
if icon.is_none() {
125+
if let Some(appearance) = state
126+
.workspace_appearance_repository
127+
.get(normalized_root)
128+
.await
129+
.map_err(|e| e.to_string())?
130+
{
131+
icon = Some(appearance.icon);
132+
}
133+
}
134+
Ok(icon)
135+
}
136+
137+
/// Drop appearance rows once a binding owns the root.
138+
async fn clear_appearance_for_bound_root(
139+
state: &AppState,
140+
normalized_root: &str,
141+
) -> Result<(), String> {
142+
if state
143+
.workspace_appearance_repository
144+
.get(normalized_root)
145+
.await
146+
.map_err(|e| e.to_string())?
147+
.is_some()
148+
{
149+
state
150+
.workspace_appearance_repository
151+
.delete(normalized_root)
152+
.await
153+
.map_err(|e| e.to_string())?;
154+
}
155+
Ok(())
156+
}
157+
92158
/// Clean + dedup the feature-set list (preserving order). An empty result is
93159
/// valid — it persists as a "no Space tools" binding.
94160
fn validate_fs_list(input: &WorkspaceBindingInput) -> Result<Vec<String>, String> {
@@ -255,14 +321,26 @@ pub async fn create_workspace_binding(
255321
));
256322
}
257323

258-
let binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids);
324+
let binding = WorkspaceBinding {
325+
id: Uuid::new_v4(),
326+
workspace_root: normalized.clone(),
327+
client_id: None,
328+
label: resolve_binding_label(&input, None),
329+
icon: resolve_binding_icon(&state, &normalized, &input, None).await?,
330+
space_id,
331+
feature_set_ids,
332+
created_at: chrono::Utc::now(),
333+
updated_at: chrono::Utc::now(),
334+
};
259335

260336
state
261337
.workspace_binding_repository
262338
.create(&binding)
263339
.await
264340
.map_err(|e| e.to_string())?;
265341

342+
clear_appearance_for_bound_root(&state, &normalized).await?;
343+
266344
info!(
267345
binding_id = %binding.id,
268346
root = %binding.workspace_root,
@@ -318,12 +396,17 @@ pub async fn update_workspace_binding(
318396
.map_err(|e| e.to_string())?
319397
.ok_or_else(|| format!("binding not found: {}", id))?;
320398
let old_space_id = existing.space_id;
399+
let previous_icon = existing.icon.clone();
400+
let client_id = existing.client_id.clone();
401+
let label = resolve_binding_label(&input, Some(&existing));
402+
let icon = resolve_binding_icon(&state, &normalized, &input, Some(&existing)).await?;
321403

322404
let updated = WorkspaceBinding {
323405
id: existing.id,
324-
workspace_root: normalized,
325-
client_id: existing.client_id,
326-
label: existing.label,
406+
workspace_root: normalized.clone(),
407+
client_id,
408+
label,
409+
icon,
327410
space_id,
328411
feature_set_ids,
329412
created_at: existing.created_at,
@@ -336,6 +419,12 @@ pub async fn update_workspace_binding(
336419
.await
337420
.map_err(|e| e.to_string())?;
338421

422+
clear_appearance_for_bound_root(&state, &normalized).await?;
423+
424+
if previous_icon.as_deref() != updated.icon.as_deref() {
425+
maybe_remove_orphaned_icon_file(&state, previous_icon.as_deref()).await?;
426+
}
427+
339428
// Notify the NEW target space first (peers that now route via this
340429
// binding). If the space changed, also notify the OLD target so peers
341430
// that resolved there lose the stale route.
@@ -379,6 +468,7 @@ pub async fn delete_workspace_binding(
379468
.map_err(|e| e.to_string())?;
380469

381470
if let Some(b) = existing {
471+
maybe_remove_orphaned_icon_file(&state, b.icon.as_deref()).await?;
382472
emit_binding_changed(gateway_state.inner(), b.space_id, b.workspace_root).await;
383473
}
384474
Ok(())

apps/desktop/src-tauri/tauri.conf.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@
4444
}
4545
],
4646
"security": {
47-
"csp": null
47+
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost https: data: blob:",
48+
"devCsp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost http://localhost:* ws://localhost:* ws://127.0.0.1:*; img-src 'self' asset: http://asset.localhost https: http: data: blob:",
49+
"assetProtocol": {
50+
"enable": true,
51+
"scope": ["$APPDATA/**"]
52+
}
4853
}
4954
},
5055
"bundle": {

apps/desktop/src/components/ServerIcon.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* - null/undefined
88
*/
99

10-
import { useEffect, useMemo, useState } from 'react';
10+
import { useEffect, useMemo, useRef, useState } from 'react';
1111
import { resolveWorkspaceIconDisplaySrc } from '@/lib/api/workspaceAppearances';
1212

1313
interface ServerIconProps {
@@ -28,6 +28,7 @@ export function ServerIcon({ icon, className = 'w-9 h-9 object-contain', fallbac
2828
const [localResolved, setLocalResolved] = useState<{ icon: string; src: string | null } | null>(
2929
null
3030
);
31+
const blobUrlRef = useRef<string | null>(null);
3132
const hasFailed = icon != null && failedIcon === icon;
3233
const localSrc =
3334
localResolved != null && localResolved.icon === icon ? localResolved.src : null;
@@ -43,8 +44,18 @@ export function ServerIcon({ icon, className = 'w-9 h-9 object-contain', fallbac
4344
void resolveWorkspaceIconDisplaySrc(localIcon)
4445
.then((src) => {
4546
if (cancelled) {
47+
if (src?.startsWith('blob:')) {
48+
URL.revokeObjectURL(src);
49+
}
4650
return;
4751
}
52+
if (blobUrlRef.current) {
53+
URL.revokeObjectURL(blobUrlRef.current);
54+
blobUrlRef.current = null;
55+
}
56+
if (src?.startsWith('blob:')) {
57+
blobUrlRef.current = src;
58+
}
4859
setLocalResolved({ icon: localIcon, src });
4960
})
5061
.catch(() => {
@@ -56,6 +67,10 @@ export function ServerIcon({ icon, className = 'w-9 h-9 object-contain', fallbac
5667

5768
return () => {
5869
cancelled = true;
70+
if (blobUrlRef.current) {
71+
URL.revokeObjectURL(blobUrlRef.current);
72+
blobUrlRef.current = null;
73+
}
5974
};
6075
}, [icon, isLocalRef]);
6176

0 commit comments

Comments
 (0)