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
+6 -14
View File
@@ -17,6 +17,7 @@ import {
encodeWorkspaceIdForPathSegment,
isSettingsSectionSlug,
normalizeHostSectionSlug,
normalizeProjectSettingsRouteId,
parseHostAgentRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
parseHostWorkspaceRouteFromPathname,
@@ -196,23 +197,14 @@ describe("projects settings routes", () => {
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
});
it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => {
expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe(
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
it("buildProjectSettingsRoute addresses a host-local project id", () => {
expect(buildProjectSettingsRoute("host a", "project/1")).toBe(
"/settings/projects/host%20a/project%2F1",
);
});
it("buildProjectSettingsRoute encodes a local repo-root key", () => {
expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe(
"/settings/projects/%2FUsers%2Fme%2Fdev%2Fpaseo",
);
});
it("project keys round-trip through decodeURIComponent", () => {
const projectKey = "remote:github.com/acme/app";
const route = buildProjectSettingsRoute(projectKey);
const segment = route.slice("/settings/projects/".length);
expect(decodeURIComponent(segment)).toBe(projectKey);
it("keeps route ids opaque", () => {
expect(normalizeProjectSettingsRouteId("project%2F1")).toBe("project%2F1");
});
});
+9 -5
View File
@@ -566,10 +566,14 @@ export function buildProjectsSettingsRoute() {
return "/settings/projects" as const;
}
export function buildProjectSettingsRoute(projectKey: string) {
const normalized = trimNonEmpty(projectKey);
if (!normalized) {
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
export function buildProjectSettingsRoute(serverId: string, projectId: string) {
if (!serverId.trim() || !projectId.trim()) {
throw new Error("buildProjectSettingsRoute requires a serverId and projectId");
}
return `/settings/projects/${encodeSegment(normalized)}` as const;
return `/settings/projects/${encodeSegment(serverId)}/${encodeSegment(projectId)}` as const;
}
export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string {
const id = Array.isArray(value) ? value[0] : value;
return typeof id === "string" ? id : "";
}
+66 -554
View File
@@ -1,595 +1,107 @@
import { describe, expect, it } from "vitest";
import type { ProjectPlacementPayload } from "@getpaseo/protocol/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects } from "./projects";
import { describe, expect, test } from "vitest";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, getProjectSummaryForHostProject } from "./projects";
function placement(input: {
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
mainRepoRoot?: string | null;
}): ProjectPlacementPayload {
function descriptor(id: string, key: string, root: string): ProjectDescriptor {
return {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: input.mainRepoRoot ?? null,
},
projectId: id,
projectKey: key,
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: root,
projectKind: "git",
};
}
function workspace(input: {
id: string;
repoRoot: string;
project?: ProjectPlacementPayload;
projectId?: string;
projectName?: string;
remoteUrl?: string | null;
currentBranch?: string;
archivingAt?: string | null;
}): WorkspaceDescriptor {
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectRootPath: input.repoRoot,
workspaceDirectory: input.repoRoot,
id,
projectId,
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: root,
workspaceDirectory: root,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
name: "main",
status: "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: input.currentBranch ?? "main",
remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: input.project,
};
}
describe("buildProjects", () => {
it("normalizes detached and blank branches out of workspace summaries", () => {
test("uses the grouped project list while retaining each host project id", () => {
const key = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
serverId: "host-a",
serverName: "Host A",
isOnline: true,
workspaces: [
workspace({ id: "detached", repoRoot: "/repo/app", currentBranch: "HEAD" }),
workspace({ id: "blank", repoRoot: "/repo/app", currentBranch: " " }),
],
},
],
});
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
{ id: "detached", currentBranch: null },
{ id: "blank", currentBranch: null },
]);
});
it("carries active archive state into workspace summaries", () => {
const archivingAt = "2026-07-15T18:00:00.000Z";
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({ id: "archiving", repoRoot: "/repo/app", archivingAt }),
workspace({ id: "active", repoRoot: "/repo/app" }),
],
},
],
});
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
{ id: "archiving", archivingAt },
{ id: "active" },
]);
expect(result.projects[0]?.hosts[0]?.workspaces[1]).not.toHaveProperty("archivingAt");
});
it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-a",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-a",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-b",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-b",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
projects: [descriptor("prj_a", key, "/a/app")],
workspaces: [workspace("ws-a", "prj_a", "/a/app")],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
workspace({
id: "feature",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app/feature",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("remote:github.com/acme/app");
expect(summary?.projectName).toBe("acme/app");
expect(summary?.hostCount).toBe(2);
expect(summary?.onlineHostCount).toBe(2);
expect(summary?.totalWorkspaceCount).toBe(5);
expect(summary?.githubUrl).toBe("https://github.com/acme/app");
expect(summary?.hosts).toHaveLength(2);
const local = summary?.hosts.find((host) => host.serverId === "local");
const laptop = summary?.hosts.find((host) => host.serverId === "laptop");
expect(local?.workspaceCount).toBe(3);
expect(laptop?.workspaceCount).toBe(2);
expect(local?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature-a", "feature-b"]);
expect(laptop?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature"]);
});
it("collapses five workspaces on one host into a single host entry whose workspaceCount is five", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: Array.from({ length: 5 }, (_, index) =>
workspace({
id: `ws-${index}`,
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: `/repo/app/ws-${index}`,
remoteUrl: "https://github.com/acme/app.git",
}),
}),
),
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toHaveLength(1);
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(5);
expect(result.projects[0]?.totalWorkspaceCount).toBe(5);
expect(result.projects[0]?.hostCount).toBe(1);
});
it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/worktrees/app/main",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/worktrees/app/main",
remoteUrl: "https://github.com/acme/app.git",
mainRepoRoot: "/repo/app",
}),
}),
],
},
{
serverId: "legacy",
serverName: "Legacy",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
}),
],
},
],
});
const acme = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const legacy = result.projects.find((project) => project.projectKey === "legacy-project");
expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app");
expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy");
});
it("derives githubUrl only when projectKey matches remote:github.com/{owner}/{repo}", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
const github = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const local = result.projects.find((project) => project.projectKey === "/repo/local");
expect(github?.githubUrl).toBe("https://github.com/acme/app");
expect(local?.githubUrl).toBeUndefined();
});
it("totals hostCount across all hosts and counts only online ones in onlineHostCount", () => {
const result = buildProjects({
hosts: [
{
serverId: "online",
serverName: "Online",
isOnline: true,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
},
{
serverId: "offline",
serverName: "Offline",
serverId: "host-b",
serverName: "Host B",
isOnline: false,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
projects: [descriptor("prj_b", key, "/b/app")],
workspaces: [workspace("ws-b", "prj_b", "/b/app")],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hostCount).toBe(2);
expect(result.projects[0]?.onlineHostCount).toBe(1);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "online")?.isOnline).toBe(
true,
);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "offline")?.isOnline).toBe(
false,
);
});
it("does not merge fallback repo-root-keyed projects with different roots", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "one",
repoRoot: "/repo/one",
project: placement({
projectKey: "/repo/one",
projectName: "one",
cwd: "/repo/one",
remoteUrl: null,
}),
}),
workspace({
id: "two",
repoRoot: "/repo/two",
project: placement({
projectKey: "/repo/two",
projectName: "two",
cwd: "/repo/two",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.projects.map((project) => project.projectKey)).toEqual([
"/repo/one",
"/repo/two",
expect(result.projects).toEqual([
expect.objectContaining({
projectKey: key,
hostCount: 2,
onlineHostCount: 1,
totalWorkspaceCount: 2,
hosts: [
expect.objectContaining({ serverId: "host-a", projectId: "prj_a" }),
expect.objectContaining({ serverId: "host-b", projectId: "prj_b" }),
],
}),
]);
});
it("includes GitHub, GitLab, Bitbucket and local projects together, sorted by name", () => {
test("includes a project with no workspaces", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/github",
project: placement({
projectKey: "remote:github.com/acme/web",
projectName: "acme/web",
cwd: "/repo/github",
remoteUrl: "https://github.com/acme/web.git",
}),
}),
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/api",
projectName: "acme/api",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/api.git",
}),
}),
workspace({
id: "bitbucket",
repoRoot: "/repo/bitbucket",
project: placement({
projectKey: "remote:bitbucket.org/acme/cli",
projectName: "acme/cli",
cwd: "/repo/bitbucket",
remoteUrl: "https://bitbucket.org/acme/cli.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.projects.map((project) => project.projectKey)).toEqual([
"remote:gitlab.com/acme/api",
"remote:bitbucket.org/acme/cli",
"remote:github.com/acme/web",
"/repo/local",
]);
});
it("renders a non-GitHub remote project on its own when no other projects are present", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "acme/app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.projectKey).toBe("remote:gitlab.com/acme/app");
expect(result.projects[0]?.projectName).toBe("acme/app");
expect(result.projects[0]?.githubUrl).toBeUndefined();
});
it("groups two workspaces sharing a non-GitHub remote into one project with workspaceCount two", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "acme/app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
workspace({
id: "feature",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "acme/app",
cwd: "/repo/gitlab/feature",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toHaveLength(1);
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(2);
});
it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => {
const result = buildProjects({
hosts: [
{
serverId: "old-daemon",
serverName: "Old daemon",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
remoteUrl: "https://gitlab.com/acme/legacy.git",
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("legacy-project");
expect(summary?.projectName).toBe("Legacy");
expect(summary?.githubUrl).toBeUndefined();
expect(summary?.hosts).toHaveLength(1);
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
});
it("surfaces a project with no workspaces yet and gives its host an editable repoRoot", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
serverId: "host-a",
serverName: "Host A",
isOnline: true,
projects: [descriptor("prj_a", "local-a", "/a/app")],
workspaces: [],
emptyProjects: [
{
projectId: "/repo/fresh",
projectDisplayName: "fresh",
projectCustomName: null,
projectRootPath: "/repo/fresh",
projectKind: "git",
},
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("/repo/fresh");
expect(summary?.totalWorkspaceCount).toBe(0);
expect(summary?.hosts).toHaveLength(1);
// repoRoot must be non-empty or the project settings screen treats the host
// as non-editable and shows its empty state.
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/fresh");
expect(summary?.hosts[0]?.workspaceCount).toBe(0);
expect(result.projects[0]).toMatchObject({
totalWorkspaceCount: 0,
hosts: [{ projectId: "prj_a", repoRoot: "/a/app" }],
});
});
test("looks up a grouped project by host-local identity", () => {
const project = buildProjects({
hosts: [
{
serverId: "host-a",
serverName: "Host A",
isOnline: true,
projects: [descriptor("prj_a", "shared", "/a/app")],
workspaces: [],
},
],
}).projects[0];
expect(getProjectSummaryForHostProject(project ? [project] : [], "host-a", "prj_a")).toBe(
project,
);
});
});
+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);
@@ -6,6 +6,7 @@ import type {
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
} from "./sidebar-project-row-model";
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
@@ -34,18 +35,26 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
};
}
function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry {
type ProjectOverrides = Omit<Partial<SidebarProjectEntry>, "hosts"> & {
hosts?: Array<Omit<SidebarProjectEntry["hosts"][number], "projectId"> & { projectId?: string }>;
};
function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
const projectKind = overrides.projectKind ?? "git";
const hosts = Array.from(
overrides.hosts ?? [
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
],
(host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }),
);
return {
projectKey: "project-1",
projectName: "paseo",
projectKind,
iconWorkingDir: "/repo",
hosts: overrides.hosts ?? [
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
],
workspaces: [workspace()],
...overrides,
hosts,
};
}
@@ -80,7 +89,7 @@ describe("buildSidebarProjectRowModel", () => {
chevron: "expand",
trailingAction: {
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
},
});
});
@@ -94,7 +103,7 @@ describe("buildSidebarProjectRowModel", () => {
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
});
});
@@ -117,7 +126,7 @@ describe("buildSidebarProjectRowModel", () => {
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
});
});
@@ -178,7 +187,7 @@ describe("buildSidebarProjectRowModel", () => {
chevron: "expand",
trailingAction: {
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
},
});
});
@@ -193,7 +202,24 @@ describe("buildSidebarProjectRowModel", () => {
}),
);
expect(iconTarget).toEqual({ serverId: "host-b", iconWorkingDir: "/repo/b" });
expect(iconTarget).toEqual({
serverId: "host-b",
projectId: "project-host-b",
iconWorkingDir: "/repo/b",
});
});
it("resolves desktop file actions from the local project placement", () => {
const groupedProject = project({
iconWorkingDir: "/remote/repo",
hosts: [
{ serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true },
{ serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true },
],
});
expect(resolveSidebarProjectLocalPath(groupedProject, "local")).toBe("/local/repo");
expect(resolveSidebarProjectLocalPath(groupedProject, "missing")).toBe("");
});
it("renders an empty project as an expandable section", () => {
@@ -207,7 +233,7 @@ describe("buildSidebarProjectRowModel", () => {
chevron: "collapse",
trailingAction: {
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
},
});
});
@@ -2,6 +2,7 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
export interface SidebarProjectHostTarget {
serverId: string;
projectId: string;
iconWorkingDir: string;
}
@@ -18,16 +19,16 @@ export interface SidebarProjectSectionRowModel {
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
function hostTarget(input: {
serverId: string;
projectId: string;
iconWorkingDir: string;
}): SidebarProjectHostTarget | null {
const iconWorkingDir = input.iconWorkingDir.trim();
if (!input.serverId || !iconWorkingDir) {
return null;
}
return { serverId: input.serverId, iconWorkingDir };
return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir };
}
export function resolveSidebarProjectIconTarget(
@@ -42,6 +43,14 @@ export function resolveSidebarProjectIconTarget(
return null;
}
export function resolveSidebarProjectLocalPath(
project: SidebarProjectEntry,
localServerId: string | null,
): string {
if (!localServerId) return "";
return project.hosts.find((host) => host.serverId === localServerId)?.iconWorkingDir.trim() ?? "";
}
// A project can host a brand-new workspace on a host when that host can create a
// git worktree (git projects) OR the host supports running multiple independent
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
@@ -57,9 +66,7 @@ function resolveNewWorkspaceTarget(
continue;
}
const target = hostTarget(host);
if (target) {
return target;
}
if (target) return target;
}
return null;
}
@@ -53,6 +53,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
hosts: [
{
serverId: workspaces[0]?.serverId ?? "s1",
projectId: projectKey,
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
canCreateWorktree: true,
},