@@ -84,7 +84,7 @@ import {
8484} from '@/stores' ;
8585import type { Space } from '@/lib/api/spaces' ;
8686import { FormField } from './workspace-binding-form.component' ;
87- import { formatFsList } from './workspace-binding-form.helpers' ;
87+ import { formatFsList , projectKey } from './workspace-binding-form.helpers' ;
8888import { EmojiPickerButton } from '@/components/emoji-picker-button.component' ;
8989import { useViewerIdentity } from '@/hooks/use-viewer-identity.hook' ;
9090
@@ -110,12 +110,60 @@ interface Entry {
110110 id : string ;
111111 kind : EntryKind ;
112112 root : string ;
113+ /** Every distinct root folded into this card (1 unless project-linked). */
114+ roots : string [ ] ;
113115 bindings : WorkspaceBinding [ ] ;
114116 isLive : boolean ;
115117 /** Id-type bindings route by OAuth/API client id, not folder path. */
116118 isClientMapping ?: boolean ;
117119}
118120
121+ const ENTRY_KIND_RANK : Record < EntryKind , number > = {
122+ 'unmapped-live' : 0 ,
123+ 'live-elsewhere' : 1 ,
124+ 'mapped-live' : 2 ,
125+ 'mapped-offline' : 3 ,
126+ } ;
127+
128+ /**
129+ * Fold root-level entries that share a project key into one card.
130+ */
131+ function mergeByProjectKey ( rootEntries : Entry [ ] ) : Entry [ ] {
132+ const byKey = new Map < string , Entry [ ] > ( ) ;
133+ const standalone : Entry [ ] = [ ] ;
134+ for ( const entry of rootEntries ) {
135+ const key = entry . bindings . map ( projectKey ) . find ( ( k ) => k != null ) ?? null ;
136+ if ( key == null ) {
137+ standalone . push ( { ...entry , roots : entry . roots . length > 0 ? entry . roots : [ entry . root ] } ) ;
138+ continue ;
139+ }
140+ const group = byKey . get ( key ) ?? [ ] ;
141+ group . push ( entry ) ;
142+ byKey . set ( key , group ) ;
143+ }
144+ const merged = [ ...byKey . values ( ) ] . map ( ( group ) => {
145+ if ( group . length === 1 ) {
146+ const only = group [ 0 ] ;
147+ return { ...only , roots : only . roots . length > 0 ? only . roots : [ only . root ] } ;
148+ }
149+ const primary =
150+ group . find ( ( e ) => e . bindings . some ( ( b ) => b . machine_id == null ) ) ?? group [ 0 ] ;
151+ const roots = [ ...new Set ( group . flatMap ( ( e ) => ( e . roots . length > 0 ? e . roots : [ e . root ] ) ) ) ] ;
152+ const kinds = group . map ( ( e ) => e . kind ) ;
153+ const kind = kinds . reduce ( ( best , next ) =>
154+ ENTRY_KIND_RANK [ next ] < ENTRY_KIND_RANK [ best ] ? next : best ,
155+ ) ;
156+ return {
157+ ...primary ,
158+ roots,
159+ bindings : group . flatMap ( ( e ) => e . bindings ) ,
160+ isLive : group . some ( ( e ) => e . isLive ) ,
161+ kind,
162+ } ;
163+ } ) ;
164+ return [ ...standalone , ...merged ] ;
165+ }
166+
119167/**
120168 * Canonical binding for an entry — global (`machine_id IS NULL`) first,
121169 * else first machine-scoped binding.
@@ -278,13 +326,15 @@ export function WorkspacesPage() {
278326 id : '' ,
279327 kind : 'unmapped-live' ,
280328 root,
329+ roots : [ root ] ,
281330 bindings : binds ,
282331 isLive : true ,
283332 } ) ;
284333 const entry : Entry = {
285334 id : primary ?. id ?? `live:${ root } ` ,
286335 kind : 'unmapped-live' ,
287336 root,
337+ roots : [ root ] ,
288338 bindings : binds ,
289339 isLive : true ,
290340 } ;
@@ -305,13 +355,15 @@ export function WorkspacesPage() {
305355 id : '' ,
306356 kind : 'mapped-offline' ,
307357 root : b . workspace_root ,
358+ roots : [ b . workspace_root ] ,
308359 bindings : binds ,
309360 isLive : false ,
310361 } ) ;
311362 list . push ( {
312363 id : primary ! . id ,
313364 kind : 'mapped-offline' ,
314365 root : b . workspace_root ,
366+ roots : [ b . workspace_root ] ,
315367 bindings : binds ,
316368 isLive : false ,
317369 } ) ;
@@ -325,19 +377,14 @@ export function WorkspacesPage() {
325377 id : b . id ,
326378 kind : 'mapped-offline' ,
327379 root : b . workspace_root ,
380+ roots : [ b . workspace_root ] ,
328381 bindings : [ b ] ,
329382 isLive : false ,
330383 isClientMapping : true ,
331384 } ) ;
332385 }
333- const rank : Record < EntryKind , number > = {
334- 'unmapped-live' : 0 ,
335- 'live-elsewhere' : 1 ,
336- 'mapped-live' : 2 ,
337- 'mapped-offline' : 3 ,
338- } ;
339- return list . sort ( ( a , b ) => {
340- const o = rank [ a . kind ] - rank [ b . kind ] ;
386+ return mergeByProjectKey ( list ) . sort ( ( a , b ) => {
387+ const o = ENTRY_KIND_RANK [ a . kind ] - ENTRY_KIND_RANK [ b . kind ] ;
341388 return o !== 0 ? o : a . root . localeCompare ( b . root ) ;
342389 } ) ;
343390 } , [ bindings , bindingsByRoot , reportedRoots , localMachineId , viewerMachineId ] ) ;
@@ -377,6 +424,7 @@ export function WorkspacesPage() {
377424 const label = binding ?. label ?. toLowerCase ( ) ?? '' ;
378425 return (
379426 e . root . toLowerCase ( ) . includes ( q ) ||
427+ e . roots . some ( ( root ) => root . toLowerCase ( ) . includes ( q ) ) ||
380428 label . includes ( q ) ||
381429 spaceName . toLowerCase ( ) . includes ( q ) ||
382430 fsNames . toLowerCase ( ) . includes ( q )
@@ -786,6 +834,8 @@ interface EntryCardRoutingRow {
786834 ghost ?: boolean ;
787835 machine ?: Machine ;
788836 machineLabel : string ;
837+ /** Shown under the machine when the card spans more than one path. */
838+ root ?: string ;
789839 fsName : string ;
790840 spaceName : string | undefined ;
791841 clickable : boolean ;
@@ -868,12 +918,19 @@ function EntryCardRoutingTable({
868918 ] . join ( ' ' ) }
869919 { ...rowProps }
870920 >
871- < span className = { `${ cellCls } truncate whitespace-nowrap` } title = { row . machineLabel } >
872- < span className = "inline-flex max-w-full items-center gap-1" >
873- { row . machine ?. icon ? (
874- < span className = "shrink-0 text-[11px] leading-none" > { row . machine . icon } </ span >
921+ < span className = { `${ cellCls } whitespace-nowrap` } title = { row . root ?? row . machineLabel } >
922+ < span className = "inline-flex max-w-full flex-col" >
923+ < span className = "inline-flex max-w-full items-center gap-1" >
924+ { row . machine ?. icon ? (
925+ < span className = "shrink-0 text-[11px] leading-none" > { row . machine . icon } </ span >
926+ ) : null }
927+ < span className = "truncate" > { row . machineLabel } </ span >
928+ </ span >
929+ { row . root ? (
930+ < span className = "truncate font-mono text-[10px] text-[rgb(var(--muted))]" >
931+ { row . root }
932+ </ span >
875933 ) : null }
876- < span className = "truncate" > { row . machineLabel } </ span >
877934 </ span >
878935 </ span >
879936 < span
@@ -911,6 +968,7 @@ function buildEntryRoutingRows(
911968 fsById : Map < string , FeatureSet > ,
912969 t : TFunction < [ 'workspaces' , 'common' ] > ,
913970) : EntryCardRoutingRow [ ] {
971+ const showPath = entry . roots . length > 1 ;
914972 const rows : EntryCardRoutingRow [ ] = bindings . map ( ( rowBinding ) => {
915973 const rowMachine = rowBinding . machine_id
916974 ? machinesById . get ( rowBinding . machine_id )
@@ -920,6 +978,7 @@ function buildEntryRoutingRows(
920978 bindingId : rowBinding . id ,
921979 machine : rowMachine ,
922980 machineLabel : machineBindingLabel ( rowBinding , machinesById , t ) ,
981+ root : showPath ? rowBinding . workspace_root : undefined ,
923982 fsName : formatFsList (
924983 rowBinding . feature_set_ids . map ( ( id ) => fsById . get ( id ) ?. name ?? id ) ,
925984 ) ,
@@ -1101,12 +1160,24 @@ function EntryCard({
11011160 </ p >
11021161 < p
11031162 className = { `mt-0.5 line-clamp-2 min-h-[2rem] font-mono text-xs leading-snug text-[rgb(var(--muted))] ${
1104- hasLabel || entry . isClientMapping ? 'break-all' : 'invisible'
1163+ hasLabel || entry . isClientMapping || entry . roots . length > 1
1164+ ? 'break-all'
1165+ : 'invisible'
11051166 } `}
1106- title = { hasLabel || entry . isClientMapping ? entry . root : undefined }
1107- aria-hidden = { ! hasLabel && ! entry . isClientMapping }
1167+ title = {
1168+ entry . roots . length > 1
1169+ ? entry . roots . join ( ', ' )
1170+ : hasLabel || entry . isClientMapping
1171+ ? entry . root
1172+ : undefined
1173+ }
1174+ aria-hidden = { ! hasLabel && ! entry . isClientMapping && entry . roots . length <= 1 }
11081175 >
1109- { hasLabel || entry . isClientMapping ? entry . root : '\u00A0' }
1176+ { entry . roots . length > 1
1177+ ? t ( 'card.locations' , { count : entry . roots . length } )
1178+ : hasLabel || entry . isClientMapping
1179+ ? entry . root
1180+ : '\u00A0' }
11101181 </ p >
11111182 { entry . kind === 'unmapped-live' && (
11121183 < Button
0 commit comments