@@ -12,13 +12,17 @@ import {
1212 AlertCircle ,
1313 Check ,
1414 ChevronDown ,
15+ FolderOpen ,
1516 FolderSearch ,
1617 Loader2 ,
1718} from 'lucide-react' ;
1819import { Button } from '@mcpmux/ui' ;
1920import {
21+ type WorkspaceBinding ,
2022 type WorkspaceBindingInput ,
2123} from '@/lib/api/workspaceBindings' ;
24+ import { uploadWorkspaceIcon } from '@/lib/api/workspaceAppearances' ;
25+ import { ServerIcon } from '@/components/ServerIcon' ;
2226import { isStarterFeatureSet , type FeatureSet } from '@/lib/api/featureSets' ;
2327import { createMachine , getHostname , type Machine } from '@/lib/api/machines' ;
2428import type { Space } from '@/lib/api/spaces' ;
@@ -93,7 +97,21 @@ export type RootValidationState =
9397 | { state : 'idle' }
9498 | { state : 'checking' }
9599 | { state : 'ok' ; normalized : string }
96- | { state : 'error' ; reason : string } ;
100+ | { state : 'error' ; reason : string ; duplicate ?: boolean } ;
101+
102+ /** True when two bindings would collide on the partial unique indexes. */
103+ export function bindingScopeConflicts (
104+ existing : WorkspaceBinding ,
105+ root : string ,
106+ machineId : string | null ,
107+ clientId : string | null | undefined ,
108+ ) : boolean {
109+ if ( existing . workspace_root !== root ) return false ;
110+ return (
111+ ( existing . machine_id ?? null ) === machineId &&
112+ ( existing . client_id ?? null ) === ( clientId ?? null )
113+ ) ;
114+ }
97115
98116/** Map empty machine picker value to null for API payloads. */
99117export function bindingMachineId ( value : string ) : string | null {
@@ -152,6 +170,7 @@ export function RoutingFields({
152170 setSpaceId,
153171 fsIds,
154172 setFsIds,
173+ spacePickerDisabled = false ,
155174 t,
156175} : {
157176 spaces : Space [ ] ;
@@ -160,6 +179,7 @@ export function RoutingFields({
160179 setSpaceId : ( value : string ) => void ;
161180 fsIds : string [ ] ;
162181 setFsIds : ( value : string [ ] | ( ( prev : string [ ] ) => string [ ] ) ) => void ;
182+ spacePickerDisabled ?: boolean ;
163183 t : TFunction < [ 'workspaces' , 'common' ] > ;
164184} ) {
165185 const [ fsSearch , setFsSearch ] = useState ( '' ) ;
@@ -201,17 +221,21 @@ export function RoutingFields({
201221
202222 return (
203223 < div className = "space-y-5" >
204- < FormField label = { t ( 'form.space' ) } hint = { t ( 'form.spaceHint' ) } >
224+ < FormField
225+ label = { t ( 'form.space' ) }
226+ hint = { spacePickerDisabled ? t ( 'form.spaceLockedHint' ) : t ( 'form.spaceHint' ) }
227+ >
205228 < Picker
206229 value = { spaceId }
207230 onChange = { setSpaceId }
208231 placeholder = { t ( 'form.pickSpace' ) }
232+ disabled = { spacePickerDisabled }
209233 options = { spaces . map ( ( s ) => ( {
210234 value : s . id ,
211235 label : s . is_default ? `${ s . name } ${ t ( 'form.defaultSuffix' ) } ` : s . name ,
212236 icon : s . icon ?? undefined ,
213237 } ) ) }
214- testId = "workspace-binding-space"
238+ testId = "workspace-binding-space-picker "
215239 />
216240 </ FormField >
217241
@@ -327,52 +351,51 @@ export function RoutingFields({
327351}
328352
329353/**
330- * Machine scope and workspace root fields for workspace bindings.
354+ * Machine scope, workspace root, and advanced icon fields for workspace bindings.
331355 */
332356export function ScopeFields ( {
333357 mode,
334358 machines,
335- localMachineId,
336359 machineId,
337360 setMachineId,
361+ machineIds,
362+ setMachineIds,
363+ icon,
364+ setIcon,
365+ onPersistIcon,
338366 root,
339367 setRoot,
340368 rootValidation,
341369 rootEditable,
342- canSubmit,
343- submitting,
344- onFormSubmit,
345- onCancel,
346370 onError,
347371 t,
348372} : {
349373 mode : 'create' | 'edit' | 'create-from-live' ;
350374 machines : Machine [ ] ;
351- localMachineId : string | null ;
352375 machineId : string ;
353376 setMachineId : ( value : string ) => void ;
377+ machineIds : string [ ] ;
378+ setMachineIds : ( value : string [ ] | ( ( prev : string [ ] ) => string [ ] ) ) => void ;
379+ icon : string ;
380+ setIcon : ( value : string ) => void ;
381+ onPersistIcon ?: ( nextIcon : string ) => Promise < void > ;
354382 root : string ;
355383 setRoot : ( value : string ) => void ;
356384 rootValidation : RootValidationState ;
357385 rootEditable : boolean ;
358- canSubmit : boolean ;
359- submitting : boolean ;
360- onFormSubmit : ( machineTargets : ( string | null ) [ ] ) => Promise < void > ;
361- onCancel : ( ) => void ;
362386 onError : ( message : string ) => void ;
363387 t : TFunction < [ 'workspaces' , 'common' ] > ;
364388} ) {
365389 const rootRef = useRef < HTMLInputElement | null > ( null ) ;
366- const [ machineIds , setMachineIds ] = useState < string [ ] > ( ( ) =>
367- mode === 'edit' ? [ ] : localMachineId ? [ localMachineId ] : [ ] ,
368- ) ;
390+ const [ iconFilePath , setIconFilePath ] = useState ( '' ) ;
369391 const [ localMachines , setLocalMachines ] = useState < Machine [ ] > ( machines ) ;
370392 const [ showNewMachine , setShowNewMachine ] = useState ( false ) ;
371393 const [ newMachineName , setNewMachineName ] = useState ( '' ) ;
372394 const [ newMachineIcon , setNewMachineIcon ] = useState ( '' ) ;
373395 const [ newMachineHostname , setNewMachineHostname ] = useState ( '' ) ;
374396 const [ creatingMachine , setCreatingMachine ] = useState ( false ) ;
375397 const isEdit = mode === 'edit' ;
398+ const trimmedIcon = icon . trim ( ) ;
376399
377400 useEffect ( ( ) => {
378401 setLocalMachines ( machines ) ;
@@ -386,6 +409,15 @@ export function ScopeFields({
386409 setMachineIds ( ( prev ) => ( prev . includes ( id ) ? prev . filter ( ( x ) => x !== id ) : [ ...prev , id ] ) ) ;
387410 } ;
388411
412+ /**
413+ * Persist icon immediately after upload so the card updates without waiting for autosave.
414+ */
415+ const persistIconNow = async ( nextIcon : string ) => {
416+ if ( onPersistIcon ) {
417+ await onPersistIcon ( nextIcon ) ;
418+ }
419+ } ;
420+
389421 const machineOptions = useMemo (
390422 ( ) => localMachines . map ( ( m ) => ( { value : m . id , label : m . name , icon : m . icon ?? undefined } ) ) ,
391423 [ localMachines ] ,
@@ -435,11 +467,112 @@ export function ScopeFields({
435467 }
436468 } ;
437469
438- const submitLabel =
439- mode === 'create-from-live' ? t ( 'form.saveBinding' ) : t ( 'form.createBinding' ) ;
440-
441470 return (
442471 < div className = "space-y-5" >
472+ < FormField label = { t ( 'form.icon' ) } hint = { t ( 'form.iconHint' ) } >
473+ < div className = "space-y-2.5" >
474+ < div className = "flex items-start gap-3" >
475+ < div className = "w-14 h-14 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--background))] flex items-center justify-center flex-shrink-0" >
476+ { trimmedIcon ? (
477+ < ServerIcon icon = { trimmedIcon } className = "h-9 w-9 object-contain" fallback = "📁" />
478+ ) : (
479+ < FolderOpen className = "h-6 w-6 text-[rgb(var(--muted))]" />
480+ ) }
481+ </ div >
482+ < div className = "flex-1 min-w-0 space-y-2" >
483+ < input
484+ type = "text"
485+ value = { icon }
486+ onChange = { ( e ) => {
487+ const next = e . target . value ;
488+ setIcon ( next ) ;
489+ } }
490+ placeholder = { t ( 'form.iconPlaceholder' ) }
491+ className = "w-full h-10 px-3 rounded-lg text-sm bg-[rgb(var(--background))] border border-[rgb(var(--border))] focus:outline-none focus:ring-2 focus:ring-primary-500"
492+ data-testid = "workspace-binding-icon-input"
493+ />
494+ < div className = "flex items-center gap-2 flex-wrap" >
495+ { isTauri ( ) ? (
496+ < Button
497+ variant = "secondary"
498+ size = "sm"
499+ onClick = { async ( ) => {
500+ try {
501+ const picked = await pickPath ( {
502+ directory : false ,
503+ multiple : false ,
504+ title : t ( 'form.pickIconTitle' ) ,
505+ filters : [
506+ {
507+ name : t ( 'form.imagesFilter' ) ,
508+ extensions : [ 'png' , 'jpg' , 'jpeg' , 'webp' , 'gif' ] ,
509+ } ,
510+ ] ,
511+ } ) ;
512+ if ( typeof picked !== 'string' || picked . length === 0 ) return ;
513+ const localRef = await uploadWorkspaceIcon ( picked ) ;
514+ setIcon ( localRef ) ;
515+ await persistIconNow ( localRef ) ;
516+ } catch ( e ) {
517+ onError ( e instanceof Error ? e . message : String ( e ) ) ;
518+ }
519+ } }
520+ data-testid = "workspace-binding-icon-upload"
521+ >
522+ { t ( 'form.upload' ) }
523+ </ Button >
524+ ) : (
525+ < >
526+ < input
527+ type = "text"
528+ value = { iconFilePath }
529+ onChange = { ( e ) => setIconFilePath ( e . target . value ) }
530+ placeholder = "Enter absolute path"
531+ className = "min-w-0 flex-1 px-3 py-2 rounded-lg text-sm bg-[rgb(var(--background))] border border-[rgb(var(--border))] focus:outline-none focus:ring-2 focus:ring-primary-500"
532+ data-testid = "workspace-binding-icon-path-input"
533+ />
534+ < Button
535+ variant = "secondary"
536+ size = "sm"
537+ disabled = { ! iconFilePath . trim ( ) }
538+ onClick = { async ( ) => {
539+ const picked = iconFilePath . trim ( ) ;
540+ if ( ! picked ) return ;
541+ try {
542+ const localRef = await uploadWorkspaceIcon ( picked ) ;
543+ setIcon ( localRef ) ;
544+ await persistIconNow ( localRef ) ;
545+ setIconFilePath ( '' ) ;
546+ } catch ( e ) {
547+ onError ( e instanceof Error ? e . message : String ( e ) ) ;
548+ }
549+ } }
550+ data-testid = "workspace-binding-icon-upload"
551+ >
552+ { t ( 'form.upload' ) }
553+ </ Button >
554+ </ >
555+ ) }
556+ < Button
557+ variant = "ghost"
558+ size = "sm"
559+ onClick = { ( ) => {
560+ setIcon ( '' ) ;
561+ void persistIconNow ( '' ) . catch ( ( e ) =>
562+ onError ( e instanceof Error ? e . message : String ( e ) ) ,
563+ ) ;
564+ } }
565+ disabled = { ! trimmedIcon }
566+ data-testid = "workspace-binding-icon-clear"
567+ >
568+ { t ( 'form.clear' ) }
569+ </ Button >
570+ </ div >
571+ </ div >
572+ </ div >
573+ </ div >
574+ </ FormField >
575+
443576 < FormField label = { t ( 'form.machine' ) } hint = { t ( 'form.machineHint' ) } >
444577 { isEdit ? (
445578 < div className = "space-y-2" >
@@ -643,32 +776,6 @@ export function ScopeFields({
643776 </ div >
644777 < RootValidationHint state = { rootValidation } editable = { rootEditable } originalValue = { root } t = { t } />
645778 </ FormField >
646-
647- { ! isEdit && (
648- < div className = "flex items-center gap-2 pt-1" >
649- < Button
650- variant = "primary"
651- size = "md"
652- onClick = { ( ) => {
653- const targets = machineIds . length > 0 ? machineIds : [ null as string | null ] ;
654- void onFormSubmit ( targets ) ;
655- } }
656- disabled = { ! canSubmit }
657- className = "flex-1"
658- data-testid = "workspace-binding-submit"
659- >
660- { submitting ? (
661- < Loader2 className = "h-4 w-4 animate-spin mr-1.5" />
662- ) : (
663- < Check className = "h-4 w-4 mr-1.5" />
664- ) }
665- { submitLabel }
666- </ Button >
667- < Button variant = "secondary" size = "md" onClick = { onCancel } disabled = { submitting } >
668- { t ( 'common:actions.cancel' ) }
669- </ Button >
670- </ div >
671- ) }
672779 </ div >
673780 ) ;
674781}
@@ -680,11 +787,7 @@ function RootValidationHint({
680787 originalValue,
681788 t,
682789} : {
683- state :
684- | { state : 'idle' }
685- | { state : 'checking' }
686- | { state : 'ok' ; normalized : string }
687- | { state : 'error' ; reason : string } ;
790+ state : RootValidationState ;
688791 editable : boolean ;
689792 originalValue : string ;
690793 t : TFunction < [ 'workspaces' , 'common' ] > ;
@@ -711,7 +814,12 @@ function RootValidationHint({
711814 }
712815 if ( state . state === 'error' ) {
713816 return (
714- < p className = "mt-1.5 text-[11px] text-red-600 dark:text-red-400" > { state . reason } </ p >
817+ < p
818+ className = "mt-1.5 text-[11px] text-red-600 dark:text-red-400"
819+ data-testid = { state . duplicate ? 'workspace-binding-duplicate-error' : undefined }
820+ >
821+ { state . reason }
822+ </ p >
715823 ) ;
716824 }
717825 const changed = state . normalized !== originalValue . trim ( ) ;
0 commit comments