Restore project grouping across hosts (#2565)

* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* fix(projects): keep ambiguous clones separate

A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
Mohamed Boudra
2026-07-29 23:36:31 +02:00
committed by GitHub
parent 9e5accee3c
commit 20e46e3e3f
113 changed files with 3073 additions and 2038 deletions
+117 -77
View File
@@ -1,5 +1,5 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { HostProjectListItem } from "@/projects/host-project-model";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
export interface WorkspaceSummary {
@@ -14,6 +14,9 @@ export interface WorkspaceSummary {
export interface ProjectHostEntry {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
repoRoot: string;
@@ -39,8 +42,7 @@ export interface ProjectHost {
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
/** Project parents with no active workspaces, so they still surface as projects. */
emptyProjects?: EmptyProjectDescriptor[];
projects: ProjectDescriptor[];
}
export interface BuildProjectsInput {
@@ -51,10 +53,35 @@ export interface BuildProjectsResult {
projects: ProjectSummary[];
}
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
export function getProjectSummaryForHostProject(
projects: readonly ProjectSummary[],
serverId: string,
projectId: string,
): ProjectSummary | undefined {
for (const project of projects) {
for (const host of project.hosts) {
if (host.serverId === serverId && host.projectId === projectId) return project;
}
}
return undefined;
}
export function getProjectHostEntry(
project: ProjectSummary | undefined,
serverId: string,
projectId?: string,
): ProjectHostEntry | undefined {
for (const host of project?.hosts ?? []) {
if (host.serverId === serverId && (!projectId || host.projectId === projectId)) return host;
}
return undefined;
}
interface HostGroup {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
@@ -70,50 +97,32 @@ interface ProjectGroup {
hostsByServerId: Map<string, HostGroup>;
}
function findProjectCustomName(
workspaces: WorkspaceDescriptor[],
function findProjectMetadata(
host: ProjectHost,
projectKey: string,
): { customName: string; displayName: string } | null {
for (const workspace of workspaces) {
if (workspace.projectId === projectKey && workspace.projectCustomName) {
): { customName: string | null; displayName: string } | null {
for (const project of host.projects) {
if (project.projectId === projectKey) {
return {
customName: workspace.projectCustomName,
displayName: workspace.projectDisplayName,
customName: project.projectCustomName ?? null,
displayName: project.projectDisplayName,
};
}
}
return null;
}
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
return buildHostProjectList({
projects: buildWorkspaceStructureProjects({
sessions: [
{
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
},
],
}),
function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
return buildWorkspaceStructureProjects({
sessions: hosts.map((host) => ({
serverId: host.serverId,
projects: host.projects,
workspaces: host.workspaces,
})),
});
}
function deriveGithubUrl(projectKey: string): string | undefined {
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
if (!match) {
return undefined;
}
return `https://github.com/${match[1]}/${match[2]}`;
}
function resolveHostRepoRoot(group: HostGroup): string {
for (const workspace of group.workspaces) {
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
if (mainRepoRoot) {
return mainRepoRoot;
}
}
return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
}
@@ -137,6 +146,9 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
group.workspaces[0];
return {
serverId: group.serverId,
projectId: group.projectId,
projectName: group.projectName,
projectCustomName: group.projectCustomName,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
@@ -167,53 +179,81 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
githubUrl: deriveGithubUrl(draft.projectKey),
};
}
function addHostProjects(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const repoRootByProjectId = new Map(
host.projects.map((project) => [project.projectId, project.projectRootPath]),
);
for (const hostProject of hostProjects) {
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
const projectId = placement.projectId;
const customName = findProjectMetadata(host, projectId);
let group = groups.get(hostProject.projectKey);
if (!group) {
group = {
projectKey: hostProject.projectKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.projectKey, group);
} else if (customName?.customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
}
if (!group.hostsByServerId.has(host.serverId)) {
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
projectId,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
});
}
}
}
function attachHostWorkspaces(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const projectKeyByProjectId = new Map(
hostProjects.flatMap((project) =>
project.hosts
.filter((placement) => placement.serverId === host.serverId && placement.projectId)
.map((placement) => [placement.projectId!, project.projectKey] as const),
),
);
for (const workspace of host.workspaces) {
const key = projectKeyByProjectId.get(workspace.projectId);
if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
}
}
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
const groups = new Map<string, ProjectGroup>();
const projectEntries = buildHostProjectEntries(input.hosts);
for (const host of input.hosts) {
const emptyRepoRootByProjectKey = new Map<string, string>();
for (const emptyProject of host.emptyProjects ?? []) {
emptyRepoRootByProjectKey.set(emptyProject.projectId, emptyProject.projectRootPath);
}
const hostProjects = buildHostProjectEntries(host);
for (const hostProject of hostProjects) {
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
let group = groups.get(hostProject.projectKey);
if (!group) {
group = {
projectKey: hostProject.projectKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.projectKey, group);
} else if (customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
}
if (!group.hostsByServerId.has(host.serverId)) {
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "",
});
}
}
for (const workspace of host.workspaces) {
const group = groups.get(workspace.projectId);
const hostGroup = group?.hostsByServerId.get(host.serverId);
if (!hostGroup) continue;
hostGroup.workspaces.push(workspace);
}
const hostProjects = projectEntries.filter((project) =>
project.hosts.some((placement) => placement.serverId === host.serverId),
);
addHostProjects(groups, host, hostProjects);
attachHostWorkspaces(groups, host, hostProjects);
}
const projects = Array.from(groups.values()).map(toProjectSummary);