@@ -8,8 +8,9 @@ use std::collections::{HashMap, HashSet};
88use std:: sync:: Arc ;
99
1010use 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} ;
1415use serde:: { Deserialize , Serialize } ;
1516use tauri:: State ;
@@ -19,6 +20,7 @@ use uuid::Uuid;
1920
2021use super :: gateway:: GatewayAppState ;
2122use super :: server_manager:: ServerManagerState ;
23+ use super :: workspace_appearance:: maybe_remove_orphaned_icon_file;
2224use crate :: state:: AppState ;
2325
2426/// Publish `WorkspaceBindingChanged` on the gateway's domain bus so
@@ -54,6 +56,8 @@ async fn emit_binding_changed(
5456pub 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 ) ]
8288pub 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.
94160fn 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 ( ( ) )
0 commit comments