@@ -8,8 +8,8 @@ use std::collections::{HashMap, HashSet};
88use std:: sync:: Arc ;
99
1010use mcpmux_core:: {
11- normalize_optional_metadata, validate_workspace_root as validate_root, DomainEvent , FeatureSet ,
12- FeatureSetType , MemberMode , MemberType , ServerFeature , WorkspaceBinding ,
11+ normalize_optional_metadata, validate_workspace_root as validate_root, BindingType , DomainEvent ,
12+ FeatureSet , FeatureSetType , MemberMode , MemberType , ServerFeature , WorkspaceBinding ,
1313 WorkspaceRootValidation ,
1414} ;
1515use serde:: { Deserialize , Serialize } ;
@@ -61,6 +61,8 @@ pub struct WorkspaceBindingDto {
6161 pub space_id : String ,
6262 pub feature_set_ids : Vec < String > ,
6363 pub machine_id : Option < String > ,
64+ #[ serde( default ) ]
65+ pub binding_type : Option < String > ,
6466 pub created_at : String ,
6567 pub updated_at : String ,
6668}
@@ -75,6 +77,7 @@ impl From<WorkspaceBinding> for WorkspaceBindingDto {
7577 space_id : b. space_id . to_string ( ) ,
7678 feature_set_ids : b. feature_set_ids ,
7779 machine_id : b. machine_id . map ( |id| id. to_string ( ) ) ,
80+ binding_type : Some ( b. binding_type . as_db_str ( ) . to_string ( ) ) ,
7881 created_at : b. created_at . to_rfc3339 ( ) ,
7982 updated_at : b. updated_at . to_rfc3339 ( ) ,
8083 }
@@ -95,6 +98,8 @@ pub struct WorkspaceBindingInput {
9598 pub feature_set_ids : Vec < String > ,
9699 #[ serde( default ) ]
97100 pub machine_id : Option < String > ,
101+ #[ serde( default ) ]
102+ pub binding_type : Option < String > ,
98103}
99104
100105fn parse_optional_machine_id ( value : Option < & str > ) -> Result < Option < Uuid > , String > {
@@ -119,6 +124,13 @@ fn binding_scope_conflicts(
119124 existing. machine_id == machine_id && existing. client_id . as_deref ( ) == client_id
120125}
121126
127+ fn parse_binding_type ( value : Option < & str > ) -> BindingType {
128+ match value {
129+ Some ( "id" ) => BindingType :: Id ,
130+ _ => BindingType :: Path ,
131+ }
132+ }
133+
122134fn parse_space_id ( input : & WorkspaceBindingInput ) -> Result < Uuid , String > {
123135 Uuid :: parse_str ( & input. space_id ) . map_err ( |e| format ! ( "bad space_id: {e}" ) )
124136}
@@ -358,51 +370,74 @@ pub async fn create_workspace_binding(
358370) -> Result < WorkspaceBindingDto , String > {
359371 let space_id = parse_space_id ( & input) ?;
360372 let feature_set_ids = validate_fs_list ( & input) ?;
361- let normalized = normalize_and_validate ( & input. workspace_root ) ?;
373+ let binding_type = parse_binding_type ( input. binding_type . as_deref ( ) ) ;
374+ let machine_id = parse_optional_machine_id ( input. machine_id . as_deref ( ) ) ?;
375+
376+ let ( normalized, binding) = if binding_type == BindingType :: Id {
377+ let client_key = input. workspace_root . trim ( ) ;
378+ if client_key. is_empty ( ) {
379+ return Err ( "client id cannot be empty" . into ( ) ) ;
380+ }
381+ let mut binding = WorkspaceBinding :: new_id_multi ( client_key, space_id, feature_set_ids) ;
382+ binding. machine_id = machine_id;
383+ binding. label = resolve_binding_label ( & input, None ) ;
384+ binding. icon = normalize_optional_metadata ( & input. icon ) ;
385+ ( client_key. to_string ( ) , binding)
386+ } else {
387+ let normalized = normalize_and_validate ( & input. workspace_root ) ?;
388+ let binding = WorkspaceBinding {
389+ id : Uuid :: new_v4 ( ) ,
390+ workspace_root : normalized. clone ( ) ,
391+ binding_type : BindingType :: Path ,
392+ client_id : None ,
393+ machine_id,
394+ label : resolve_binding_label ( & input, None ) ,
395+ icon : resolve_binding_icon ( & state, & normalized, & input, None ) . await ?,
396+ space_id,
397+ feature_set_ids,
398+ created_at : chrono:: Utc :: now ( ) ,
399+ updated_at : chrono:: Utc :: now ( ) ,
400+ } ;
401+ ( normalized, binding)
402+ } ;
362403
363404 // Reject a duplicate folder up front with a readable message. The schema
364405 // already enforces `UNIQUE(workspace_root)`, but that surfaces an opaque
365406 // SQLite constraint error — this gives the UI something a user can act on.
366- let machine_id = parse_optional_machine_id ( input. machine_id . as_deref ( ) ) ?;
367407
368408 let existing = state
369409 . workspace_binding_repository
370410 . list ( )
371411 . await
372412 . map_err ( |e| e. to_string ( ) ) ?;
373- if existing
374- . iter ( )
375- . any ( |b| binding_scope_conflicts ( b, & normalized, machine_id, None ) )
376- {
413+ if existing. iter ( ) . any ( |b| {
414+ b. binding_type == binding_type
415+ && binding_scope_conflicts ( b, & normalized, machine_id, None )
416+ } ) {
417+ let noun = if binding_type == BindingType :: Id {
418+ "client mapping"
419+ } else {
420+ "mapping"
421+ } ;
377422 return Err ( format ! (
378- "A mapping already exists for {normalized}. Edit the existing mapping instead of adding a second one."
423+ "A {noun} already exists for {normalized}. Edit the existing mapping instead of adding a second one."
379424 ) ) ;
380425 }
381426
382- let binding = WorkspaceBinding {
383- id : Uuid :: new_v4 ( ) ,
384- workspace_root : normalized. clone ( ) ,
385- client_id : None ,
386- machine_id,
387- label : resolve_binding_label ( & input, None ) ,
388- icon : resolve_binding_icon ( & state, & normalized, & input, None ) . await ?,
389- space_id,
390- feature_set_ids,
391- created_at : chrono:: Utc :: now ( ) ,
392- updated_at : chrono:: Utc :: now ( ) ,
393- } ;
394-
395427 state
396428 . workspace_binding_repository
397429 . create ( & binding)
398430 . await
399431 . map_err ( |e| e. to_string ( ) ) ?;
400432
401- clear_appearance_for_bound_root ( & state, & normalized) . await ?;
433+ if binding_type == BindingType :: Path {
434+ clear_appearance_for_bound_root ( & state, & normalized) . await ?;
435+ }
402436
403437 info ! (
404438 binding_id = %binding. id,
405439 root = %binding. workspace_root,
440+ ?binding_type,
406441 %space_id,
407442 feature_sets = ?binding. feature_set_ids,
408443 "[workspace_binding] created" ,
@@ -417,8 +452,6 @@ pub async fn create_workspace_binding(
417452 Ok ( binding. into ( ) )
418453}
419454
420- /// Update an existing binding. Accepts full input so the UI can edit any
421- /// axis (root, target space, target FS) in one call.
422455#[ tauri:: command]
423456pub async fn update_workspace_binding (
424457 id : String ,
@@ -429,9 +462,26 @@ pub async fn update_workspace_binding(
429462 let id_uuid = Uuid :: parse_str ( & id) . map_err ( |e| e. to_string ( ) ) ?;
430463 let space_id = parse_space_id ( & input) ?;
431464 let feature_set_ids = validate_fs_list ( & input) ?;
432- let normalized = normalize_and_validate ( & input. workspace_root ) ? ;
465+ let binding_type = parse_binding_type ( input. binding_type . as_deref ( ) ) ;
433466 let machine_id = parse_optional_machine_id ( input. machine_id . as_deref ( ) ) ?;
434467
468+ let existing = state
469+ . workspace_binding_repository
470+ . get ( & id_uuid)
471+ . await
472+ . map_err ( |e| e. to_string ( ) ) ?
473+ . ok_or_else ( || format ! ( "binding not found: {}" , id) ) ?;
474+
475+ let normalized = if binding_type == BindingType :: Id {
476+ let client_key = input. workspace_root . trim ( ) ;
477+ if client_key. is_empty ( ) {
478+ return Err ( "client id cannot be empty" . into ( ) ) ;
479+ }
480+ client_key. to_string ( )
481+ } else {
482+ normalize_and_validate ( & input. workspace_root ) ?
483+ } ;
484+
435485 // If the edit moved the folder onto a path another mapping already owns,
436486 // reject with a readable message rather than tripping the DB UNIQUE
437487 // constraint. Exclude this binding's own row.
@@ -440,30 +490,34 @@ pub async fn update_workspace_binding(
440490 . list ( )
441491 . await
442492 . map_err ( |e| e. to_string ( ) ) ?;
443- if all
444- . iter ( )
445- . any ( |b| b. id != id_uuid && binding_scope_conflicts ( b, & normalized, machine_id, None ) )
446- {
493+ if all. iter ( ) . any ( |b| {
494+ b. id != id_uuid
495+ && b. binding_type == binding_type
496+ && binding_scope_conflicts ( b, & normalized, machine_id, None )
497+ } ) {
447498 return Err ( format ! (
448499 "Another mapping already uses {normalized}. Pick a different folder."
449500 ) ) ;
450501 }
451502
452- let existing = state
453- . workspace_binding_repository
454- . get ( & id_uuid)
455- . await
456- . map_err ( |e| e. to_string ( ) ) ?
457- . ok_or_else ( || format ! ( "binding not found: {}" , id) ) ?;
458503 let old_space_id = existing. space_id ;
459504 let previous_icon = existing. icon . clone ( ) ;
460505 let client_id = existing. client_id . clone ( ) ;
461506 let label = resolve_binding_label ( & input, Some ( & existing) ) ;
462- let icon = resolve_binding_icon ( & state, & normalized, & input, Some ( & existing) ) . await ?;
507+ let icon = if binding_type == BindingType :: Id {
508+ if input. icon . is_some ( ) {
509+ normalize_optional_metadata ( & input. icon )
510+ } else {
511+ existing. icon . clone ( )
512+ }
513+ } else {
514+ resolve_binding_icon ( & state, & normalized, & input, Some ( & existing) ) . await ?
515+ } ;
463516
464517 let updated = WorkspaceBinding {
465518 id : existing. id ,
466519 workspace_root : normalized. clone ( ) ,
520+ binding_type,
467521 client_id,
468522 machine_id,
469523 label,
@@ -480,7 +534,9 @@ pub async fn update_workspace_binding(
480534 . await
481535 . map_err ( |e| e. to_string ( ) ) ?;
482536
483- clear_appearance_for_bound_root ( & state, & normalized) . await ?;
537+ if binding_type == BindingType :: Path {
538+ clear_appearance_for_bound_root ( & state, & normalized) . await ?;
539+ }
484540
485541 if previous_icon. as_deref ( ) != updated. icon . as_deref ( ) {
486542 maybe_remove_orphaned_icon_file ( & state, previous_icon. as_deref ( ) ) . await ?;
0 commit comments