Skip to content

Commit fe783ba

Browse files
committed
feat(workspaces): group the same project across machines via git remote
Bindings created from different clone paths now share a card by default, with a panel override to force-link or isolate when origin is missing or wrong. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 1ae9099 commit fe783ba

20 files changed

Lines changed: 1015 additions & 36 deletions

File tree

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

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

1010
use mcpmux_core::{
11-
normalize_optional_metadata, validate_workspace_root as validate_root, BindingType,
12-
DomainEvent, FeatureSet, FeatureSetType, MemberMode, MemberType, ServerFeature,
13-
WorkspaceBinding, WorkspaceRootValidation,
11+
normalize_optional_metadata, resolve_persisted_override,
12+
validate_workspace_root as validate_root, BindingType, DomainEvent, FeatureSet, FeatureSetType,
13+
MemberMode, MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation,
1414
};
15+
use mcpmux_gateway::services::{apply_detected_git_remote, detect_origin_remote};
1516
use mcpmux_storage::InboundClientRepository;
1617
use serde::{Deserialize, Serialize};
1718
use tauri::State;
@@ -79,6 +80,10 @@ pub struct WorkspaceBindingDto {
7980
pub machine_id: Option<String>,
8081
#[serde(default)]
8182
pub binding_type: Option<String>,
83+
#[serde(default)]
84+
pub git_remote_url: Option<String>,
85+
#[serde(default)]
86+
pub project_link_id: Option<String>,
8287
pub created_at: String,
8388
pub updated_at: String,
8489
}
@@ -94,6 +99,8 @@ impl From<WorkspaceBinding> for WorkspaceBindingDto {
9499
feature_set_ids: b.feature_set_ids,
95100
machine_id: b.machine_id.map(|id| id.to_string()),
96101
binding_type: Some(b.binding_type.as_db_str().to_string()),
102+
git_remote_url: b.git_remote_url,
103+
project_link_id: b.project_link_id,
97104
created_at: b.created_at.to_rfc3339(),
98105
updated_at: b.updated_at.to_rfc3339(),
99106
}
@@ -116,6 +123,10 @@ pub struct WorkspaceBindingInput {
116123
pub machine_id: Option<String>,
117124
#[serde(default)]
118125
pub binding_type: Option<String>,
126+
#[serde(default)]
127+
pub git_remote_url: Option<String>,
128+
#[serde(default)]
129+
pub project_link_id: Option<String>,
119130
}
120131

121132
fn parse_optional_machine_id(value: Option<&str>) -> Result<Option<Uuid>, String> {
@@ -352,6 +363,18 @@ pub async fn list_workspace_bindings_for_space(
352363
.map_err(|e| e.to_string())
353364
}
354365

366+
/// Best-effort `origin` remote for a folder. `None` when the path isn't a
367+
/// git clone, git isn't on PATH, or detection times out.
368+
#[tauri::command]
369+
pub async fn detect_workspace_git_remote(path: String) -> Result<Option<String>, String> {
370+
let normalized = match validate_root(&path) {
371+
WorkspaceRootValidation::Empty => return Ok(None),
372+
WorkspaceRootValidation::Ok { normalized } => normalized,
373+
WorkspaceRootValidation::Invalid { reason } => return Err(reason),
374+
};
375+
Ok(detect_origin_remote(std::path::Path::new(&normalized)).await)
376+
}
377+
355378
/// Live path validation for the UI — returns `Ok(normalized)` or
356379
/// `Err(reason)`. Runs the same rules the create/update commands apply, so
357380
/// the form can show the real error message without round-tripping a save.
@@ -398,22 +421,28 @@ pub async fn create_workspace_binding(
398421
binding.machine_id = machine_id;
399422
binding.label = resolve_binding_label(&input, None);
400423
binding.icon = normalize_optional_metadata(&input.icon);
424+
binding.git_remote_url = resolve_persisted_override(input.git_remote_url.as_deref(), None);
425+
binding.project_link_id =
426+
resolve_persisted_override(input.project_link_id.as_deref(), None);
401427
(client_key.to_string(), binding)
402428
} else {
403429
let normalized = normalize_and_validate(&input.workspace_root)?;
404-
let binding = WorkspaceBinding {
430+
let mut binding = WorkspaceBinding {
405431
id: Uuid::new_v4(),
406432
workspace_root: normalized.clone(),
407433
binding_type: BindingType::Path,
408434
client_id: None,
409435
machine_id,
410436
label: resolve_binding_label(&input, None),
411437
icon: resolve_binding_icon(&state, &normalized, &input, None).await?,
438+
git_remote_url: resolve_persisted_override(input.git_remote_url.as_deref(), None),
439+
project_link_id: resolve_persisted_override(input.project_link_id.as_deref(), None),
412440
space_id,
413441
feature_set_ids,
414442
created_at: chrono::Utc::now(),
415443
updated_at: chrono::Utc::now(),
416444
};
445+
apply_detected_git_remote(&mut binding).await;
417446
(normalized, binding)
418447
};
419448

@@ -539,6 +568,14 @@ pub async fn update_workspace_binding(
539568
machine_id,
540569
label,
541570
icon,
571+
git_remote_url: resolve_persisted_override(
572+
input.git_remote_url.as_deref(),
573+
existing.git_remote_url.clone(),
574+
),
575+
project_link_id: resolve_persisted_override(
576+
input.project_link_id.as_deref(),
577+
existing.project_link_id.clone(),
578+
),
542579
space_id,
543580
feature_set_ids,
544581
created_at: existing.created_at,

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,7 @@ pub fn run() {
10311031
commands::is_workspace_binding_prompt_dismissed,
10321032
commands::delete_workspace_binding,
10331033
commands::validate_workspace_root,
1034+
commands::detect_workspace_git_remote,
10341035
commands::get_workspace_effective_features,
10351036
// Per-workspace MCP client config install (X-Mcpmux-Workspace header)
10361037
commands::list_workspace_install_clients,

apps/desktop/src/features/workspaces/WorkspacesPage.tsx

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ import {
8484
} from '@/stores';
8585
import type { Space } from '@/lib/api/spaces';
8686
import { FormField } from './workspace-binding-form.component';
87-
import { formatFsList } from './workspace-binding-form.helpers';
87+
import { formatFsList, projectKey } from './workspace-binding-form.helpers';
8888
import { EmojiPickerButton } from '@/components/emoji-picker-button.component';
8989
import { 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

apps/desktop/src/features/workspaces/workspace-binding-form.helpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ export function folderName(root: string): string {
4545
* Bindings on other machines (or scopes) that can seed a new create-from-live row.
4646
* Same folder name is enough; identical absolute paths count when machine differs.
4747
*/
48+
/**
49+
* Grouping key for cross-machine project cards. Manual link wins over git remote.
50+
*/
51+
export function projectKey(binding: WorkspaceBinding): string | null {
52+
return binding.project_link_id || binding.git_remote_url || null;
53+
}
54+
55+
/**
56+
* Path-type bindings the user can manually link to, excluding `excludeId`.
57+
*/
58+
export function findLinkableBindings(
59+
allBindings: WorkspaceBinding[],
60+
excludeId: string | undefined,
61+
): WorkspaceBinding[] {
62+
return allBindings.filter((binding) => {
63+
if (binding.id === excludeId) return false;
64+
return binding.binding_type !== 'id';
65+
});
66+
}
67+
4868
export function findAdoptableSiblingBindings(
4969
allBindings: WorkspaceBinding[],
5070
workspaceRoot: string,

0 commit comments

Comments
 (0)