fix(projects): strengthen grouping identity

This commit is contained in:
Mohamed Boudra
2026-07-28 21:59:01 +00:00
parent b92d142fce
commit b7ee191245
11 changed files with 297 additions and 94 deletions
@@ -21,6 +21,8 @@ function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
return {
serverId: "host-1",
projectName: "Project",
projectCustomName: null,
serverName: "Host 1",
isOnline: true,
repoRoot: "/tmp/project",
@@ -242,7 +242,8 @@ function ProjectSettingsBody({
projectKey={project.projectKey}
/>
<ProjectNameEditor
project={project}
projectName={selectedHost.projectName}
projectCustomName={selectedHost.projectCustomName}
projectId={selectedHost.projectId ?? project.projectKey}
client={client}
/>
@@ -793,17 +794,23 @@ function ResolveSpinnerColor(): string {
}
interface ProjectNameEditorProps {
project: ProjectSummary;
projectName: string;
projectCustomName: string | null;
projectId: string;
client: DaemonClient;
}
function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProps) {
function ProjectNameEditor({
projectName,
projectCustomName,
projectId,
client,
}: ProjectNameEditorProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const toast = useToast();
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(project.projectCustomName ?? "");
const [value, setValue] = useState(projectCustomName ?? "");
const renameMutation = useMutation({
mutationFn: (customName: string | null) => client.renameProject(projectId, customName),
@@ -820,24 +827,24 @@ function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProp
});
const handleStartEdit = useCallback(() => {
setValue(project.projectCustomName ?? "");
setValue(projectCustomName ?? "");
setIsEditing(true);
}, [project.projectCustomName]);
}, [projectCustomName]);
const handleCancel = useCallback(() => {
setIsEditing(false);
setValue(project.projectCustomName ?? "");
}, [project.projectCustomName]);
setValue(projectCustomName ?? "");
}, [projectCustomName]);
const handleSave = useCallback(() => {
const trimmed = value.trim();
const next = trimmed.length === 0 ? null : trimmed;
if (next === (project.projectCustomName ?? null)) {
if (next === projectCustomName) {
setIsEditing(false);
return;
}
renameMutation.mutate(next);
}, [value, project.projectCustomName, renameMutation]);
}, [value, projectCustomName, renameMutation]);
const handleReset = useCallback(() => {
renameMutation.mutate(null);
@@ -847,7 +854,7 @@ function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProp
return (
<View style={styles.nameEditorRow}>
<Text style={styles.projectTitle} numberOfLines={1}>
{project.projectName}
{projectName}
</Text>
<Pressable
testID="project-name-edit-button"
@@ -858,7 +865,7 @@ function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProp
>
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
{project.projectCustomName ? (
{projectCustomName ? (
<Pressable
testID="project-name-reset-button"
accessibilityLabel={t("settings.project.rename.resetLabel")}
@@ -881,7 +888,7 @@ function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProp
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
value={value}
onChangeText={setValue}
placeholder={project.projectName}
placeholder={projectName}
placeholderTextColor={styles.placeholderColor.color}
autoFocus
style={styles.nameEditorInput}
@@ -221,6 +221,8 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
projectName: "Project",
projectCustomName: null,
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",
+78 -2
View File
@@ -121,6 +121,37 @@ describe("buildProjects", () => {
]);
});
it("uses one ambiguity scope across every host", () => {
const projectGroupKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectGroupKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({ id: "clone-c", repoRoot: "/c", projectId: "prj_c", projectGroupKey }),
],
},
],
});
expect(result.projects.map((project) => project.projectKey).sort()).toEqual([
"host:desktop:project:prj_a",
"host:desktop:project:prj_b",
"host:laptop:project:prj_c",
]);
});
it("keeps host-local metadata when the grouping key differs from the project ID", () => {
const projectGroupKey = "remote:github.com/acme/app";
const result = buildProjects({
@@ -150,6 +181,49 @@ describe("buildProjects", () => {
});
});
it("keeps custom-name state on the host that owns it", () => {
const projectGroupKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({
id: "desktop-main",
repoRoot: "/desktop/app",
projectId: "prj_desktop",
projectGroupKey,
projectName: "My App",
projectCustomName: "My App",
}),
],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({
id: "laptop-main",
repoRoot: "/laptop/app",
projectId: "prj_laptop",
projectGroupKey,
projectName: "acme/app",
projectCustomName: null,
}),
],
},
],
});
expect(result.projects[0]?.hosts).toMatchObject([
{ serverId: "desktop", projectName: "My App", projectCustomName: "My App" },
{ serverId: "laptop", projectName: "acme/app", projectCustomName: null },
]);
});
it("preserves the root of a grouped project without workspaces", () => {
const result = buildProjects({
hosts: [
@@ -162,8 +236,8 @@ describe("buildProjects", () => {
{
projectId: "prj_app",
projectGroupKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectCustomName: null,
projectDisplayName: "My Empty Project",
projectCustomName: "My Empty Project",
projectRootPath: "/repo/app",
projectKind: "git",
},
@@ -174,6 +248,8 @@ describe("buildProjects", () => {
expect(result.projects[0]?.hosts[0]).toMatchObject({
projectId: "prj_app",
projectName: "My Empty Project",
projectCustomName: "My Empty Project",
repoRoot: "/repo/app",
});
});
+98 -71
View File
@@ -16,6 +16,8 @@ export interface WorkspaceSummary {
export interface ProjectHostEntry {
serverId: string;
projectId?: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
repoRoot: string;
@@ -58,6 +60,8 @@ const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
interface HostGroup {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
@@ -73,31 +77,35 @@ 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 workspace of host.workspaces) {
if (workspace.projectId === projectKey) {
return {
customName: workspace.projectCustomName,
customName: workspace.projectCustomName ?? null,
displayName: workspace.projectDisplayName,
};
}
}
return null;
const emptyProject = host.emptyProjects?.find((project) => project.projectId === projectKey);
return emptyProject
? {
customName: emptyProject.projectCustomName ?? null,
displayName: emptyProject.projectDisplayName,
}
: null;
}
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
return buildHostProjectList({
projects: buildWorkspaceStructureProjects({
sessions: [
{
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
},
],
sessions: hosts.map((host) => ({
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
})),
}),
});
}
@@ -141,6 +149,8 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
return {
serverId: group.serverId,
projectId: group.projectId,
projectName: group.projectName,
projectCustomName: group.projectCustomName,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
@@ -175,67 +185,84 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
};
}
function addHostProjects(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const emptyRepoRootByProjectId = new Map(
(host.emptyProjects ?? []).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 ?? hostProject.projectKey;
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: emptyRepoRootByProjectId.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) ??
resolveProjectGroupKey({
serverId: host.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
});
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);
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),
),
const hostProjects = projectEntries.filter((project) =>
project.hosts.some((placement) => placement.serverId === host.serverId),
);
for (const hostProject of hostProjects) {
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
const projectId = placement.projectId ?? hostProject.projectKey;
const customName = findProjectCustomName(host.workspaces, 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 && !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,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectKey.get(projectId) ?? "",
});
}
}
for (const workspace of host.workspaces) {
const key =
projectKeyByProjectId.get(workspace.projectId) ??
resolveProjectGroupKey({
serverId: host.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
});
const group = groups.get(key);
const hostGroup = group?.hostsByServerId.get(host.serverId);
if (!hostGroup) continue;
hostGroup.workspaces.push(workspace);
}
addHostProjects(groups, host, hostProjects);
attachHostWorkspaces(groups, host, hostProjects);
}
const projects = Array.from(groups.values()).map(toProjectSummary);
@@ -0,0 +1,45 @@
import path from "node:path";
import { describe, expect, test } from "vitest";
import { deriveProjectGroupKey } from "./project-group-key.js";
describe("deriveProjectGroupKey", () => {
test("preserves an explicit remote port", () => {
const rootPath = path.resolve("repo");
expect(
deriveProjectGroupKey({
rootPath,
remoteUrl: "https://git.example.com:8443/acme/app.git",
worktreeRoot: rootPath,
mainRepoRoot: null,
}),
).toBe("remote:git.example.com:8443/acme/app");
});
test("includes the selected path within a repository", () => {
const worktreeRoot = path.resolve("repo");
const rootPath = path.join(worktreeRoot, "packages", "app");
expect(
deriveProjectGroupKey({
rootPath,
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot,
mainRepoRoot: null,
}),
).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app");
});
test("keeps repository-root keys stable", () => {
const rootPath = path.resolve("repo");
expect(
deriveProjectGroupKey({
rootPath,
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot: rootPath,
mainRepoRoot: null,
}),
).toBe("remote:github.com/getpaseo/paseo");
});
});
@@ -1,4 +1,4 @@
import { resolve } from "node:path";
import { isAbsolute, relative, resolve, sep } from "node:path";
/**
* Derives the persisted, opaque equivalence key used to group projects across hosts.
@@ -7,11 +7,29 @@ import { resolve } from "node:path";
export function deriveProjectGroupKey(input: {
rootPath: string;
remoteUrl: string | null;
worktreeRoot: string | null;
mainRepoRoot: string | null;
}): string {
return (
deriveRemoteProjectGroupKey(input.remoteUrl) ?? resolve(input.mainRepoRoot ?? input.rootPath)
);
const remoteKey = deriveRemoteProjectGroupKey(input.remoteUrl);
if (!remoteKey) return resolve(input.mainRepoRoot ?? input.rootPath);
const selectedPath = deriveSelectedPath(input.rootPath, input.worktreeRoot);
return selectedPath ? `${remoteKey}#subdir:${selectedPath}` : remoteKey;
}
function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): string | null {
if (!worktreeRoot) return null;
const selectedPath = relative(resolve(worktreeRoot), resolve(rootPath));
if (
!selectedPath ||
selectedPath === "." ||
selectedPath === ".." ||
selectedPath.startsWith(`..${sep}`) ||
isAbsolute(selectedPath)
) {
return null;
}
return selectedPath.split(sep).map(encodeURIComponent).join("/");
}
function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null {
@@ -27,7 +45,7 @@ function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null {
} else if (trimmed.includes("://")) {
try {
const parsed = new URL(trimmed);
host = parsed.hostname || null;
host = parsed.host || null;
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
} catch {
return null;
@@ -206,6 +206,10 @@ test("reopening archived exact-root records restores the fresh Git project", asy
displayName: "repo",
timestamp: ARCHIVED_AT,
});
await projectRegistry.upsert({
...project,
projectGroupKey: "remote:github.com/acme/old-repo",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-archived-root",
projectId: project.projectId,
@@ -227,7 +231,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
remoteUrl: "https://github.com/acme/new-repo.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
@@ -244,6 +248,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
});
expect(await projectRegistry.get(project.projectId)).toMatchObject({
kind: "git",
projectGroupKey: "remote:github.com/acme/new-repo",
archivedAt: null,
});
});
@@ -166,6 +166,7 @@ export function createWorkspaceProvisioningService(deps: {
projectGroupKey: deriveProjectGroupKey({
rootPath,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
mainRepoRoot: checkout.mainRepoRoot,
}),
timestamp,
@@ -270,6 +271,7 @@ export function createWorkspaceProvisioningService(deps: {
projectGroupKey: deriveProjectGroupKey({
rootPath: input.repoRoot,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
mainRepoRoot: checkout.mainRepoRoot,
}),
timestamp: new Date().toISOString(),
@@ -346,8 +348,24 @@ export function createWorkspaceProvisioningService(deps: {
? checkout
: await workspaceGitService.getCheckout(project.rootPath);
const kind = projectCheckout.isGit ? "git" : "non_git";
if (project.archivedAt || project.kind !== kind) {
await projectRegistry.upsert({ ...project, kind, archivedAt: null, updatedAt: timestamp });
const projectGroupKey = deriveProjectGroupKey({
rootPath: project.rootPath,
remoteUrl: projectCheckout.remoteUrl,
worktreeRoot: projectCheckout.worktreeRoot,
mainRepoRoot: projectCheckout.mainRepoRoot,
});
if (
project.archivedAt ||
project.kind !== kind ||
project.projectGroupKey !== projectGroupKey
) {
await projectRegistry.upsert({
...project,
kind,
projectGroupKey,
archivedAt: null,
updatedAt: timestamp,
});
}
}
if (!next) return workspace;
@@ -386,6 +404,7 @@ export function createWorkspaceProvisioningService(deps: {
const projectGroupKey = deriveProjectGroupKey({
rootPath: project.rootPath,
remoteUrl: projectCheckout.remoteUrl,
worktreeRoot: projectCheckout.worktreeRoot,
mainRepoRoot: projectCheckout.mainRepoRoot,
});
if (project.kind === kind && project.projectGroupKey === projectGroupKey) return project;
@@ -345,6 +345,7 @@ export class WorkspaceReconciliationService {
const projectGroupKey = deriveProjectGroupKey({
rootPath: project.rootPath,
remoteUrl: currentGit.remoteUrl,
worktreeRoot: currentGit.worktreeRoot,
mainRepoRoot: currentGit.mainRepoRoot,
});
@@ -35,6 +35,7 @@ export function classifyDirectoryForProjectMembership(input: {
const projectKey = deriveProjectGroupKey({
rootPath: checkout.worktreeRoot ?? cwd,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
mainRepoRoot: checkout.mainRepoRoot,
});