Keep selected projects when switching hosts (#2700)

* fix(app): preserve project selection across hosts

Rendered project identity and persisted equivalence were conflated after duplicate-project handling. Keep them separate so host changes resolve selectable equivalents deterministically without resetting legacy sidebar identity.

* fix(app): restore selected clone after host round trip

Keep manual origin and current destination placements separately so switching back restores the chosen checkout while archive gaps preserve the active host selection.

* fix(app): key status icons by project view

* fix(app): preserve origin during archive gaps
This commit is contained in:
Mohamed Boudra
2026-07-31 15:42:08 +02:00
committed by GitHub
parent 87ef631ac4
commit 99eca19a76
56 changed files with 1234 additions and 428 deletions
@@ -10,6 +10,7 @@ import {
import { expectOpenedProject } from "./helpers/project-picker-ui"; import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client"; import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { createTempGitRepo } from "./helpers/workspace"; import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { waitForSidebarHydration } from "./helpers/workspace-ui";
@@ -32,12 +33,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await archiveItem.click(); await archiveItem.click();
} }
async function removeProjectFromSidebar(page: Page, projectKey: string): Promise<void> { async function removeProjectFromSidebar(page: Page, projectViewKey: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`); const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 }); await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover(); await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`); const kebab = page.getByTestId(`sidebar-project-kebab-${projectViewKey}`);
await expect(kebab).toBeVisible({ timeout: 10_000 }); await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click(); await kebab.click();
@@ -45,7 +46,7 @@ async function removeProjectFromSidebar(page: Page, projectKey: string): Promise
// user-confirmed removal proceeds deterministically. // user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept()); page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`); const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectViewKey}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 }); await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click(); await removeItem.click();
} }
@@ -152,9 +153,10 @@ test.describe("Project with no workspaces persists", () => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" }); const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try { try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`); const projectViewKey = projectEquivalenceViewKey(workspace.projectKey);
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`);
const newWorkspaceRow = page.getByTestId( const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectKey}`, `sidebar-project-new-workspace-row-${projectViewKey}`,
); );
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace"); const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -198,7 +200,8 @@ test.describe("Project remove", () => {
let readdedProjectId: string | null = null; let readdedProjectId: string | null = null;
try { try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`); const projectViewKey = projectEquivalenceViewKey(workspace.projectKey);
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`);
await gotoAppShell(page); await gotoAppShell(page);
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
@@ -207,7 +210,7 @@ test.describe("Project remove", () => {
timeout: 30_000, timeout: 30_000,
}); });
await removeProjectFromSidebar(page, workspace.projectKey); await removeProjectFromSidebar(page, projectViewKey);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, { await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000, timeout: 30_000,
@@ -233,7 +236,9 @@ test.describe("Project remove", () => {
await expect(projectRow).toContainText(workspace.projectDisplayName); await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath); await expect(projectRow).not.toContainText(workspace.repoPath);
await expect( await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`), page.getByTestId(
`sidebar-project-new-workspace-row-${projectEquivalenceViewKey(readdedProjectKey)}`,
),
).toBeVisible({ timeout: 30_000 }); ).toBeVisible({ timeout: 30_000 });
} finally { } finally {
if (readdedProjectId) { if (readdedProjectId) {
+11 -6
View File
@@ -3,6 +3,7 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader"; import { connectDaemonClient } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port"; import { daemonWsRoutePattern } from "./daemon-port";
import { projectEquivalenceViewKey } from "./project-view-key";
import { expectWorkspaceHeader } from "./workspace-ui"; import { expectWorkspaceHeader } from "./workspace-ui";
type NewWorkspaceDaemonClient = Pick< type NewWorkspaceDaemonClient = Pick<
@@ -167,11 +168,12 @@ export async function openNewWorkspaceComposer(
page: Page, page: Page,
input: { projectKey: string; projectDisplayName: string }, input: { projectKey: string; projectDisplayName: string },
): Promise<void> { ): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first(); const projectViewKey = projectEquivalenceViewKey(input.projectKey);
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`).first();
await expect(projectRow).toBeVisible({ timeout: 30_000 }); await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover(); await projectRow.hover();
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first(); const button = page.getByTestId(`sidebar-project-new-worktree-${projectViewKey}`).first();
await expect(button).toBeVisible({ timeout: 30_000 }); await expect(button).toBeVisible({ timeout: 30_000 });
await button.click(); await button.click();
@@ -216,8 +218,10 @@ export async function expectNewWorkspaceDraft(page: Page, draft: string): Promis
} }
export async function selectNewWorkspaceHost(page: Page, hostLabel: string): Promise<void> { export async function selectNewWorkspaceHost(page: Page, hostLabel: string): Promise<void> {
await page.getByTestId("host-picker-trigger").click(); const trigger = page.getByTestId("host-picker-trigger");
await page.getByText(hostLabel, { exact: true }).click(); await trigger.click();
await page.getByRole("button", { name: hostLabel, exact: true }).click();
await expect(trigger).toContainText(hostLabel);
} }
export async function submitNewWorkspacePrompt( export async function submitNewWorkspacePrompt(
@@ -244,13 +248,14 @@ export async function clickNewWorkspaceButton(
export async function selectNewWorkspaceProject( export async function selectNewWorkspaceProject(
page: Page, page: Page,
input: { projectKey: string; projectDisplayName: string }, input: { projectKey: string; projectDisplayName: string; projectViewKey?: string },
): Promise<void> { ): Promise<void> {
const trigger = page.getByTestId("new-workspace-project-picker-trigger"); const trigger = page.getByTestId("new-workspace-project-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 }); await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click(); await trigger.click();
const option = page.getByTestId(`new-workspace-project-picker-option-${input.projectKey}`); const projectViewKey = input.projectViewKey ?? projectEquivalenceViewKey(input.projectKey);
const option = page.getByTestId(`new-workspace-project-picker-option-${projectViewKey}`);
await expect(option).toBeVisible({ timeout: 30_000 }); await expect(option).toBeVisible({ timeout: 30_000 });
await option.click(); await option.click();
@@ -0,0 +1,7 @@
export function projectEquivalenceViewKey(projectKey: string): string {
return projectKey;
}
export function projectPlacementViewKey(serverId: string, projectId: string): string {
return JSON.stringify([serverId, projectId]);
}
+5 -4
View File
@@ -11,6 +11,7 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedSavedSettingsHosts } from "./helpers/settings"; import { seedSavedSettingsHosts } from "./helpers/settings";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { waitForSidebarHydration } from "./helpers/workspace-ui";
@@ -22,7 +23,7 @@ import { waitForSidebarHydration } from "./helpers/workspace-ui";
// screen, and non-git projects never offer the worktree Isolation control. // screen, and non-git projects never offer the worktree Isolation control.
function projectRow(page: import("@playwright/test").Page, projectKey: string) { function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`); return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
} }
test.describe("New workspace entry points", () => { test.describe("New workspace entry points", () => {
@@ -195,7 +196,7 @@ test.describe("New workspace entry points", () => {
// route-driven navigation targets a different project. // route-driven navigation targets a different project.
await page.getByTestId("new-workspace-project-picker-trigger").click(); await page.getByTestId("new-workspace-project-picker-trigger").click();
const optionC = page.getByTestId( const optionC = page.getByTestId(
`new-workspace-project-picker-option-${projectC.projectKey}`, `new-workspace-project-picker-option-${projectEquivalenceViewKey(projectC.projectKey)}`,
); );
await expect(optionC).toBeVisible({ timeout: 30_000 }); await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click(); await optionC.click();
@@ -238,7 +239,7 @@ test.describe("New workspace entry points", () => {
await expect(trigger).toBeVisible({ timeout: 30_000 }); await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click(); await trigger.click();
const nonGitOption = page.getByTestId( const nonGitOption = page.getByTestId(
`new-workspace-project-picker-option-${nonGitProject.projectKey}`, `new-workspace-project-picker-option-${projectEquivalenceViewKey(nonGitProject.projectKey)}`,
); );
await expect(nonGitOption).toBeVisible({ timeout: 30_000 }); await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
await nonGitOption.click(); await nonGitOption.click();
@@ -251,7 +252,7 @@ test.describe("New workspace entry points", () => {
// Switching to the git project on the same screen reveals the Isolation row. // Switching to the git project on the same screen reveals the Isolation row.
await trigger.click(); await trigger.click();
const gitOption = page.getByTestId( const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${gitProject.projectKey}`, `new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`,
); );
await expect(gitOption).toBeVisible({ timeout: 30_000 }); await expect(gitOption).toBeVisible({ timeout: 30_000 });
await gitOption.click(); await gitOption.click();
@@ -0,0 +1,196 @@
import { expect, test as base } from "./fixtures";
import { openProjectDirectoryWithHosts } from "./helpers/project-grouping";
import { projectPlacementViewKey } from "./helpers/project-view-key";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceHost,
selectNewWorkspaceProject,
} from "./helpers/new-workspace";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { createTempGitRepo } from "./helpers/workspace";
const PRIMARY_HOST_LABEL = "Primary host";
const SECONDARY_HOST_LABEL = "Secondary host";
const SHARED_PROJECT_NAME = "Paseo";
const SHARED_REMOTE_URL = "https://github.com/getpaseo/paseo.git";
interface CreatedProject {
projectId: string;
projectKey: string;
workspaceId: string;
}
interface CrossHostProjectScenario {
contextWorkspaceId: string;
selectedProjectViewKey: string;
sharedProjectKey: string;
secondaryHost: {
serverId: string;
label: string;
port: number;
};
}
async function createProject(
client: SeedDaemonClient,
input: { projectPath: string; projectName: string; workspaceName: string },
): Promise<CreatedProject> {
const created = await client.createWorkspace({
source: { kind: "directory", path: input.projectPath },
title: input.workspaceName,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project ${input.projectName}`);
}
await client.renameProject(created.workspace.projectId, input.projectName);
const listed = await client.listProjects();
const project = listed.projects.find(
(candidate) => candidate.projectId === created.workspace?.projectId,
);
if (!project?.projectKey) {
throw new Error(`Created project ${created.workspace.projectId} has no project key`);
}
return {
projectId: created.workspace.projectId,
projectKey: project.projectKey,
workspaceId: created.workspace.id,
};
}
async function openNewWorkspaceFromContextProject(
page: Parameters<typeof openProjectDirectoryWithHosts>[0],
scenario: CrossHostProjectScenario,
): Promise<void> {
await openProjectDirectoryWithHosts(page, {
hosts: [scenario.secondaryHost],
primaryLabel: PRIMARY_HOST_LABEL,
});
await expect(
page.getByTestId(`sidebar-project-row-${scenario.selectedProjectViewKey}`),
).toBeVisible();
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: scenario.contextWorkspaceId,
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, "Context project");
}
const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon(
"new-workspace-project-preservation-secondary",
);
const contextRepo = await createTempGitRepo("new-workspace-context-");
const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryDuplicateRepo = await createTempGitRepo("new-workspace-paseo-duplicate-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryFallbackRepo = await createTempGitRepo("new-workspace-fallback-");
const secondarySharedRepo = await createTempGitRepo("new-workspace-paseo-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryDaemon.port });
const primaryProjects: CreatedProject[] = [];
const secondaryProjects: CreatedProject[] = [];
try {
const contextProject = await createProject(primaryClient, {
projectPath: contextRepo.path,
projectName: "Context project",
workspaceName: "Context workspace",
});
primaryProjects.push(contextProject);
const primarySharedProject = await createProject(primaryClient, {
projectPath: primarySharedRepo.path,
projectName: SHARED_PROJECT_NAME,
workspaceName: "Primary Paseo workspace",
});
primaryProjects.push(primarySharedProject);
const primaryDuplicateProject = await createProject(primaryClient, {
projectPath: primaryDuplicateRepo.path,
projectName: "Paseo duplicate",
workspaceName: "Duplicate Paseo workspace",
});
primaryProjects.push(primaryDuplicateProject);
secondaryProjects.push(
await createProject(secondaryClient, {
projectPath: secondaryFallbackRepo.path,
projectName: "Aardvark project",
workspaceName: "Secondary fallback workspace",
}),
);
const secondarySharedProject = await createProject(secondaryClient, {
projectPath: secondarySharedRepo.path,
projectName: SHARED_PROJECT_NAME,
workspaceName: "Secondary Paseo workspace",
});
secondaryProjects.push(secondarySharedProject);
if (
primarySharedProject.projectKey !== primaryDuplicateProject.projectKey ||
primarySharedProject.projectKey !== secondarySharedProject.projectKey
) {
throw new Error("Expected the Paseo clones to have the same equivalence key");
}
await provide({
contextWorkspaceId: contextProject.workspaceId,
selectedProjectViewKey: projectPlacementViewKey(
getServerId(),
primarySharedProject.projectId,
),
sharedProjectKey: primarySharedProject.projectKey,
secondaryHost: {
serverId: secondaryDaemon.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryDaemon.port,
},
});
} finally {
for (const project of primaryProjects) {
await primaryClient.removeProject(project.projectId).catch(() => undefined);
}
for (const project of secondaryProjects) {
await secondaryClient.removeProject(project.projectId).catch(() => undefined);
}
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await contextRepo.cleanup().catch(() => undefined);
await primarySharedRepo.cleanup().catch(() => undefined);
await primaryDuplicateRepo.cleanup().catch(() => undefined);
await secondaryFallbackRepo.cleanup().catch(() => undefined);
await secondarySharedRepo.cleanup().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
}
},
});
test.describe("New workspace host project preservation", () => {
test.describe.configure({ timeout: 180_000 });
test("keeps the equivalent project selected when one host has another clone", async ({
page,
crossHostProject,
}) => {
await openNewWorkspaceFromContextProject(page, crossHostProject);
await selectNewWorkspaceProject(page, {
projectKey: crossHostProject.sharedProjectKey,
projectViewKey: crossHostProject.selectedProjectViewKey,
projectDisplayName: SHARED_PROJECT_NAME,
});
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
});
});
@@ -7,6 +7,7 @@ import {
} from "./helpers/schedule-fake-host"; } from "./helpers/schedule-fake-host";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { escapeRegex } from "./helpers/regex"; import { escapeRegex } from "./helpers/regex";
import { expectNoTruncation } from "./helpers/no-truncation"; import { expectNoTruncation } from "./helpers/no-truncation";
import { expectSettled, expectStableHeight } from "./helpers/settled"; import { expectSettled, expectStableHeight } from "./helpers/settled";
@@ -205,7 +206,9 @@ test.describe("Schedules project target", () => {
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0); await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /select project/i }).click(); await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click(); await page
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(workspace.projectKey)}`)
.click();
const projectTrigger = page.getByTestId("schedule-project-trigger"); const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(workspace.projectDisplayName); await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger); await expectSettled(projectTrigger);
@@ -315,7 +318,9 @@ test.describe("Schedules project target", () => {
await expectSettled(hostTrigger); await expectSettled(hostTrigger);
await projectTrigger.click(); await projectTrigger.click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click(); await page
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(workspace.projectKey)}`)
.click();
await expect(projectTrigger).toContainText(workspace.projectDisplayName); await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger); await expectSettled(projectTrigger);
@@ -337,7 +342,9 @@ test.describe("Schedules project target", () => {
await expectSettled(projectTrigger); await expectSettled(projectTrigger);
await projectTrigger.click(); await projectTrigger.click();
await page.getByTestId(`schedule-project-option-${fakeHost.projectKey}`).click(); await page
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(fakeHost.projectKey)}`)
.click();
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName); await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
await expectSettled(projectTrigger); await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(/select model/i); await expect(modelTrigger).toContainText(/select model/i);
@@ -363,7 +370,9 @@ test.describe("Schedules project target", () => {
await page.getByTestId("schedules-empty-new").click(); await page.getByTestId("schedules-empty-new").click();
await page.getByRole("button", { name: /select project/i }).click(); await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click(); await page
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(workspace.projectKey)}`)
.click();
await selectModelByLabel(page, "Ten second stream"); await selectModelByLabel(page, "Ten second stream");
await expect( await expect(
page.getByText("Off keeps each run's workspace in the sidebar for inspection."), page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
+3 -2
View File
@@ -4,6 +4,7 @@ import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedMockAgentWorkspace } from "./helpers/mock-agent"; import { seedMockAgentWorkspace } from "./helpers/mock-agent";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
@@ -17,11 +18,11 @@ function workspaceRow(page: Page, workspaceId: string) {
} }
function projectRow(page: Page, projectKey: string) { function projectRow(page: Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`); return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
} }
function projectNewWorktreeIcon(page: Page, projectKey: string) { function projectNewWorktreeIcon(page: Page, projectKey: string) {
return page.getByTestId(`sidebar-project-new-worktree-${projectKey}`); return page.getByTestId(`sidebar-project-new-worktree-${projectEquivalenceViewKey(projectKey)}`);
} }
async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> { async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> {
+4 -1
View File
@@ -11,6 +11,7 @@ import {
import { seedWorkspace } from "./helpers/seed-client"; import { seedWorkspace } from "./helpers/seed-client";
import { expectWorkspaceHeader } from "./helpers/workspace-ui"; import { expectWorkspaceHeader } from "./helpers/workspace-ui";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { escapeRegex } from "./helpers/regex"; import { escapeRegex } from "./helpers/regex";
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git"; const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
@@ -219,7 +220,9 @@ test.describe("Half-screen desktop layout", () => {
} }
await gotoAppShell(page); await gotoAppShell(page);
await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click(); await page
.getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(workspace.projectKey)}`)
.click();
await waitForSidebarWorkspace(page, lastWorkspaceId); await waitForSidebarWorkspace(page, lastWorkspaceId);
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll"); const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
@@ -12,6 +12,7 @@ import {
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { expectExplorerEntryVisible } from "./helpers/file-explorer"; import { expectExplorerEntryVisible } from "./helpers/file-explorer";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B reshape: a workspace is the unit, its isolation (local checkout or // Model B reshape: a workspace is the unit, its isolation (local checkout or
@@ -190,9 +191,9 @@ test.describe("Workspace multiplicity creation flow", () => {
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
// Model B: a non-git project is an expandable parent like any other, with // Model B: a non-git project is an expandable parent like any other, with
// its single workspace already rendered as its own row underneath. // its single workspace already rendered as its own row underneath.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ await expect(
timeout: 30_000, page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`),
}); ).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
@@ -210,9 +211,9 @@ test.describe("Workspace multiplicity creation flow", () => {
// Both the original and the new workspace render as distinct rows under // Both the original and the new workspace render as distinct rows under
// the same expandable parent. // the same expandable parent.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ await expect(
timeout: 30_000, page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`),
}); ).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
+8 -8
View File
@@ -77,14 +77,14 @@ interface SidebarSharedProps {
pinnedGroups: PinnedSidebarGroups; pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>; workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
isInitialLoad: boolean; isInitialLoad: boolean;
isRevalidating: boolean; isRevalidating: boolean;
isManualRefresh: boolean; isManualRefresh: boolean;
groupMode: SidebarGroupMode; groupMode: SidebarGroupMode;
collapsedProjectKeys: ReadonlySet<string>; collapsedProjectKeys: ReadonlySet<string>;
shortcutIndexByWorkspaceKey: Map<string, number>; shortcutIndexByWorkspaceKey: Map<string, number>;
toggleProjectCollapsed: (projectKey: string) => void; toggleProjectCollapsed: (projectViewKey: string) => void;
handleRefresh: () => void; handleRefresh: () => void;
handleOpenProject: () => void; handleOpenProject: () => void;
handleHome: () => void; handleHome: () => void;
@@ -132,7 +132,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
const { const {
projects, projects,
workspaceEntriesByKey, workspaceEntriesByKey,
projectNamesByKey, projectNamesByViewKey,
isInitialLoad, isInitialLoad,
isRevalidating, isRevalidating,
refreshAll, refreshAll,
@@ -238,7 +238,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
pinnedGroups, pinnedGroups,
projects, projects,
workspaceEntriesByKey, workspaceEntriesByKey,
projectNamesByKey, projectNamesByViewKey,
isInitialLoad, isInitialLoad,
isRevalidating, isRevalidating,
isManualRefresh, isManualRefresh,
@@ -592,7 +592,7 @@ function MobileSidebar({
pinnedGroups, pinnedGroups,
projects, projects,
workspaceEntriesByKey, workspaceEntriesByKey,
projectNamesByKey, projectNamesByViewKey,
isInitialLoad, isInitialLoad,
isRevalidating, isRevalidating,
isManualRefresh, isManualRefresh,
@@ -708,7 +708,7 @@ function MobileSidebar({
pinnedGroups={pinnedGroups} pinnedGroups={pinnedGroups}
projects={projects} projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey} workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey} projectNamesByViewKey={projectNamesByViewKey}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress} onWorkspacePress={handleWorkspacePress}
@@ -738,7 +738,7 @@ function DesktopSidebar({
pinnedGroups, pinnedGroups,
projects, projects,
workspaceEntriesByKey, workspaceEntriesByKey,
projectNamesByKey, projectNamesByViewKey,
isInitialLoad, isInitialLoad,
isRevalidating, isRevalidating,
isManualRefresh, isManualRefresh,
@@ -875,7 +875,7 @@ function DesktopSidebar({
pinnedGroups={pinnedGroups} pinnedGroups={pinnedGroups}
projects={projects} projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey} workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey} projectNamesByViewKey={projectNamesByViewKey}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onAddProject={handleOpenProject} onAddProject={handleOpenProject}
@@ -15,22 +15,22 @@ const WHITE_TEXT = { color: "#ffffff" } as const;
export function ProjectIconView({ export function ProjectIconView({
iconDataUri, iconDataUri,
initial, initial,
projectKey, projectViewKey,
imageStyle, imageStyle,
fallbackStyle, fallbackStyle,
textStyle, textStyle,
}: { }: {
iconDataUri: string | null; iconDataUri: string | null;
initial: string; initial: string;
projectKey: string; projectViewKey: string;
imageStyle: StyleProp<ImageStyle>; imageStyle: StyleProp<ImageStyle>;
fallbackStyle: StyleProp<ViewStyle>; fallbackStyle: StyleProp<ViewStyle>;
textStyle: StyleProp<TextStyle>; textStyle: StyleProp<TextStyle>;
}) { }) {
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
const fallbackStyles = useMemo( const fallbackStyles = useMemo(
() => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectKey) }], () => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectViewKey) }],
[fallbackStyle, projectKey], [fallbackStyle, projectViewKey],
); );
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]); const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import React from "react"; import React from "react";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { createProjectViewKey } from "@/projects/workspace-structure";
vi.hoisted(() => { vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
@@ -196,7 +197,7 @@ function ProjectHeaderProbe({
project: SidebarProjectEntry; project: SidebarProjectEntry;
counts: RenderCounts; counts: RenderCounts;
}): null { }): null {
incrementRecord(counts.headers, project.projectKey); incrementRecord(counts.headers, project.viewKey);
return null; return null;
} }
@@ -232,7 +233,7 @@ function ProjectActiveProbe({
activeSelection?.serverId === serverId && activeSelection?.serverId === serverId &&
project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId); project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId);
void isActive; void isActive;
incrementRecord(counts.projectSelection, project.projectKey); incrementRecord(counts.projectSelection, project.viewKey);
return null; return null;
} }
@@ -260,7 +261,7 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
return ( return (
<> <>
{projects.map((project) => ( {projects.map((project) => (
<div key={project.projectKey}> <div key={project.viewKey}>
<ProjectHeaderProbe project={project} counts={counts} /> <ProjectHeaderProbe project={project} counts={counts} />
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} /> <ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
{project.workspaces.map((entry) => ( {project.workspaces.map((entry) => (
@@ -453,8 +454,8 @@ describe("sidebar workspace render isolation", () => {
expect(counts.frame).toBe(1); expect(counts.frame).toBe(1);
expect(counts.projectSelection).toEqual({ expect(counts.projectSelection).toEqual({
"project-a": 1, [createProjectViewKey({ kind: "equivalence", projectKey: "project-a" })]: 1,
"project-b": 1, [createProjectViewKey({ kind: "equivalence", projectKey: "project-b" })]: 1,
}); });
expect(counts.rowSelection).toEqual({ expect(counts.rowSelection).toEqual({
"a-main": 1, "a-main": 1,
@@ -57,7 +57,7 @@ import {
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useHostFeatureMap } from "@/runtime/host-features"; import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { import {
buildNewWorkspaceRoute, buildNewWorkspaceRoute,
buildProjectSettingsRoute, buildProjectSettingsRoute,
@@ -137,7 +137,7 @@ import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKey;
const WORKSPACE_STATUS_DOT_WIDTH = 14; const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7; const DEFAULT_STATUS_DOT_SIZE = 7;
@@ -234,9 +234,9 @@ interface SidebarWorkspaceListProps {
pinnedGroups: PinnedSidebarGroups; pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>; workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
collapsedProjectKeys: ReadonlySet<string>; collapsedProjectKeys: ReadonlySet<string>;
onToggleProjectCollapsed: (projectKey: string) => void; onToggleProjectCollapsed: (projectViewKey: string) => void;
shortcutIndexByWorkspaceKey: Map<string, number>; shortcutIndexByWorkspaceKey: Map<string, number>;
groupMode: "project" | "status"; groupMode: "project" | "status";
isRefreshing?: boolean; isRefreshing?: boolean;
@@ -431,7 +431,7 @@ function ProjectLeadingVisual({
displayName, displayName,
iconDataUri, iconDataUri,
workspace, workspace,
projectKey, projectViewKey,
chevron = null, chevron = null,
showChevron = false, showChevron = false,
isArchiving = false, isArchiving = false,
@@ -439,7 +439,7 @@ function ProjectLeadingVisual({
displayName: string; displayName: string;
iconDataUri: string | null; iconDataUri: string | null;
workspace: SidebarWorkspaceEntry | null; workspace: SidebarWorkspaceEntry | null;
projectKey: string; projectViewKey: string;
chevron?: "expand" | "collapse" | null; chevron?: "expand" | "collapse" | null;
showChevron?: boolean; showChevron?: boolean;
isArchiving?: boolean; isArchiving?: boolean;
@@ -467,7 +467,7 @@ function ProjectLeadingVisual({
<ProjectIcon <ProjectIcon
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial} placeholderInitial={placeholderInitial}
projectKey={projectKey} projectViewKey={projectViewKey}
/> />
</View> </View>
); );
@@ -477,7 +477,7 @@ function ProjectLeadingVisual({
<ProjectLeadingVisualStatus <ProjectLeadingVisualStatus
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial} placeholderInitial={placeholderInitial}
projectKey={projectKey} projectViewKey={projectViewKey}
isArchiving={isArchiving} isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader} shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace} activeWorkspace={activeWorkspace}
@@ -518,7 +518,7 @@ function ProjectRowTrailingActions({
onPress={onBeginWorkspaceSetup} onPress={onBeginWorkspaceSetup}
visible={actionsVisible} visible={actionsVisible}
showShortcutHint={isProjectActive} showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`} testID={`sidebar-project-new-worktree-${project.viewKey}`}
/> />
) : null} ) : null}
{onRemoveProject ? ( {onRemoveProject ? (
@@ -527,7 +527,7 @@ function ProjectRowTrailingActions({
pointerEvents={actionsVisible ? "auto" : "none"} pointerEvents={actionsVisible ? "auto" : "none"}
> >
<ProjectKebabMenu <ProjectKebabMenu
projectKey={project.projectKey} projectViewKey={project.viewKey}
settingsTarget={settingsTarget} settingsTarget={settingsTarget}
projectPath={localProjectPath} projectPath={localProjectPath}
onRemoveProject={onRemoveProject} onRemoveProject={onRemoveProject}
@@ -555,13 +555,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
} }
function ProjectKebabMenu({ function ProjectKebabMenu({
projectKey, projectViewKey,
settingsTarget, settingsTarget,
projectPath, projectPath,
onRemoveProject, onRemoveProject,
removeProjectStatus, removeProjectStatus,
}: { }: {
projectKey: string; projectViewKey: string;
settingsTarget: { serverId: string; projectId: string } | null; settingsTarget: { serverId: string; projectId: string } | null;
projectPath: string; projectPath: string;
onRemoveProject: () => void; onRemoveProject: () => void;
@@ -595,14 +595,14 @@ function ProjectKebabMenu({
style={projectKebabStyle} style={projectKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"} accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.project.actions.menu")} accessibilityLabel={t("sidebar.project.actions.menu")}
testID={`sidebar-project-kebab-${projectKey}`} testID={`sidebar-project-kebab-${projectViewKey}`}
> >
{renderKebabTriggerIcon} {renderKebabTriggerIcon}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}> <DropdownMenuContent align="end" width={220}>
{canOpenProjectSettings ? ( {canOpenProjectSettings ? (
<DropdownMenuItem <DropdownMenuItem
testID={`sidebar-project-menu-open-settings-${projectKey}`} testID={`sidebar-project-menu-open-settings-${projectViewKey}`}
leading={settingsLeadingIcon} leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings} onSelect={handleOpenProjectSettings}
> >
@@ -611,7 +611,7 @@ function ProjectKebabMenu({
) : null} ) : null}
{canOpenInNewWindow ? ( {canOpenInNewWindow ? (
<DropdownMenuItem <DropdownMenuItem
testID={`sidebar-project-menu-open-new-window-${projectKey}`} testID={`sidebar-project-menu-open-new-window-${projectViewKey}`}
leading={openInNewWindowLeadingIcon} leading={openInNewWindowLeadingIcon}
onSelect={handleOpenInNewWindow} onSelect={handleOpenInNewWindow}
> >
@@ -620,10 +620,10 @@ function ProjectKebabMenu({
) : null} ) : null}
<OpenInFileManagerMenuItem <OpenInFileManagerMenuItem
path={projectPath} path={projectPath}
testID={`sidebar-project-menu-open-folder-${projectKey}`} testID={`sidebar-project-menu-open-folder-${projectViewKey}`}
/> />
<DropdownMenuItem <DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`} testID={`sidebar-project-menu-remove-${projectViewKey}`}
leading={trash2LeadingIcon} leading={trash2LeadingIcon}
status={removeProjectStatus} status={removeProjectStatus}
pendingLabel={t("sidebar.project.actions.removing")} pendingLabel={t("sidebar.project.actions.removing")}
@@ -725,17 +725,17 @@ function WorkspaceRowRightGroup({
function ProjectIcon({ function ProjectIcon({
iconDataUri, iconDataUri,
placeholderInitial, placeholderInitial,
projectKey, projectViewKey,
}: { }: {
iconDataUri: string | null; iconDataUri: string | null;
placeholderInitial: string; placeholderInitial: string;
projectKey: string; projectViewKey: string;
}) { }) {
return ( return (
<ProjectIconView <ProjectIconView
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
initial={placeholderInitial} initial={placeholderInitial}
projectKey={projectKey} projectViewKey={projectViewKey}
imageStyle={styles.projectIcon} imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback} fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText} textStyle={styles.projectIconFallbackText}
@@ -746,14 +746,14 @@ function ProjectIcon({
function ProjectLeadingVisualStatus({ function ProjectLeadingVisualStatus({
iconDataUri, iconDataUri,
placeholderInitial, placeholderInitial,
projectKey, projectViewKey,
isArchiving, isArchiving,
shouldShowSyncedLoader, shouldShowSyncedLoader,
activeWorkspace, activeWorkspace,
}: { }: {
iconDataUri: string | null; iconDataUri: string | null;
placeholderInitial: string; placeholderInitial: string;
projectKey: string; projectViewKey: string;
isArchiving: boolean; isArchiving: boolean;
shouldShowSyncedLoader: boolean; shouldShowSyncedLoader: boolean;
activeWorkspace: SidebarWorkspaceEntry; activeWorkspace: SidebarWorkspaceEntry;
@@ -796,7 +796,7 @@ function ProjectLeadingVisualStatus({
<ProjectIcon <ProjectIcon
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial} placeholderInitial={placeholderInitial}
projectKey={projectKey} projectViewKey={projectViewKey}
/> />
{dotColorStyle ? ( {dotColorStyle ? (
<StatusDotOverlay <StatusDotOverlay
@@ -936,7 +936,7 @@ function NewWorkspaceGhostRow({
})} })}
onPress={handlePress} onPress={handlePress}
style={rowStyle} style={rowStyle}
testID={`sidebar-project-new-workspace-row-${project.projectKey}`} testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
> >
{({ hovered, pressed }) => ( {({ hovered, pressed }) => (
<> <>
@@ -1040,7 +1040,7 @@ function ProjectHeaderRow({
displayName={displayName} displayName={displayName}
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
workspace={workspace} workspace={workspace}
projectKey={project.projectKey} projectViewKey={project.viewKey}
chevron={chevron} chevron={chevron}
showChevron={isHovered && chevron !== null} showChevron={isHovered && chevron !== null}
isArchiving={isArchiving} isArchiving={isArchiving}
@@ -1088,7 +1088,7 @@ function ProjectHeaderRow({
onTouchMove={interaction.handleTouchMove} onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut} onPressOut={interaction.handlePressOut}
onPress={handlePress} onPress={handlePress}
testID={`sidebar-project-row-${project.projectKey}`} testID={`sidebar-project-row-${project.viewKey}`}
> >
{rowChildren} {rowChildren}
</ContextMenuTrigger> </ContextMenuTrigger>
@@ -1111,7 +1111,7 @@ function ProjectHeaderRow({
onTouchMove={interaction.handleTouchMove} onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut} onPressOut={interaction.handlePressOut}
onPress={handlePress} onPress={handlePress}
testID={`sidebar-project-row-${project.projectKey}`} testID={`sidebar-project-row-${project.viewKey}`}
> >
{rowChildren} {rowChildren}
</Pressable> </Pressable>
@@ -1622,9 +1622,9 @@ function ProjectBlock({
showShortcutBadges: boolean; showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>; shortcutIndexByWorkspaceKey: Map<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>; parentGestureRef?: MutableRefObject<GestureType | undefined>;
onToggleCollapsed: (projectKey: string) => void; onToggleCollapsed: (projectViewKey: string) => void;
onWorkspacePress?: () => void; onWorkspacePress?: () => void;
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspacePlacement[]) => void; onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
onWorktreeCreated?: (workspaceId: string) => void; onWorktreeCreated?: (workspaceId: string) => void;
drag: () => void; drag: () => void;
isDragging: boolean; isDragging: boolean;
@@ -1725,9 +1725,9 @@ function ProjectBlock({
const handleWorkspaceDragEnd = useCallback( const handleWorkspaceDragEnd = useCallback(
(workspaces: SidebarWorkspacePlacement[]) => { (workspaces: SidebarWorkspacePlacement[]) => {
onWorkspaceReorder(project.projectKey, workspaces); onWorkspaceReorder(project.viewKey, workspaces);
}, },
[onWorkspaceReorder, project.projectKey], [onWorkspaceReorder, project.viewKey],
); );
const toast = useToast(); const toast = useToast();
@@ -1753,7 +1753,6 @@ function ProjectBlock({
setIsRemovingProject(true); setIsRemovingProject(true);
const readiness = getCurrentProjectRemoveReadiness({ const readiness = getCurrentProjectRemoveReadiness({
projectKey: project.projectKey,
hosts: project.hosts, hosts: project.hosts,
}); });
if (readiness.kind === "needs_host_update") { if (readiness.kind === "needs_host_update") {
@@ -1785,11 +1784,11 @@ function ProjectBlock({
setIsRemovingProject(false); setIsRemovingProject(false);
}); });
})(); })();
}, [isRemovingProject, displayName, t, toast, project.projectKey, project.hosts]); }, [isRemovingProject, displayName, t, toast, project.hosts]);
const handleToggleCollapsed = useCallback(() => { const handleToggleCollapsed = useCallback(() => {
onToggleCollapsed(project.projectKey); onToggleCollapsed(project.viewKey);
}, [onToggleCollapsed, project.projectKey]); }, [onToggleCollapsed, project.viewKey]);
let projectChildren = null; let projectChildren = null;
if (!collapsed) { if (!collapsed) {
@@ -1797,7 +1796,7 @@ function ProjectBlock({
projectChildren = ( projectChildren = (
<> <>
<DraggableList <DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`} testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces} data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor} keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace} renderItem={renderWorkspace}
@@ -1813,7 +1812,7 @@ function ProjectBlock({
<SidebarGroupToggleRow <SidebarGroupToggleRow
expanded={workspacesExpanded} expanded={workspacesExpanded}
onPress={toggleWorkspacesExpanded} onPress={toggleWorkspacesExpanded}
testID={`sidebar-project-show-more-${project.projectKey}`} testID={`sidebar-project-show-more-${project.viewKey}`}
/> />
) : null} ) : null}
</> </>
@@ -1925,7 +1924,7 @@ export function SidebarWorkspaceList({
pinnedGroups, pinnedGroups,
projects, projects,
workspaceEntriesByKey, workspaceEntriesByKey,
projectNamesByKey, projectNamesByViewKey,
collapsedProjectKeys, collapsedProjectKeys,
onToggleProjectCollapsed, onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
@@ -1959,7 +1958,7 @@ export function SidebarWorkspaceList({
() => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []), () => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []),
[groupMode, projects], [groupMode, projects],
); );
const statusProjectIconByProjectKey = useProjectIconDataByProjectKey({ const statusProjectIconByProjectViewKey = useProjectIconDataByProjectViewKey({
projects: statusProjectIconTargets, projects: statusProjectIconTargets,
}); });
@@ -1969,8 +1968,8 @@ export function SidebarWorkspaceList({
statusGroups={statusGroups} statusGroups={statusGroups}
pinnedGroups={pinnedGroups} pinnedGroups={pinnedGroups}
workspaceEntriesByKey={workspaceEntriesByKey} workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey} projectNamesByViewKey={projectNamesByViewKey}
projectIconByProjectKey={statusProjectIconByProjectKey} projectIconByProjectViewKey={statusProjectIconByProjectViewKey}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId} hostLabelByServerId={hostLabelByServerId}
@@ -2008,8 +2007,8 @@ function SidebarStatusModeWrapper({
statusGroups, statusGroups,
pinnedGroups, pinnedGroups,
workspaceEntriesByKey, workspaceEntriesByKey,
projectNamesByKey, projectNamesByViewKey,
projectIconByProjectKey, projectIconByProjectViewKey,
shortcutIndexByWorkspaceKey: _projectShortcutIndex, shortcutIndexByWorkspaceKey: _projectShortcutIndex,
onWorkspacePress, onWorkspacePress,
hostLabelByServerId, hostLabelByServerId,
@@ -2021,8 +2020,8 @@ function SidebarStatusModeWrapper({
statusGroups: StatusGroup[]; statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups; pinnedGroups: PinnedSidebarGroups;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>; workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>; projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndexByWorkspaceKey: Map<string, number>; shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void; onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>; hostLabelByServerId: ReadonlyMap<string, string>;
@@ -2040,8 +2039,8 @@ function SidebarStatusModeWrapper({
const entry = workspaceEntriesByKey.get(workspace.workspaceKey); const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
return entry ? [entry] : []; return entry ? [entry] : [];
})} })}
projectNamesByKey={projectNamesByKey} projectNamesByViewKey={projectNamesByViewKey}
projectIconByProjectKey={projectIconByProjectKey} projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndexByWorkspaceKey={_projectShortcutIndex} shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges} showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
@@ -2074,7 +2073,7 @@ function ProjectModeList({
onToggleWorkspacePin, onToggleWorkspacePin,
}: Omit< }: Omit<
SidebarWorkspaceListProps, SidebarWorkspaceListProps,
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh" "statusGroups" | "projectNamesByViewKey" | "groupMode" | "isRefreshing" | "onRefresh"
> & { > & {
pathname: string; pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>; hostLabelByServerId: ReadonlyMap<string, string>;
@@ -2128,7 +2127,7 @@ function ProjectModeList({
[parentGestureRef], [parentGestureRef],
); );
const projectIconByProjectKey = useProjectIconDataByProjectKey({ const projectIconByProjectViewKey = useProjectIconDataByProjectViewKey({
projects: projectIconTargets, projects: projectIconTargets,
}); });
@@ -2180,7 +2179,7 @@ function ProjectModeList({
const handleProjectDragEnd = useCallback( const handleProjectDragEnd = useCallback(
(reorderedProjects: SidebarProjectEntry[]) => { (reorderedProjects: SidebarProjectEntry[]) => {
const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey); const reorderedProjectKeys = reorderedProjects.map((project) => project.viewKey);
const currentProjectOrder = getProjectOrder(); const currentProjectOrder = getProjectOrder();
if ( if (
!hasVisibleOrderChanged({ !hasVisibleOrderChanged({
@@ -2202,9 +2201,9 @@ function ProjectModeList({
); );
const handleWorkspaceReorder = useCallback( const handleWorkspaceReorder = useCallback(
(projectKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
const currentWorkspaceOrder = getWorkspaceOrder(projectKey); const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
if ( if (
!hasVisibleOrderChanged({ !hasVisibleOrderChanged({
currentOrder: currentWorkspaceOrder, currentOrder: currentWorkspaceOrder,
@@ -2215,7 +2214,7 @@ function ProjectModeList({
} }
setWorkspaceOrder( setWorkspaceOrder(
projectKey, projectViewKey,
mergeWithRemainder({ mergeWithRemainder({
currentOrder: currentWorkspaceOrder, currentOrder: currentWorkspaceOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys, reorderedVisibleKeys: reorderedWorkspaceKeys,
@@ -2262,12 +2261,12 @@ function ProjectModeList({
) => { ) => {
return ( return (
<MemoProjectBlock <MemoProjectBlock
key={item.projectKey} key={item.viewKey}
project={item} project={item}
workspaceEntriesByKey={workspaceEntriesByKey} workspaceEntriesByKey={workspaceEntriesByKey}
collapsed={collapsedProjectKeys.has(item.projectKey)} collapsed={collapsedProjectKeys.has(item.viewKey)}
displayName={item.projectName} displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null} iconDataUri={projectIconByProjectViewKey.get(item.viewKey) ?? null}
selectionEnabled={selectionEnabled} selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges} showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
@@ -2303,7 +2302,7 @@ function ProjectModeList({
onWorkspacePress, onWorkspacePress,
onToggleProjectCollapsed, onToggleProjectCollapsed,
parentGestureRef, parentGestureRef,
projectIconByProjectKey, projectIconByProjectViewKey,
selectionEnabled, selectionEnabled,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
@@ -2392,7 +2391,7 @@ function ProjectModeList({
<DraggableList <DraggableList
testID="sidebar-project-list" testID="sidebar-project-list"
data={unpinnedProjects} data={unpinnedProjects}
keyExtractor={projectKeyExtractor} keyExtractor={projectViewKeyExtractor}
renderItem={renderProject} renderItem={renderProject}
onDragEnd={handleProjectDragEnd} onDragEnd={handleProjectDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)} extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
@@ -18,7 +18,7 @@ interface SidebarModel extends SidebarWorkspacesListResult {
statusGroups: StatusGroup[]; statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups; pinnedGroups: PinnedSidebarGroups;
collapsedProjectKeys: ReadonlySet<string>; collapsedProjectKeys: ReadonlySet<string>;
toggleProjectCollapsed: (projectKey: string) => void; toggleProjectCollapsed: (projectViewKey: string) => void;
shortcutModel: SidebarShortcutModel; shortcutModel: SidebarShortcutModel;
} }
@@ -59,7 +59,7 @@ export function SidebarModelProvider({
projects: list.projects, projects: list.projects,
pinnedKeys, pinnedKeys,
workspaceEntriesByKey: projectionWorkspaceEntriesByKey, workspaceEntriesByKey: projectionWorkspaceEntriesByKey,
projectNamesByKey: list.projectNamesByKey, projectNamesByViewKey: list.projectNamesByViewKey,
groupMode, groupMode,
pinnedCollapsed, pinnedCollapsed,
collapsedProjectKeys, collapsedProjectKeys,
@@ -69,7 +69,7 @@ export function SidebarModelProvider({
collapsedProjectKeys, collapsedProjectKeys,
collapsedStatusGroupKeys, collapsedStatusGroupKeys,
groupMode, groupMode,
list.projectNamesByKey, list.projectNamesByViewKey,
list.projects, list.projects,
pinnedCollapsed, pinnedCollapsed,
pinnedKeys, pinnedKeys,
@@ -11,7 +11,7 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu
workspaceKey: `srv:${id}`, workspaceKey: `srv:${id}`,
serverId: "srv", serverId: "srv",
workspaceId: id, workspaceId: id,
projectKey: "project", projectViewKey: "project",
projectName: "Project", projectName: "Project",
projectKind: "git", projectKind: "git",
workspaceKind: "worktree", workspaceKind: "worktree",
@@ -36,7 +36,7 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu
function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry {
return { return {
projectKey: "project", viewKey: "project",
projectName: "Project", projectName: "Project",
projectKind: "git", projectKind: "git",
iconWorkingDir: "/repo", iconWorkingDir: "/repo",
@@ -68,7 +68,7 @@ function projectionInput(options?: {
[pinned.entry.workspaceKey, pinned.entry], [pinned.entry.workspaceKey, pinned.entry],
[unpinned.entry.workspaceKey, unpinned.entry], [unpinned.entry.workspaceKey, unpinned.entry],
]), ]),
projectNamesByKey: new Map([["project", "Project"]]), projectNamesByViewKey: new Map([["project", "Project"]]),
groupMode: options?.groupMode ?? ("project" as const), groupMode: options?.groupMode ?? ("project" as const),
pinnedCollapsed: options?.pinnedCollapsed ?? false, pinnedCollapsed: options?.pinnedCollapsed ?? false,
collapsedProjectKeys: new Set<string>(), collapsedProjectKeys: new Set<string>(),
@@ -25,7 +25,7 @@ export function buildSidebarProjection(input: {
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
pinnedKeys: PinnedSidebarKeys; pinnedKeys: PinnedSidebarKeys;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>; workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
groupMode: SidebarGroupMode; groupMode: SidebarGroupMode;
pinnedCollapsed: boolean; pinnedCollapsed: boolean;
collapsedProjectKeys: ReadonlySet<string>; collapsedProjectKeys: ReadonlySet<string>;
@@ -42,7 +42,7 @@ export function buildSidebarProjection(input: {
Array.from(input.workspaceEntriesByKey.values()).filter( Array.from(input.workspaceEntriesByKey.values()).filter(
(workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey), (workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey),
), ),
input.projectNamesByKey, input.projectNamesByViewKey,
) )
: []; : [];
@@ -61,7 +61,7 @@ export function buildSidebarProjection(input: {
sections.push( sections.push(
...pinnedGroups.unpinnedProjects.map((project) => ({ ...pinnedGroups.unpinnedProjects.map((project) => ({
workspaces: project.workspaces, workspaces: project.workspaces,
collapsed: input.collapsedProjectKeys.has(project.projectKey), collapsed: input.collapsedProjectKeys.has(project.viewKey),
})), })),
); );
} }
@@ -64,8 +64,8 @@ const ThemedCircleX = withUnistyles(CircleX);
interface StatusWorkspaceListProps { interface StatusWorkspaceListProps {
groups: StatusGroup[]; groups: StatusGroup[];
pinnedWorkspaces: SidebarWorkspaceEntry[]; pinnedWorkspaces: SidebarWorkspaceEntry[];
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>; projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndexByWorkspaceKey: Map<string, number>; shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean; showShortcutBadges: boolean;
onWorkspacePress?: () => void; onWorkspacePress?: () => void;
@@ -79,8 +79,8 @@ interface StatusWorkspaceListProps {
export function SidebarStatusWorkspaceList({ export function SidebarStatusWorkspaceList({
groups, groups,
pinnedWorkspaces, pinnedWorkspaces,
projectNamesByKey, projectNamesByViewKey,
projectIconByProjectKey, projectIconByProjectViewKey,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
onWorkspacePress, onWorkspacePress,
@@ -118,8 +118,8 @@ export function SidebarStatusWorkspaceList({
workspace={workspace} workspace={workspace}
{...buildStatusRowProjectPresentation({ {...buildStatusRowProjectPresentation({
workspace, workspace,
projectNamesByKey, projectNamesByViewKey,
projectIconByProjectKey, projectIconByProjectViewKey,
hostLabelByServerId, hostLabelByServerId,
showHostLabels, showHostLabels,
})} })}
@@ -145,8 +145,8 @@ export function SidebarStatusWorkspaceList({
<StatusGroupList <StatusGroupList
groups={groups} groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys} collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey} projectNamesByViewKey={projectNamesByViewKey}
projectIconByProjectKey={projectIconByProjectKey} projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndex={statusShortcutIndex} shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges} showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
@@ -186,8 +186,8 @@ export function SidebarStatusWorkspaceList({
function StatusGroupList({ function StatusGroupList({
groups, groups,
collapsedStatusGroupKeys, collapsedStatusGroupKeys,
projectNamesByKey, projectNamesByViewKey,
projectIconByProjectKey, projectIconByProjectViewKey,
shortcutIndex, shortcutIndex,
showShortcutBadges, showShortcutBadges,
onWorkspacePress, onWorkspacePress,
@@ -198,8 +198,8 @@ function StatusGroupList({
}: { }: {
groups: StatusGroup[]; groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>; collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>; projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndex: Map<string, number>; shortcutIndex: Map<string, number>;
showShortcutBadges: boolean; showShortcutBadges: boolean;
onWorkspacePress?: () => void; onWorkspacePress?: () => void;
@@ -215,8 +215,8 @@ function StatusGroupList({
key={group.bucket} key={group.bucket}
group={group} group={group}
collapsed={collapsedStatusGroupKeys.has(group.bucket)} collapsed={collapsedStatusGroupKeys.has(group.bucket)}
projectNamesByKey={projectNamesByKey} projectNamesByViewKey={projectNamesByViewKey}
projectIconByProjectKey={projectIconByProjectKey} projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndex={shortcutIndex} shortcutIndex={shortcutIndex}
showShortcutBadges={showShortcutBadges} showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
@@ -233,8 +233,8 @@ function StatusGroupList({
function StatusGroupRows({ function StatusGroupRows({
group, group,
collapsed, collapsed,
projectNamesByKey, projectNamesByViewKey,
projectIconByProjectKey, projectIconByProjectViewKey,
shortcutIndex, shortcutIndex,
showShortcutBadges, showShortcutBadges,
onWorkspacePress, onWorkspacePress,
@@ -245,8 +245,8 @@ function StatusGroupRows({
}: { }: {
group: StatusGroup; group: StatusGroup;
collapsed: boolean; collapsed: boolean;
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>; projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndex: Map<string, number>; shortcutIndex: Map<string, number>;
showShortcutBadges: boolean; showShortcutBadges: boolean;
onWorkspacePress?: () => void; onWorkspacePress?: () => void;
@@ -276,8 +276,8 @@ function StatusGroupRows({
workspace={workspace} workspace={workspace}
{...buildStatusRowProjectPresentation({ {...buildStatusRowProjectPresentation({
workspace, workspace,
projectNamesByKey, projectNamesByViewKey,
projectIconByProjectKey, projectIconByProjectViewKey,
hostLabelByServerId, hostLabelByServerId,
showHostLabels, showHostLabels,
})} })}
@@ -324,25 +324,25 @@ interface StatusRowProjectPresentation {
function buildStatusRowProjectPresentation({ function buildStatusRowProjectPresentation({
workspace, workspace,
projectNamesByKey, projectNamesByViewKey,
projectIconByProjectKey, projectIconByProjectViewKey,
hostLabelByServerId, hostLabelByServerId,
showHostLabels, showHostLabels,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>; projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
hostLabelByServerId: ReadonlyMap<string, string>; hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean; showHostLabels: boolean;
}): StatusRowProjectPresentation { }): StatusRowProjectPresentation {
const projectName = projectNamesByKey.get(workspace.projectKey) ?? ""; const projectName = projectNamesByViewKey.get(workspace.projectViewKey) ?? "";
const hostLabel = showHostLabels const hostLabel = showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId) ? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null; : null;
return { return {
subtitle: buildStatusRowSubtitle({ projectName, hostLabel }), subtitle: buildStatusRowSubtitle({ projectName, hostLabel }),
projectName, projectName,
projectIconDataUri: projectIconByProjectKey.get(workspace.projectKey) ?? null, projectIconDataUri: projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null,
}; };
} }
@@ -10,12 +10,12 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp
* the meaning, so the icon itself stays out of the accessibility tree. * the meaning, so the icon itself stays out of the accessibility tree.
*/ */
export function SidebarSubtitleProjectIcon({ export function SidebarSubtitleProjectIcon({
projectKey, projectViewKey,
projectName, projectName,
iconDataUri, iconDataUri,
testID, testID,
}: { }: {
projectKey: string; projectViewKey: string;
projectName: string; projectName: string;
iconDataUri: string | null; iconDataUri: string | null;
testID?: string; testID?: string;
@@ -37,7 +37,7 @@ export function SidebarSubtitleProjectIcon({
<ProjectIconView <ProjectIconView
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
initial={placeholderInitial} initial={placeholderInitial}
projectKey={projectKey} projectViewKey={projectViewKey}
imageStyle={styles.icon} imageStyle={styles.icon}
fallbackStyle={styles.fallback} fallbackStyle={styles.fallback}
textStyle={styles.fallbackText} textStyle={styles.fallbackText}
@@ -157,7 +157,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<View style={styles.workspaceSubtitleRow}> <View style={styles.workspaceSubtitleRow}>
{subtitleProjectName ? ( {subtitleProjectName ? (
<SidebarSubtitleProjectIcon <SidebarSubtitleProjectIcon
projectKey={workspace.projectKey} projectViewKey={workspace.projectViewKey}
projectName={subtitleProjectName} projectName={subtitleProjectName}
iconDataUri={subtitleProjectIconDataUri} iconDataUri={subtitleProjectIconDataUri}
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`} testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
@@ -14,7 +14,7 @@ function ws(
return { return {
serverId: input.serverId ?? "srv", serverId: input.serverId ?? "srv",
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws", workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
projectKey: input.projectKey ?? "proj", projectViewKey: input.projectViewKey ?? "proj",
projectName: input.projectName ?? "Project", projectName: input.projectName ?? "Project",
projectRootPath: input.projectRootPath, projectRootPath: input.projectRootPath,
workspaceDirectory: input.workspaceDirectory, workspaceDirectory: input.workspaceDirectory,
@@ -124,9 +124,24 @@ describe("buildStatusGroups", () => {
]); ]);
const workspaces = [ const workspaces = [
ws({ workspaceKey: "srv:1", statusBucket: "done", projectKey: "proj-b", name: "zebra" }), ws({
ws({ workspaceKey: "srv:2", statusBucket: "done", projectKey: "proj-a", name: "alpha" }), workspaceKey: "srv:1",
ws({ workspaceKey: "srv:3", statusBucket: "done", projectKey: "proj-a", name: "alpha" }), statusBucket: "done",
projectViewKey: "proj-b",
name: "zebra",
}),
ws({
workspaceKey: "srv:2",
statusBucket: "done",
projectViewKey: "proj-a",
name: "alpha",
}),
ws({
workspaceKey: "srv:3",
statusBucket: "done",
projectViewKey: "proj-a",
name: "alpha",
}),
]; ];
const groups = buildStatusGroups(workspaces, projectNames); const groups = buildStatusGroups(workspaces, projectNames);
@@ -26,7 +26,7 @@ export interface StatusGroup {
export function buildStatusGroups( export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[], workspaces: SidebarWorkspaceEntry[],
projectNamesByKey: Map<string, string>, projectNamesByViewKey: Map<string, string>,
): StatusGroup[] { ): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>(); const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
@@ -46,7 +46,7 @@ export function buildStatusGroups(
const rows = bucketRows.get(bucket); const rows = bucketRows.get(bucket);
if (!rows || rows.length === 0) continue; if (!rows || rows.length === 0) continue;
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByKey)); rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey));
groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows }); groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows });
} }
@@ -56,7 +56,7 @@ export function buildStatusGroups(
function compareStatusRows( function compareStatusRows(
a: SidebarWorkspaceEntry, a: SidebarWorkspaceEntry,
b: SidebarWorkspaceEntry, b: SidebarWorkspaceEntry,
projectNamesByKey: Map<string, string>, projectNamesByViewKey: Map<string, string>,
): number { ): number {
const aTime = a.statusEnteredAt?.getTime() ?? null; const aTime = a.statusEnteredAt?.getTime() ?? null;
const bTime = b.statusEnteredAt?.getTime() ?? null; const bTime = b.statusEnteredAt?.getTime() ?? null;
@@ -69,8 +69,8 @@ function compareStatusRows(
return 1; return 1;
} }
const aProject = projectNamesByKey.get(a.projectKey) ?? ""; const aProject = projectNamesByViewKey.get(a.projectViewKey) ?? "";
const bProject = projectNamesByKey.get(b.projectKey) ?? ""; const bProject = projectNamesByViewKey.get(b.projectViewKey) ?? "";
const projectCmp = aProject.localeCompare(bProject); const projectCmp = aProject.localeCompare(bProject);
if (projectCmp !== 0) return projectCmp; if (projectCmp !== 0) return projectCmp;
@@ -83,6 +83,7 @@ function project(input: {
>; >;
}): WorkspaceStructureProject { }): WorkspaceStructureProject {
return { return {
viewKey: input.projectKey,
projectKey: input.projectKey, projectKey: input.projectKey,
projectName: input.projectName ?? input.projectKey, projectName: input.projectName ?? input.projectKey,
projectKind: input.projectKind ?? "git", projectKind: input.projectKind ?? "git",
@@ -227,7 +228,7 @@ describe("buildSidebarProjectsFromStructure", () => {
], ],
}); });
expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]); expect(projects.map((entry) => entry.viewKey)).toEqual(["project-b", "project-a"]);
}); });
it("preserves the structure hook workspace order", () => { it("preserves the structure hook workspace order", () => {
@@ -327,7 +328,7 @@ describe("shared sidebar workspace model", () => {
]); ]);
expect(model.projects).toEqual([ expect(model.projects).toEqual([
expect.objectContaining({ expect.objectContaining({
projectKey: "getpaseo/paseo", viewKey: "getpaseo/paseo",
hosts: [ hosts: [
{ {
serverId: "host-a", serverId: "host-a",
@@ -366,7 +367,7 @@ describe("shared sidebar workspace model", () => {
["host-a:main", "done", "main"], ["host-a:main", "done", "main"],
["host-b:feature", "running", "feature/status-flow"], ["host-b:feature", "running", "feature/status-flow"],
]); ]);
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]])); expect(model.projectNamesByViewKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
}); });
it("preserves unchanged row identities when another workspace updates", () => { it("preserves unchanged row identities when another workspace updates", () => {
@@ -443,7 +444,7 @@ describe("shared sidebar workspace model", () => {
], ],
}); });
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey); expect(entries.get("srv:clone-a")?.projectViewKey).toBe(projectKey);
}); });
}); });
@@ -529,8 +530,8 @@ describe("computeSidebarOrderUpdates", () => {
expect(updates.projectOrder).toEqual(["project-a", "project-b"]); expect(updates.projectOrder).toEqual(["project-a", "project-b"]);
expect(updates.workspaceOrders).toEqual([ expect(updates.workspaceOrders).toEqual([
{ projectKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] }, { projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
{ projectKey: "project-b", order: ["srv:ws-3"] }, { projectViewKey: "project-b", order: ["srv:ws-3"] },
]); ]);
}); });
@@ -550,7 +551,7 @@ describe("computeSidebarOrderUpdates", () => {
expect(updates.workspaceOrders).toEqual([ expect(updates.workspaceOrders).toEqual([
{ {
projectKey: "project-a", projectViewKey: "project-a",
order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"], order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"],
}, },
]); ]);
@@ -19,7 +19,7 @@ export interface SidebarWorkspacePlacement {
workspaceKey: string; workspaceKey: string;
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
projectKey: string; projectViewKey: string;
projectName: string; projectName: string;
projectRootPath?: string; projectRootPath?: string;
workspaceDirectory?: string; workspaceDirectory?: string;
@@ -50,7 +50,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement {
} }
export interface SidebarProjectEntry { export interface SidebarProjectEntry {
projectKey: string; viewKey: string;
projectName: string; projectName: string;
projectKind: WorkspaceDescriptor["projectKind"]; projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string; iconWorkingDir: string;
@@ -61,7 +61,7 @@ export interface SidebarProjectEntry {
export interface SidebarWorkspacePlacementModel { export interface SidebarWorkspacePlacementModel {
workspaces: SidebarWorkspacePlacement[]; workspaces: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
} }
export interface SidebarWorkspaceSession { export interface SidebarWorkspaceSession {
@@ -122,11 +122,11 @@ interface EffectiveWorkspaceStatus {
enteredAt: Date | null; enteredAt: Date | null;
} }
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string { function projectNameForWorkspace(workspace: WorkspaceDescriptor): string {
return ( return (
workspace.projectCustomName ?? workspace.projectCustomName ??
workspace.projectDisplayName ?? workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey) projectDisplayNameFromProjectId(workspace.projectId)
); );
} }
@@ -141,18 +141,18 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
export function createSidebarWorkspaceEntry(input: { export function createSidebarWorkspaceEntry(input: {
serverId: string; serverId: string;
workspace: WorkspaceDescriptor; workspace: WorkspaceDescriptor;
projectKey?: string; projectViewKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>; pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>; workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry { }): SidebarWorkspaceEntry {
const projectKey = input.projectKey ?? input.workspace.projectId; const projectViewKey = input.projectViewKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input); const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return { return {
workspaceKey: `${input.serverId}:${input.workspace.id}`, workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId, serverId: input.serverId,
workspaceId: input.workspace.id, workspaceId: input.workspace.id,
projectKey, projectViewKey,
projectName: projectNameForWorkspace(input.workspace, projectKey), projectName: projectNameForWorkspace(input.workspace),
projectRootPath: input.workspace.projectRootPath, projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory, workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind, projectKind: input.workspace.projectKind,
@@ -228,8 +228,8 @@ export function buildSidebarWorkspacePlacementModel(input: {
return { return {
projects, projects,
workspaces: projects.flatMap((project) => project.workspaces), workspaces: projects.flatMap((project) => project.workspaces),
projectNamesByKey: new Map( projectNamesByViewKey: new Map(
projects.map((project) => [project.projectKey, project.projectName]), projects.map((project) => [project.viewKey, project.projectName]),
), ),
}; };
} }
@@ -247,7 +247,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceKey: identity.workspaceKey, workspaceKey: identity.workspaceKey,
serverId: identity.serverId, serverId: identity.serverId,
workspaceId: identity.workspaceId, workspaceId: identity.workspaceId,
projectKey: input.project.projectKey, projectViewKey: input.project.viewKey,
projectName: input.project.projectName, projectName: input.project.projectName,
projectRootPath: input.project.iconWorkingDir, projectRootPath: input.project.iconWorkingDir,
workspaceDirectory: undefined, workspaceDirectory: undefined,
@@ -324,7 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
const entry = createSidebarWorkspaceEntry({ const entry = createSidebarWorkspaceEntry({
serverId: placement.serverId, serverId: placement.serverId,
workspace, workspace,
projectKey: placement.projectKey, projectViewKey: placement.projectViewKey,
pendingCreateAttempts: input.pendingCreateAttempts, pendingCreateAttempts: input.pendingCreateAttempts,
workspaceAgentActivity: session.workspaceAgentActivity, workspaceAgentActivity: session.workspaceAgentActivity,
}); });
@@ -369,6 +369,7 @@ export function buildSidebarProjectsFromStructure(input: {
}): SidebarProjectEntry[] { }): SidebarProjectEntry[] {
return buildSidebarProjectsFromHostProjects({ return buildSidebarProjectsFromHostProjects({
projects: input.projects.map((project) => ({ projects: input.projects.map((project) => ({
viewKey: project.viewKey,
projectKey: project.projectKey, projectKey: project.projectKey,
projectName: project.projectName, projectName: project.projectName,
projectKind: project.projectKind, projectKind: project.projectKind,
@@ -387,7 +388,7 @@ export function buildSidebarProjectsFromHostProjects(input: {
} }
return input.projects.map((project) => ({ return input.projects.map((project) => ({
projectKey: project.projectKey, viewKey: project.viewKey,
projectName: project.projectName, projectName: project.projectName,
projectKind: project.projectKind, projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir, iconWorkingDir: project.iconWorkingDir,
@@ -498,13 +499,13 @@ export function prependMissingOrderKeys(input: {
export interface SidebarOrderUpdates { export interface SidebarOrderUpdates {
projectOrder: string[] | null; projectOrder: string[] | null;
workspaceOrders: Array<{ projectKey: string; order: string[] }>; workspaceOrders: Array<{ projectViewKey: string; order: string[] }>;
} }
export function computeSidebarOrderUpdates(input: { export function computeSidebarOrderUpdates(input: {
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
persistedProjectOrder: string[]; persistedProjectOrder: string[];
getWorkspaceOrder: (projectKey: string) => string[]; getWorkspaceOrder: (projectViewKey: string) => string[];
}): SidebarOrderUpdates { }): SidebarOrderUpdates {
if (input.projects.length === 0) { if (input.projects.length === 0) {
return { projectOrder: null, workspaceOrders: [] }; return { projectOrder: null, workspaceOrders: [] };
@@ -512,19 +513,19 @@ export function computeSidebarOrderUpdates(input: {
const nextProjectOrder = appendMissingOrderKeys({ const nextProjectOrder = appendMissingOrderKeys({
currentOrder: input.persistedProjectOrder, currentOrder: input.persistedProjectOrder,
visibleKeys: input.projects.map((project) => project.projectKey), visibleKeys: input.projects.map((project) => project.viewKey),
}); });
const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
const workspaceOrders: Array<{ projectKey: string; order: string[] }> = []; const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = [];
for (const project of input.projects) { for (const project of input.projects) {
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey); const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey);
const nextWorkspaceOrder = prependMissingOrderKeys({ const nextWorkspaceOrder = prependMissingOrderKeys({
currentOrder: persistedWorkspaceOrder, currentOrder: persistedWorkspaceOrder,
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
}); });
if (nextWorkspaceOrder !== persistedWorkspaceOrder) { if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
workspaceOrders.push({ projectKey: project.projectKey, order: nextWorkspaceOrder }); workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder });
} }
} }
@@ -10,7 +10,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement {
workspaceKey, workspaceKey,
serverId: "s1", serverId: "s1",
workspaceId: workspaceKey, workspaceId: workspaceKey,
projectKey: "p1", projectViewKey: "p1",
projectName: "Project 1", projectName: "Project 1",
projectKind: "git", projectKind: "git",
workspaceKind: "worktree", workspaceKind: "worktree",
@@ -20,7 +20,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement {
function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry {
return { return {
projectKey, viewKey: projectKey,
projectName: projectKey, projectName: projectKey,
projectKind: "git", projectKind: "git",
iconWorkingDir: "", iconWorkingDir: "",
@@ -17,7 +17,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
workspaceKey: `srv:${workspaceId}`, workspaceKey: `srv:${workspaceId}`,
serverId: "srv", serverId: "srv",
workspaceId, workspaceId,
projectKey, projectViewKey: projectKey,
projectName: projectKey, projectName: projectKey,
projectRootPath: `/repo/${projectKey}`, projectRootPath: `/repo/${projectKey}`,
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`, workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
@@ -40,7 +40,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
function project(projectKey: string): SidebarProjectEntry { function project(projectKey: string): SidebarProjectEntry {
return { return {
projectKey, viewKey: projectKey,
projectName: projectKey, projectName: projectKey,
projectKind: "git", projectKind: "git",
iconWorkingDir: `/repo/${projectKey}`, iconWorkingDir: `/repo/${projectKey}`,
@@ -41,7 +41,7 @@ const EMPTY_PROJECT_NAMES = new Map<string, string>();
export interface SidebarWorkspacesListResult { export interface SidebarWorkspacesListResult {
workspacePlacements: SidebarWorkspacePlacement[]; workspacePlacements: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
isLoading: boolean; isLoading: boolean;
isInitialLoad: boolean; isInitialLoad: boolean;
isRevalidating: boolean; isRevalidating: boolean;
@@ -100,23 +100,25 @@ export function useSidebarWorkspacesList(options?: {
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS; const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
const workspacePlacements = const workspacePlacements =
sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES; sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES;
const projectNamesByKey = const projectNamesByViewKey =
sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES; sidebarModel.projectNamesByViewKey.size > 0
? sidebarModel.projectNamesByViewKey
: EMPTY_PROJECT_NAMES;
useEffect(() => { useEffect(() => {
const orderStore = useSidebarOrderStore.getState(); const orderStore = useSidebarOrderStore.getState();
const updates = computeSidebarOrderUpdates({ const updates = computeSidebarOrderUpdates({
projects, projects,
persistedProjectOrder, persistedProjectOrder,
getWorkspaceOrder: (projectKey) => getWorkspaceOrder: (projectViewKey) =>
orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER, orderStore.workspaceOrderByProject[projectViewKey] ?? EMPTY_ORDER,
}); });
if (updates.projectOrder) { if (updates.projectOrder) {
orderStore.setProjectOrder(updates.projectOrder); orderStore.setProjectOrder(updates.projectOrder);
} }
for (const { projectKey, order } of updates.workspaceOrders) { for (const { projectViewKey, order } of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(projectKey, order); orderStore.setWorkspaceOrder(projectViewKey, order);
} }
}, [persistedProjectOrder, projects]); }, [persistedProjectOrder, projects]);
@@ -144,7 +146,7 @@ export function useSidebarWorkspacesList(options?: {
return { return {
workspacePlacements, workspacePlacements,
projects, projects,
projectNamesByKey, projectNamesByViewKey,
...loadingState, ...loadingState,
refreshAll, refreshAll,
}; };
+81 -21
View File
@@ -3,6 +3,7 @@ import type {
WorkspaceStructureHostPlacement, WorkspaceStructureHostPlacement,
WorkspaceStructureProject, WorkspaceStructureProject,
} from "@/projects/workspace-structure"; } from "@/projects/workspace-structure";
import { createProjectViewKey } from "@/projects/workspace-structure";
export type HostProjectListItem = WorkspaceStructureProject; export type HostProjectListItem = WorkspaceStructureProject;
@@ -31,7 +32,8 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
return null; return null;
} }
return { return {
projectKey: projectId, viewKey: createProjectViewKey({ kind: "placement", serverId: route.serverId, projectId }),
projectKey: null,
projectName: trimOptional(route.displayName) || projectId, projectName: trimOptional(route.displayName) || projectId,
projectKind: "git", projectKind: "git",
iconWorkingDir, iconWorkingDir,
@@ -64,7 +66,12 @@ export function hostProjectFromWorkspace(input: {
} }
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind); const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
return { return {
projectKey: projectId, viewKey: createProjectViewKey({
kind: "placement",
serverId: input.serverId,
projectId,
}),
projectKey: null,
projectName: input.workspace.projectDisplayName || projectId, projectName: input.workspace.projectDisplayName || projectId,
projectKind: input.workspace.projectKind, projectKind: input.workspace.projectKind,
iconWorkingDir, iconWorkingDir,
@@ -131,6 +138,67 @@ export function filterWorkspaceProjectsForHost(input: {
); );
} }
function compareHostProjectsForServer(
left: HostProjectListItem,
right: HostProjectListItem,
serverId: string,
): number {
return (
left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
}) ||
(getHostProjectId(left, serverId) ?? "").localeCompare(
getHostProjectId(right, serverId) ?? "",
) ||
left.viewKey.localeCompare(right.viewKey)
);
}
export function resolveExactHostProjectCandidate(input: {
candidate: HostProjectListItem;
projects: readonly HostProjectListItem[];
serverId: string;
}): HostProjectListItem | null {
const candidatePlacement = getHostProjectId(input.candidate, input.serverId);
if (candidatePlacement) {
const exactPlacement = input.projects.find(
(project) => getHostProjectId(project, input.serverId) === candidatePlacement,
);
if (exactPlacement) return exactPlacement;
}
const exactView = input.projects.find((project) => project.viewKey === input.candidate.viewKey);
if (input.candidate.projectKey !== null && exactView?.projectKey === input.candidate.projectKey) {
return exactView;
}
return null;
}
export function resolveEquivalentHostProjectCandidate(input: {
candidate: HostProjectListItem;
projects: readonly HostProjectListItem[];
serverId: string;
}): HostProjectListItem | null {
if (input.candidate.projectKey === null) return null;
const equivalents = input.projects.filter(
(project) => project.projectKey === input.candidate.projectKey,
);
if (equivalents.length === 0) return null;
return equivalents.toSorted((left, right) =>
compareHostProjectsForServer(left, right, input.serverId),
)[0]!;
}
export function resolveHostProjectCandidate(input: {
candidate: HostProjectListItem;
projects: readonly HostProjectListItem[];
serverId: string;
}): HostProjectListItem | null {
return resolveExactHostProjectCandidate(input) ?? resolveEquivalentHostProjectCandidate(input);
}
export function resolveInitialWorkspaceProject(input: { export function resolveInitialWorkspaceProject(input: {
routeProject: HostProjectListItem | null; routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null; lastActiveProject: HostProjectListItem | null;
@@ -143,20 +211,12 @@ export function resolveInitialWorkspaceProject(input: {
if (!candidate) { if (!candidate) {
continue; continue;
} }
const candidatePlacement = candidate.hosts.find(
(host) => host.serverId === input.serverId && host.projectId,
);
const hydratedProject = const hydratedProject =
(candidatePlacement resolveHostProjectCandidate({
? input.projects.find((project) => candidate,
project.hosts.some( projects: input.projects,
(host) => serverId: input.serverId,
host.serverId === input.serverId && host.projectId === candidatePlacement.projectId, }) ?? candidate;
),
)
: undefined) ??
input.projects.find((project) => project.projectKey === candidate.projectKey) ??
candidate;
if ( if (
canCreateWorkspaceForHostProject({ canCreateWorkspaceForHostProject({
project: hydratedProject, project: hydratedProject,
@@ -186,19 +246,19 @@ export function resolveInitialWorktreeProject(input: {
} }
export function resolveSelectedHostProject(input: { export function resolveSelectedHostProject(input: {
selectedProjectKey: string | null; selectedViewKey: string | null;
projects: readonly HostProjectListItem[]; projects: readonly HostProjectListItem[];
routeProject: HostProjectListItem | null; routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null; lastActiveProject: HostProjectListItem | null;
}): HostProjectListItem | null { }): HostProjectListItem | null {
const selectedProjectKey = input.selectedProjectKey?.trim() ?? ""; const selectedViewKey = input.selectedViewKey?.trim() ?? "";
if (!selectedProjectKey) { if (!selectedViewKey) {
return null; return null;
} }
return ( return (
input.projects.find((project) => project.projectKey === selectedProjectKey) ?? input.projects.find((project) => project.viewKey === selectedViewKey) ??
(input.routeProject?.projectKey === selectedProjectKey ? input.routeProject : null) ?? (input.routeProject?.viewKey === selectedViewKey ? input.routeProject : null) ??
(input.lastActiveProject?.projectKey === selectedProjectKey ? input.lastActiveProject : null) (input.lastActiveProject?.viewKey === selectedViewKey ? input.lastActiveProject : null)
); );
} }
@@ -9,6 +9,7 @@ import {
function project(): HostProjectListItem { function project(): HostProjectListItem {
return { return {
viewKey: "view:acme/app",
projectKey: "remote:github.com/acme/app", projectKey: "remote:github.com/acme/app",
projectName: "acme/app", projectName: "acme/app",
projectKind: "git", projectKind: "git",
@@ -57,7 +58,7 @@ describe("host project lookups", () => {
sourceDirectory: "/repo/a", sourceDirectory: "/repo/a",
}), }),
).toMatchObject({ ).toMatchObject({
projectKey: "prj_a", projectKey: null,
hosts: [{ serverId: "host-a", projectId: "prj_a" }], hosts: [{ serverId: "host-a", projectId: "prj_a" }],
}); });
}); });
@@ -9,6 +9,9 @@ export {
getHostProjectId, getHostProjectId,
hostProjectFromRoute, hostProjectFromRoute,
hostProjectFromWorkspace, hostProjectFromWorkspace,
resolveHostProjectCandidate,
resolveExactHostProjectCandidate,
resolveEquivalentHostProjectCandidate,
resolveInitialWorkspaceProject, resolveInitialWorkspaceProject,
resolveInitialWorktreeProject, resolveInitialWorktreeProject,
resolveSelectedHostProject, resolveSelectedHostProject,
+4 -4
View File
@@ -5,7 +5,7 @@ import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-i
export interface ProjectIconRequestTarget { export interface ProjectIconRequestTarget {
serverId: string; serverId: string;
projectKey: string; projectViewKey: string;
iconWorkingDir: string; iconWorkingDir: string;
} }
@@ -20,7 +20,7 @@ function useStableProjectIconData(
return stableRef.current.data; return stableRef.current.data;
} }
export function useProjectIconDataByProjectKey(input: { export function useProjectIconDataByProjectViewKey(input: {
projects: readonly ProjectIconRequestTarget[]; projects: readonly ProjectIconRequestTarget[];
}): Map<string, string | null> { }): Map<string, string | null> {
const projectIconRequests = useMemo(() => { const projectIconRequests = useMemo(() => {
@@ -80,11 +80,11 @@ export function useProjectIconDataByProjectKey(input: {
for (const project of input.projects) { for (const project of input.projects) {
const cwd = project.iconWorkingDir.trim(); const cwd = project.iconWorkingDir.trim();
if (!cwd) { if (!cwd) {
byProject.set(project.projectKey, null); byProject.set(project.projectViewKey, null);
continue; continue;
} }
byProject.set( byProject.set(
project.projectKey, project.projectViewKey,
iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null, iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null,
); );
} }
@@ -6,7 +6,6 @@ import {
} from "./project-remove"; } from "./project-remove";
const project: ProjectRemoveProject = { const project: ProjectRemoveProject = {
projectKey: "remote:github.com/acme/app",
hosts: [ hosts: [
{ serverId: "host-a", projectId: "prj_host_a" }, { serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" }, { serverId: "host-b", projectId: "prj_host_b" },
@@ -8,7 +8,6 @@ interface ProjectRemoveHost {
} }
export interface ProjectRemoveProject { export interface ProjectRemoveProject {
projectKey: string;
hosts: readonly ProjectRemoveHost[]; hosts: readonly ProjectRemoveHost[];
} }
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildWorkspaceStructureProjects } from "./workspace-structure"; import { buildWorkspaceStructureProjects, createProjectViewKey } from "./workspace-structure";
function project(input: { function project(input: {
id: string; id: string;
@@ -57,6 +57,7 @@ describe("buildWorkspaceStructureProjects", () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ expect(result[0]).toMatchObject({
viewKey: key,
projectKey: key, projectKey: key,
hosts: [ hosts: [
{ serverId: "host-a", projectId: "prj_a" }, { serverId: "host-a", projectId: "prj_a" },
@@ -85,6 +86,7 @@ describe("buildWorkspaceStructureProjects", () => {
}); });
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result.map((item) => item.projectKey)).toEqual([key, key]);
expect(result.map((item) => item.hosts[0]?.projectId).sort()).toEqual(["prj_one", "prj_two"]); expect(result.map((item) => item.hosts[0]?.projectId).sort()).toEqual(["prj_one", "prj_two"]);
expect(result.map((item) => item.workspaceKeys[0]).sort()).toEqual([ expect(result.map((item) => item.workspaceKeys[0]).sort()).toEqual([
"host-a:ws-one", "host-a:ws-one",
@@ -112,7 +114,7 @@ describe("buildWorkspaceStructureProjects", () => {
const identitiesByProjectId = (projects: ReturnType<typeof build>) => { const identitiesByProjectId = (projects: ReturnType<typeof build>) => {
const identities: Record<string, string> = {}; const identities: Record<string, string> = {};
for (const group of projects) { for (const group of projects) {
for (const host of group.hosts) identities[host.projectId] = group.projectKey; for (const host of group.hosts) identities[host.projectId] = group.viewKey;
} }
return identities; return identities;
}; };
@@ -142,6 +144,33 @@ describe("buildWorkspaceStructureProjects", () => {
}); });
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(new Set(result.map((item) => item.projectKey)).size).toBe(2); expect(result.map((item) => item.projectKey)).toEqual([null, null]);
expect(new Set(result.map((item) => item.viewKey)).size).toBe(2);
});
test("keeps opaque project keys separate from placement view keys", () => {
const placementShapedKey = createProjectViewKey({
kind: "placement",
serverId: "host-a",
projectId: "prj_b",
});
const result = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: [
project({ id: "prj_a", key: placementShapedKey, root: "/repos/a" }),
project({ id: "prj_b", key: null, root: "/repos/b" }),
],
workspaces: [],
},
],
});
expect(result).toHaveLength(2);
expect(new Set(result.map((item) => item.viewKey)).size).toBe(2);
expect(result.find((item) => item.projectKey === placementShapedKey)?.viewKey).toBe(
placementShapedKey,
);
}); });
}); });
@@ -9,7 +9,8 @@ export interface WorkspaceStructureHostPlacement {
} }
export interface WorkspaceStructureProject { export interface WorkspaceStructureProject {
projectKey: string; viewKey: string;
projectKey: string | null;
projectName: string; projectName: string;
projectKind: WorkspaceDescriptor["projectKind"]; projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string; iconWorkingDir: string;
@@ -28,7 +29,8 @@ interface WorkspaceStructureSession {
} }
interface ProjectDraft { interface ProjectDraft {
projectKey: string; viewKey: string;
projectKey: string | null;
projectName: string; projectName: string;
hasCustomName: boolean; hasCustomName: boolean;
projectKind: WorkspaceDescriptor["projectKind"]; projectKind: WorkspaceDescriptor["projectKind"];
@@ -57,19 +59,29 @@ export function buildWorkspaceStructureProjects(input: {
} }
} }
const allocatedViewKeys = new Set(
projectEntries.flatMap(({ project }) => (project.projectKey ? [project.projectKey] : [])),
);
for (const { serverId, project } of projectEntries) { for (const { serverId, project } of projectEntries) {
const projectKey = addProjectToView({ byProject, keyCountsByServer, serverId, project }); const viewKey = addProjectToView({
byProject,
keyCountsByServer,
allocatedViewKeys,
serverId,
project,
});
getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set( getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set(
project.projectId, project.projectId,
projectKey, viewKey,
); );
} }
for (const session of input.sessions) { for (const session of input.sessions) {
for (const workspace of session.workspaces) { for (const workspace of session.workspaces) {
const projectKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId);
if (!projectKey) continue; if (!viewKey) continue;
byProject.get(projectKey)?.workspaces.push({ byProject.get(viewKey)?.workspaces.push({
workspaceId: workspace.id, workspaceId: workspace.id,
workspaceName: workspace.name, workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`, workspaceKey: `${session.serverId}:${workspace.id}`,
@@ -79,6 +91,7 @@ export function buildWorkspaceStructureProjects(input: {
return Array.from(byProject.values()) return Array.from(byProject.values())
.map((draft) => ({ .map((draft) => ({
viewKey: draft.viewKey,
projectKey: draft.projectKey, projectKey: draft.projectKey,
projectName: draft.projectName, projectName: draft.projectName,
projectKind: draft.projectKind, projectKind: draft.projectKind,
@@ -88,17 +101,48 @@ export function buildWorkspaceStructureProjects(input: {
.sort(compareWorkspaceStructureItems) .sort(compareWorkspaceStructureItems)
.map((workspace) => workspace.workspaceKey), .map((workspace) => workspace.workspaceKey),
})) }))
.sort((left, right) => .sort(
left.projectName.localeCompare(right.projectName, undefined, { (left, right) =>
numeric: true, left.projectName.localeCompare(right.projectName, undefined, {
sensitivity: "base", numeric: true,
}), sensitivity: "base",
}) || left.viewKey.localeCompare(right.viewKey),
); );
} }
export function createProjectViewKey(
identity:
| { kind: "equivalence"; projectKey: string }
| { kind: "placement"; serverId: string; projectId: string },
): string {
return identity.kind === "equivalence"
? identity.projectKey
: JSON.stringify([identity.serverId, identity.projectId]);
}
function allocatePlacementViewKey(
allocatedViewKeys: Set<string>,
serverId: string,
projectId: string,
): string {
const legacyKey = createProjectViewKey({ kind: "placement", serverId, projectId });
if (!allocatedViewKeys.has(legacyKey)) {
allocatedViewKeys.add(legacyKey);
return legacyKey;
}
for (let suffix = 0; ; suffix += 1) {
const collisionKey = JSON.stringify(["placement", serverId, projectId, suffix]);
if (allocatedViewKeys.has(collisionKey)) continue;
allocatedViewKeys.add(collisionKey);
return collisionKey;
}
}
function addProjectToView(input: { function addProjectToView(input: {
byProject: Map<string, ProjectDraft>; byProject: Map<string, ProjectDraft>;
keyCountsByServer: Map<string, Map<string, number>>; keyCountsByServer: Map<string, Map<string, number>>;
allocatedViewKeys: Set<string>;
serverId: string; serverId: string;
project: ProjectDescriptor; project: ProjectDescriptor;
}): string { }): string {
@@ -106,17 +150,20 @@ function addProjectToView(input: {
const sharedKey = project.projectKey ?? null; const sharedKey = project.projectKey ?? null;
const canUseSharedKey = const canUseSharedKey =
sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1; sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1;
const projectKey = canUseSharedKey ? sharedKey : JSON.stringify([serverId, project.projectId]); const viewKey = canUseSharedKey
? createProjectViewKey({ kind: "equivalence", projectKey: sharedKey })
: allocatePlacementViewKey(input.allocatedViewKeys, serverId, project.projectId);
const placement: WorkspaceStructureHostPlacement = { const placement: WorkspaceStructureHostPlacement = {
serverId, serverId,
projectId: project.projectId, projectId: project.projectId,
iconWorkingDir: project.projectRootPath, iconWorkingDir: project.projectRootPath,
canCreateWorktree: project.projectKind === "git", canCreateWorktree: project.projectKind === "git",
}; };
const draft = byProject.get(projectKey); const draft = byProject.get(viewKey);
if (!draft) { if (!draft) {
byProject.set(projectKey, { byProject.set(viewKey, {
projectKey, viewKey,
projectKey: sharedKey,
projectName: projectName:
project.projectCustomName ?? project.projectCustomName ??
project.projectDisplayName ?? project.projectDisplayName ??
@@ -134,7 +181,7 @@ function addProjectToView(input: {
} }
draft.hosts.set(serverId, placement); draft.hosts.set(serverId, placement);
} }
return projectKey; return viewKey;
} }
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V { function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
@@ -54,7 +54,7 @@ function target(input: {
optionId: buildProjectOptionId(input.serverId, input.projectKey), optionId: buildProjectOptionId(input.serverId, input.projectKey),
serverId: input.serverId, serverId: input.serverId,
serverName: input.serverId === "host-a" ? "Host A" : "Host B", serverName: input.serverId === "host-a" ? "Host A" : "Host B",
projectKey: input.projectKey, projectViewKey: input.projectKey,
projectName: input.projectName, projectName: input.projectName,
cwd: input.cwd, cwd: input.cwd,
isGit: input.isGit ?? true, isGit: input.isGit ?? true,
@@ -8,7 +8,7 @@ import {
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary { function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
return { return {
projectKey: "proj", viewKey: "proj",
projectName: "Project", projectName: "Project",
hosts: [], hosts: [],
totalWorkspaceCount: 0, totalWorkspaceCount: 0,
@@ -7,14 +7,14 @@ export interface ScheduleProjectTarget {
optionId: string; optionId: string;
serverId: string; serverId: string;
serverName: string; serverName: string;
projectKey: string; projectViewKey: string;
projectName: string; projectName: string;
cwd: string; cwd: string;
isGit: boolean; isGit: boolean;
} }
export function buildProjectOptionId(serverId: string, projectKey: string): string { export function buildProjectOptionId(serverId: string, projectViewKey: string): string {
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`; return `${PROJECT_OPTION_PREFIX}${serverId}:${projectViewKey}`;
} }
/** /**
@@ -33,10 +33,10 @@ export function buildScheduleProjectTargets(
continue; continue;
} }
targets.push({ targets.push({
optionId: buildProjectOptionId(host.serverId, project.projectKey), optionId: buildProjectOptionId(host.serverId, project.viewKey),
serverId: host.serverId, serverId: host.serverId,
serverName: host.serverName, serverName: host.serverName,
projectKey: project.projectKey, projectViewKey: project.viewKey,
projectName: host.projectName, projectName: host.projectName,
cwd, cwd,
isGit: Boolean(host.gitRuntime), isGit: Boolean(host.gitRuntime),
@@ -17,7 +17,7 @@ function projectTarget(input: {
optionId: buildProjectOptionId("host-a", input.projectKey), optionId: buildProjectOptionId("host-a", input.projectKey),
serverId: "host-a", serverId: "host-a",
serverName: "Host A", serverName: "Host A",
projectKey: input.projectKey, projectViewKey: input.projectKey,
projectName: input.projectName, projectName: input.projectName,
cwd: input.cwd, cwd: input.cwd,
isGit: true, isGit: true,
@@ -8,6 +8,7 @@ import {
function projectFor(serverId: string, key = "project"): HostProjectListItem { function projectFor(serverId: string, key = "project"): HostProjectListItem {
return { return {
viewKey: `view:${serverId}:${key}`,
projectKey: key, projectKey: key,
projectName: key, projectName: key,
projectKind: "git", projectKind: "git",
@@ -1,5 +1,7 @@
import { import {
canCreateWorkspaceForHostProject, canCreateWorkspaceForHostProject,
filterWorkspaceProjectsForHost,
resolveHostProjectCandidate,
type HostProjectListItem, type HostProjectListItem,
} from "@/projects/host-projects"; } from "@/projects/host-projects";
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
@@ -25,35 +27,31 @@ function supportsAllProjects(
return workspaceMultiplicityByServerId.get(serverId) === true; return workspaceMultiplicityByServerId.get(serverId) === true;
} }
function getProjectForServer(input: {
candidate: HostProjectListItem;
projects: readonly HostProjectListItem[];
serverId: string;
}) {
return (
input.projects.find(
(project) =>
project.projectKey === input.candidate.projectKey &&
project.hosts.some((host) => host.serverId === input.serverId),
) ?? input.candidate
);
}
function canUseProjectForServer(input: { function canUseProjectForServer(input: {
project: HostProjectListItem; project: HostProjectListItem;
projects: readonly HostProjectListItem[]; projects: readonly HostProjectListItem[];
serverId: string; serverId: string;
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>; workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) { }) {
const project = getProjectForServer({ const allowAllProjects = supportsAllProjects(
candidate: input.project, input.workspaceMultiplicityByServerId,
input.serverId,
);
const selectableProjects = filterWorkspaceProjectsForHost({
projects: input.projects, projects: input.projects,
serverId: input.serverId, serverId: input.serverId,
allowAllProjects,
}); });
const project =
resolveHostProjectCandidate({
candidate: input.project,
projects: selectableProjects,
serverId: input.serverId,
}) ?? input.project;
return canCreateWorkspaceForHostProject({ return canCreateWorkspaceForHostProject({
project, project,
serverId: input.serverId, serverId: input.serverId,
allowAllProjects: supportsAllProjects(input.workspaceMultiplicityByServerId, input.serverId), allowAllProjects,
}); });
} }
@@ -72,7 +72,7 @@ import {
useHostProjects, useHostProjects,
type HostProjectListItem, type HostProjectListItem,
} from "@/projects/host-projects"; } from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
@@ -273,7 +273,7 @@ function ProjectPickerTrigger({
disabled, disabled,
badgePressableStyle, badgePressableStyle,
label, label,
projectKey, projectViewKey,
iconDataUri, iconDataUri,
iconColor, iconColor,
iconSize, iconSize,
@@ -283,7 +283,7 @@ function ProjectPickerTrigger({
disabled: boolean; disabled: boolean;
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"]; badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
label: string; label: string;
projectKey: string | null; projectViewKey: string | null;
iconDataUri: string | null; iconDataUri: string | null;
iconColor: string; iconColor: string;
iconSize: number; iconSize: number;
@@ -303,11 +303,11 @@ function ProjectPickerTrigger({
accessibilityLabel="Workspace project" accessibilityLabel="Workspace project"
> >
<View style={styles.badgeIconBox}> <View style={styles.badgeIconBox}>
{projectKey ? ( {projectViewKey ? (
<ProjectIconView <ProjectIconView
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
initial={placeholderInitial} initial={placeholderInitial}
projectKey={projectKey} projectViewKey={projectViewKey}
imageStyle={styles.projectIcon} imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback} fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText} textStyle={styles.projectIconFallbackText}
@@ -423,7 +423,7 @@ function IsolationOptionItem({
function ProjectOptionItem({ function ProjectOptionItem({
testID, testID,
projectKey, projectViewKey,
iconDataUri, iconDataUri,
label, label,
description, description,
@@ -433,7 +433,7 @@ function ProjectOptionItem({
onPress, onPress,
}: { }: {
testID: string; testID: string;
projectKey: string; projectViewKey: string;
iconDataUri: string | null; iconDataUri: string | null;
label: string; label: string;
description: string | undefined; description: string | undefined;
@@ -450,14 +450,14 @@ function ProjectOptionItem({
<ProjectIconView <ProjectIconView
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
initial={placeholderInitial} initial={placeholderInitial}
projectKey={projectKey} projectViewKey={projectViewKey}
imageStyle={styles.projectIcon} imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback} fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText} textStyle={styles.projectIconFallbackText}
/> />
</View> </View>
), ),
[iconDataUri, placeholderInitial, projectKey], [iconDataUri, placeholderInitial, projectViewKey],
); );
return ( return (
@@ -533,7 +533,7 @@ function NewWorkspaceProjectPickerOption({
active, active,
onPress, onPress,
projectByOptionId, projectByOptionId,
projectIconDataByProjectKey, projectIconDataByProjectViewKey,
selectedServerId, selectedServerId,
isPending, isPending,
supportsWorkspaceMultiplicity, supportsWorkspaceMultiplicity,
@@ -543,7 +543,7 @@ function NewWorkspaceProjectPickerOption({
active: boolean; active: boolean;
onPress: () => void; onPress: () => void;
projectByOptionId: Map<string, HostProjectListItem>; projectByOptionId: Map<string, HostProjectListItem>;
projectIconDataByProjectKey: Map<string, string | null>; projectIconDataByProjectViewKey: Map<string, string | null>;
selectedServerId: string; selectedServerId: string;
isPending: boolean; isPending: boolean;
supportsWorkspaceMultiplicity: boolean; supportsWorkspaceMultiplicity: boolean;
@@ -555,9 +555,9 @@ function NewWorkspaceProjectPickerOption({
return ( return (
<ProjectOptionItem <ProjectOptionItem
testID={`new-workspace-project-picker-option-${project.projectKey}`} testID={`new-workspace-project-picker-option-${project.viewKey}`}
projectKey={project.projectKey} projectViewKey={project.viewKey}
iconDataUri={projectIconDataByProjectKey.get(project.projectKey) ?? null} iconDataUri={projectIconDataByProjectViewKey.get(project.viewKey) ?? null}
label={project.projectName} label={project.projectName}
description={sourceDirectory} description={sourceDirectory}
selected={selected} selected={selected}
@@ -1305,7 +1305,7 @@ interface NewWorkspaceFormStackInput {
options: ComboboxOptionType[]; options: ComboboxOptionType[];
triggerLabel: string; triggerLabel: string;
selectedProject: HostProjectListItem | null; selectedProject: HostProjectListItem | null;
iconDataByProjectKey: Map<string, string | null>; iconDataByProjectViewKey: Map<string, string | null>;
selectedOptionId: string; selectedOptionId: string;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onAddProject: () => void; onAddProject: () => void;
@@ -1369,10 +1369,10 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
disabled={isPending} disabled={isPending}
badgePressableStyle={badgePressableStyle} badgePressableStyle={badgePressableStyle}
label={project.triggerLabel} label={project.triggerLabel}
projectKey={project.selectedProject?.projectKey ?? null} projectViewKey={project.selectedProject?.viewKey ?? null}
iconDataUri={ iconDataUri={
project.selectedProject project.selectedProject
? (project.iconDataByProjectKey.get(project.selectedProject.projectKey) ?? null) ? (project.iconDataByProjectViewKey.get(project.selectedProject.viewKey) ?? null)
: null : null
} }
iconColor={theme.colors.foregroundMuted} iconColor={theme.colors.foregroundMuted}
@@ -1592,12 +1592,12 @@ export function NewWorkspaceScreen({
if (!iconWorkingDir) { if (!iconWorkingDir) {
return []; return [];
} }
return [{ projectKey: project.projectKey, serverId: selectedServerId, iconWorkingDir }]; return [{ projectViewKey: project.viewKey, serverId: selectedServerId, iconWorkingDir }];
}), }),
[projects, selectedServerId], [projects, selectedServerId],
); );
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ const projectIconDataByProjectViewKey = useProjectIconDataByProjectViewKey({
projects: projectIconTargets, projects: projectIconTargets,
}); });
const draftKey = buildNewWorkspaceDraftKey(draftId); const draftKey = buildNewWorkspaceDraftKey(draftId);
@@ -2039,7 +2039,7 @@ export function NewWorkspaceScreen({
<NewWorkspaceProjectPickerOption <NewWorkspaceProjectPickerOption
{...props} {...props}
projectByOptionId={projectByOptionId} projectByOptionId={projectByOptionId}
projectIconDataByProjectKey={projectIconDataByProjectKey} projectIconDataByProjectViewKey={projectIconDataByProjectViewKey}
selectedServerId={selectedServerId} selectedServerId={selectedServerId}
isPending={isPending} isPending={isPending}
supportsWorkspaceMultiplicity={supportsWorkspaceMultiplicity} supportsWorkspaceMultiplicity={supportsWorkspaceMultiplicity}
@@ -2048,7 +2048,7 @@ export function NewWorkspaceScreen({
[ [
isPending, isPending,
projectByOptionId, projectByOptionId,
projectIconDataByProjectKey, projectIconDataByProjectViewKey,
selectedServerId, selectedServerId,
supportsWorkspaceMultiplicity, supportsWorkspaceMultiplicity,
], ],
@@ -2093,7 +2093,7 @@ export function NewWorkspaceScreen({
options: projectPickerOptions, options: projectPickerOptions,
triggerLabel: projectTriggerLabel, triggerLabel: projectTriggerLabel,
selectedProject, selectedProject,
iconDataByProjectKey: projectIconDataByProjectKey, iconDataByProjectViewKey: projectIconDataByProjectViewKey,
selectedOptionId: selectedProjectOptionId, selectedOptionId: selectedProjectOptionId,
onSelect: handleSelectProjectOption, onSelect: handleSelectProjectOption,
onAddProject: handleAddProject, onAddProject: handleAddProject,
@@ -45,7 +45,7 @@ function projectOptionId(projectId: string): string {
function computeProjectOptionData(projects: readonly HostProjectListItem[]) { function computeProjectOptionData(projects: readonly HostProjectListItem[]) {
const projectByOptionId = new Map<string, HostProjectListItem>(); const projectByOptionId = new Map<string, HostProjectListItem>();
const options = projects.map((project) => { const options = projects.map((project) => {
const id = projectOptionId(project.projectKey); const id = projectOptionId(project.viewKey);
projectByOptionId.set(id, project); projectByOptionId.set(id, project);
return { id, label: project.projectName }; return { id, label: project.projectName };
}); });
@@ -104,15 +104,14 @@ export function useNewWorkspaceProjectPicker({
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId], [allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
); );
const routeProjectKey = routeProject?.projectKey ?? null; const routeProjectViewKey = routeProject?.viewKey ?? null;
const selectionContextKey = createProjectSelectionContextKey({ const selectionContextKey = createProjectSelectionContextKey({
selectedServerId, selectedServerId,
routeProjectKey, routeProjectViewKey,
allowAllProjects, allowAllProjects,
}); });
const manualSelectionContextKey = createManualProjectSelectionContextKey({ const manualSelectionContextKey = createManualProjectSelectionContextKey({
selectedServerId, routeProjectViewKey,
routeProjectKey,
}); });
const shouldPreserveMissingProject = useCallback( const shouldPreserveMissingProject = useCallback(
(project: HostProjectListItem) => (project: HostProjectListItem) =>
@@ -126,6 +125,7 @@ export function useNewWorkspaceProjectPicker({
() => ({ () => ({
contextKey: selectionContextKey, contextKey: selectionContextKey,
manualContextKey: manualSelectionContextKey, manualContextKey: manualSelectionContextKey,
selectedServerId,
initialProject, initialProject,
initialProjectSource: resolveInitialProjectSelectionSource({ initialProjectSource: resolveInitialProjectSelectionSource({
initialProject, initialProject,
@@ -143,6 +143,7 @@ export function useNewWorkspaceProjectPicker({
manualSelectionContextKey, manualSelectionContextKey,
routeProject, routeProject,
selectableProjects, selectableProjects,
selectedServerId,
selectionContextKey, selectionContextKey,
shouldPreserveMissingProject, shouldPreserveMissingProject,
], ],
@@ -168,8 +169,8 @@ export function useNewWorkspaceProjectPicker({
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return; if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return;
setProjectSelection({ setProjectSelection({
contextKey: manualSelectionContextKey, contextKey: manualSelectionContextKey,
projectKey: project.projectKey,
project, project,
originProject: project,
source: "manual", source: "manual",
}); });
}, },
@@ -183,7 +184,7 @@ export function useNewWorkspaceProjectPicker({
: null, : null,
projectPickerOptions, projectPickerOptions,
projectByOptionId, projectByOptionId,
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "", selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.viewKey) : "",
projectTriggerLabel: selectedProject?.projectName ?? "Choose project", projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
handleSelectProjectOption, handleSelectProjectOption,
}; };
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { HostProjectListItem } from "@/projects/host-projects"; import { filterWorkspaceProjectsForHost, type HostProjectListItem } from "@/projects/host-projects";
import { import {
createManualProjectSelectionContextKey, createManualProjectSelectionContextKey,
createProjectSelectionContextKey, createProjectSelectionContextKey,
@@ -13,6 +13,7 @@ import {
function project(projectKey: string, serverId = "host"): HostProjectListItem { function project(projectKey: string, serverId = "host"): HostProjectListItem {
return { return {
viewKey: `view:${serverId}:${projectKey}`,
projectKey, projectKey,
projectName: projectKey, projectName: projectKey,
projectKind: "git", projectKind: "git",
@@ -40,7 +41,8 @@ function context(
const lastActiveProject = input.lastActiveProject ?? null; const lastActiveProject = input.lastActiveProject ?? null;
return { return {
contextKey, contextKey,
manualContextKey: input.manualContextKey ?? contextKey, manualContextKey: input.manualContextKey ?? "",
selectedServerId: input.selectedServerId ?? "host",
routeProject, routeProject,
lastActiveProject, lastActiveProject,
initialProjectSource: initialProjectSource:
@@ -71,7 +73,6 @@ describe("reconcileProjectSelection", () => {
expect(reconciled).toEqual({ expect(reconciled).toEqual({
contextKey: "host:", contextKey: "host:",
projectKey: remembered.projectKey,
project: remembered, project: remembered,
source: "initial", source: "initial",
}); });
@@ -80,15 +81,16 @@ describe("reconcileProjectSelection", () => {
it("resets stale selection when the route project context changes", () => { it("resets stale selection when the route project context changes", () => {
const manual = project("manual"); const manual = project("manual");
const routeProject = project("route-project"); const routeProject = { ...project("route-project"), projectKey: null };
const current: ProjectSelection = { const current: ProjectSelection = {
contextKey: "host:previous-route", contextKey: "previous-route",
projectKey: manual.projectKey,
project: manual, project: manual,
originProject: manual,
source: "manual", source: "manual",
}; };
const nextContext = context({ const nextContext = context({
contextKey: "host:route-project", contextKey: "host:route-project",
manualContextKey: routeProject.viewKey,
initialProject: routeProject, initialProject: routeProject,
projects: [manual, routeProject], projects: [manual, routeProject],
routeProject, routeProject,
@@ -96,7 +98,6 @@ describe("reconcileProjectSelection", () => {
expect(reconcileProjectSelection(current, nextContext)).toEqual({ expect(reconcileProjectSelection(current, nextContext)).toEqual({
contextKey: "host:route-project", contextKey: "host:route-project",
projectKey: routeProject.projectKey,
project: routeProject, project: routeProject,
source: "initial", source: "initial",
}); });
@@ -112,7 +113,6 @@ describe("reconcileProjectSelection", () => {
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({ expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
contextKey: "host:", contextKey: "host:",
projectKey: initialProject.projectKey,
project: initialProject, project: initialProject,
source: "initial", source: "initial",
}); });
@@ -121,6 +121,7 @@ describe("reconcileProjectSelection", () => {
it("adopts a routed project's hydrated cross-host key", () => { it("adopts a routed project's hydrated cross-host key", () => {
const routedProject = { const routedProject = {
...project("local-project-id"), ...project("local-project-id"),
projectKey: null,
hosts: [ hosts: [
{ {
serverId: "host", serverId: "host",
@@ -152,7 +153,6 @@ describe("reconcileProjectSelection", () => {
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({ expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
contextKey: "host:", contextKey: "host:",
projectKey: hydratedProject.projectKey,
project: hydratedProject, project: hydratedProject,
source: "initial", source: "initial",
}); });
@@ -177,7 +177,6 @@ describe("reconcileProjectSelection", () => {
expect(hydratedSelection).toEqual({ expect(hydratedSelection).toEqual({
contextKey: "host:", contextKey: "host:",
projectKey: hydratedProject.projectKey,
project: hydratedProject, project: hydratedProject,
source: "initial", source: "initial",
}); });
@@ -207,7 +206,6 @@ describe("reconcileProjectSelection", () => {
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual({ expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual({
contextKey: "host:", contextKey: "host:",
projectKey: remembered.projectKey,
project: remembered, project: remembered,
source: "initial", source: "initial",
}); });
@@ -217,9 +215,9 @@ describe("reconcileProjectSelection", () => {
const manual = project("manual"); const manual = project("manual");
const remembered = project("remembered"); const remembered = project("remembered");
const current: ProjectSelection = { const current: ProjectSelection = {
contextKey: "host:", contextKey: "",
projectKey: manual.projectKey,
project: manual, project: manual,
originProject: manual,
source: "manual", source: "manual",
}; };
const afterRememberedHydration = context({ const afterRememberedHydration = context({
@@ -231,6 +229,295 @@ describe("reconcileProjectSelection", () => {
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current); expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
}); });
it("resolves a manual selection on another host while retaining its origin snapshot", () => {
const selected = project("shared", "host-a");
const equivalent = project("shared", "host-b");
const fallback = project("fallback", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const nextHost = context({
selectedServerId: "host-b",
initialProject: fallback,
projects: [fallback, equivalent],
});
const reconciled = reconcileProjectSelection(current, nextHost);
expect(reconciled).toEqual({
contextKey: "",
project: equivalent,
originProject: selected,
source: "manual",
});
expect(resolveProjectSelection(reconciled, nextHost)).toBe(equivalent);
});
it("ignores a stale placement view collision and selects the persisted equivalent", () => {
const stalePlacementViewKey = JSON.stringify(["host-b", "opaque-project"]);
const selected = {
...project("shared", "host-a"),
viewKey: stalePlacementViewKey,
};
const unrelated = {
...project(stalePlacementViewKey, "host-b"),
viewKey: stalePlacementViewKey,
};
const equivalent = project("shared", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const nextHost = context({
selectedServerId: "host-b",
initialProject: unrelated,
projects: [unrelated, equivalent],
});
const reconciled = reconcileProjectSelection(current, nextHost);
expect(reconciled).toEqual({
contextKey: "",
project: equivalent,
originProject: selected,
source: "manual",
});
expect(resolveProjectSelection(reconciled, nextHost)).toBe(equivalent);
});
it("chooses the same equivalent clone regardless of input order", () => {
const selected = project("shared", "host-a");
const cloneA = {
...project("shared", "host-b"),
viewKey: "view:host-b:clone-a",
projectName: "Shared",
hosts: [
{
serverId: "host-b",
projectId: "prj_a",
iconWorkingDir: "/work/a",
canCreateWorktree: true,
},
],
};
const cloneB = {
...cloneA,
viewKey: "view:host-b:clone-b",
hosts: [{ ...cloneA.hosts[0]!, projectId: "prj_b", iconWorkingDir: "/work/b" }],
};
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const resolve = (projects: HostProjectListItem[]) => {
const nextHost = context({
selectedServerId: "host-b",
initialProject: cloneB,
projects,
});
return resolveProjectSelection(reconcileProjectSelection(current, nextHost), nextHost);
};
expect(resolve([cloneB, cloneA])).toBe(cloneA);
expect(resolve([cloneA, cloneB])).toBe(cloneA);
});
it("restores the exact manually selected clone after switching away and back", () => {
const selected = {
...project("shared", "host-a"),
viewKey: "view:host-a:selected",
hosts: [
{
serverId: "host-a",
projectId: "prj_b",
iconWorkingDir: "/work/selected",
canCreateWorktree: true,
},
],
};
const sibling = {
...selected,
viewKey: "view:host-a:sibling",
hosts: [{ ...selected.hosts[0]!, projectId: "prj_a", iconWorkingDir: "/work/sibling" }],
};
const destination = project("shared", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const otherHost = context({
selectedServerId: "host-b",
initialProject: destination,
projects: [destination],
});
const retained = reconcileProjectSelection(current, otherHost);
expect(retained).toEqual({
contextKey: "",
project: destination,
originProject: selected,
source: "manual",
});
expect(resolveProjectSelection(retained, otherHost)).toBe(destination);
const originHost = context({
selectedServerId: "host-a",
initialProject: sibling,
projects: [sibling, selected],
});
const restored = reconcileProjectSelection(retained, originHost);
expect(restored.project).toBe(selected);
expect(restored.source === "manual" ? restored.originProject : null).toBe(selected);
expect(resolveProjectSelection(restored, originHost)).toBe(selected);
});
it("preserves the current destination clone during its pending archive gap", () => {
const selected = project("shared", "host-a");
const destination = {
...project("shared", "host-b"),
workspaceKeys: ["host-b:workspace"],
};
const fallback = project("fallback", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const destinationHost = context({
selectedServerId: "host-b",
initialProject: fallback,
projects: [fallback, destination],
});
const resolvedDestination = reconcileProjectSelection(current, destinationHost);
const archiveGap = context({
selectedServerId: "host-b",
initialProject: fallback,
projects: [fallback],
shouldPreserveMissingProject: (candidate) => candidate === destination,
});
expect(reconcileProjectSelection(resolvedDestination, archiveGap)).toBe(resolvedDestination);
expect(resolveProjectSelection(resolvedDestination, archiveGap)).toBe(destination);
});
it("preserves the exact origin clone during its pending archive gap", () => {
const selected = {
...project("shared", "host-a"),
viewKey: "view:host-a:selected",
workspaceKeys: ["host-a:workspace"],
hosts: [
{
serverId: "host-a",
projectId: "prj_b",
iconWorkingDir: "/work/selected",
canCreateWorktree: true,
},
],
};
const sibling = {
...selected,
viewKey: "view:host-a:sibling",
workspaceKeys: [],
hosts: [{ ...selected.hosts[0]!, projectId: "prj_a", iconWorkingDir: "/work/sibling" }],
};
const destination = project("shared", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const destinationHost = context({
selectedServerId: "host-b",
initialProject: destination,
projects: [destination],
});
const resolvedDestination = reconcileProjectSelection(current, destinationHost);
const archiveGap = context({
selectedServerId: "host-a",
initialProject: sibling,
projects: [sibling],
shouldPreserveMissingProject: (candidate) => candidate === selected,
});
const preservedOrigin = reconcileProjectSelection(resolvedDestination, archiveGap);
expect(preservedOrigin.project).toBe(selected);
expect(resolveProjectSelection(preservedOrigin, archiveGap)).toBe(selected);
});
it("falls back when the only equivalent project is not selectable", () => {
const selected = project("shared", "host-a");
const unselectableEquivalent = {
...project("shared", "host-b"),
hosts: [
{
serverId: "host-b",
projectId: "shared",
iconWorkingDir: "/work/shared",
canCreateWorktree: false,
},
],
};
const fallback = project("fallback", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const selectableProjects = filterWorkspaceProjectsForHost({
projects: [unselectableEquivalent, fallback],
serverId: "host-b",
allowAllProjects: false,
});
const selectableContext = context({
selectedServerId: "host-b",
initialProject: fallback,
projects: selectableProjects,
});
expect(reconcileProjectSelection(current, selectableContext).project).toBe(fallback);
});
it("does not equate projects without a persisted project key", () => {
const selected = { ...project("selected", "host-a"), projectKey: null };
const sameName = {
...project("same-name", "host-b"),
projectKey: null,
projectName: selected.projectName,
};
const fallback = project("fallback", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
expect(
reconcileProjectSelection(
current,
context({
selectedServerId: "host-b",
initialProject: fallback,
projects: [sameName, fallback],
}),
).project,
).toBe(fallback);
});
it("preserves an opaque project key ending in whitespace", () => { it("preserves an opaque project key ending in whitespace", () => {
const selected = project("host:project "); const selected = project("host:project ");
const current = createProjectSelection( const current = createProjectSelection(
@@ -249,7 +536,7 @@ describe("reconcileProjectSelection", () => {
context({ context({
contextKey: createProjectSelectionContextKey({ contextKey: createProjectSelectionContextKey({
selectedServerId: "host", selectedServerId: "host",
routeProjectKey: null, routeProjectViewKey: null,
allowAllProjects: false, allowAllProjects: false,
}), }),
initialProject: fallback, initialProject: fallback,
@@ -259,7 +546,7 @@ describe("reconcileProjectSelection", () => {
const afterCapabilityHydration = context({ const afterCapabilityHydration = context({
contextKey: createProjectSelectionContextKey({ contextKey: createProjectSelectionContextKey({
selectedServerId: "host", selectedServerId: "host",
routeProjectKey: null, routeProjectViewKey: null,
allowAllProjects: true, allowAllProjects: true,
}), }),
initialProject: remembered, initialProject: remembered,
@@ -268,7 +555,6 @@ describe("reconcileProjectSelection", () => {
expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({ expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({
contextKey: "host:all-projects:", contextKey: "host:all-projects:",
projectKey: remembered.projectKey,
project: remembered, project: remembered,
source: "initial", source: "initial",
}); });
@@ -280,22 +566,20 @@ describe("reconcileProjectSelection", () => {
const remembered = project("remembered-directory"); const remembered = project("remembered-directory");
const current: ProjectSelection = { const current: ProjectSelection = {
contextKey: createManualProjectSelectionContextKey({ contextKey: createManualProjectSelectionContextKey({
selectedServerId: "host", routeProjectViewKey: null,
routeProjectKey: null,
}), }),
projectKey: manual.projectKey,
project: manual, project: manual,
originProject: manual,
source: "manual", source: "manual",
}; };
const afterCapabilityHydration = context({ const afterCapabilityHydration = context({
contextKey: createProjectSelectionContextKey({ contextKey: createProjectSelectionContextKey({
selectedServerId: "host", selectedServerId: "host",
routeProjectKey: null, routeProjectViewKey: null,
allowAllProjects: true, allowAllProjects: true,
}), }),
manualContextKey: createManualProjectSelectionContextKey({ manualContextKey: createManualProjectSelectionContextKey({
selectedServerId: "host", routeProjectViewKey: null,
routeProjectKey: null,
}), }),
initialProject: remembered, initialProject: remembered,
projects: [fallback, manual, remembered], projects: [fallback, manual, remembered],
@@ -325,6 +609,34 @@ describe("reconcileProjectSelection", () => {
expect(resolveProjectSelection(reconciled, withoutRemembered)).toEqual(remembered); expect(resolveProjectSelection(reconciled, withoutRemembered)).toEqual(remembered);
}); });
it("keeps a pending archived clone instead of jumping to a same-host equivalent", () => {
const remembered = project("shared");
const sibling = {
...project("shared"),
viewKey: "view:host:sibling",
hosts: [
{
serverId: "host",
projectId: "sibling",
iconWorkingDir: "/work/sibling",
canCreateWorktree: true,
},
],
};
const fallback = project("fallback");
const current = createProjectSelection(
context({ initialProject: remembered, projects: [remembered, sibling] }),
);
const archiveGap = context({
initialProject: fallback,
projects: [sibling, fallback],
shouldPreserveMissingProject: (candidate) => candidate.viewKey === remembered.viewKey,
});
expect(reconcileProjectSelection(current, archiveGap)).toEqual(current);
expect(resolveProjectSelection(current, archiveGap)).toEqual(remembered);
});
it("falls back when the selected project disappears without a pending archive", () => { it("falls back when the selected project disappears without a pending archive", () => {
const remembered = project("remembered"); const remembered = project("remembered");
const fallback = project("fallback"); const fallback = project("fallback");
@@ -338,7 +650,6 @@ describe("reconcileProjectSelection", () => {
expect(reconcileProjectSelection(current, withoutRemembered)).toEqual({ expect(reconcileProjectSelection(current, withoutRemembered)).toEqual({
contextKey: "host:", contextKey: "host:",
projectKey: fallback.projectKey,
project: fallback, project: fallback,
source: "initial", source: "initial",
}); });
@@ -349,13 +660,14 @@ describe("reconcileProjectSelection", () => {
const routeProject = project("route-project"); const routeProject = project("route-project");
const remembered = project("remembered"); const remembered = project("remembered");
const current: ProjectSelection = { const current: ProjectSelection = {
contextKey: "host:route-project", contextKey: routeProject.viewKey,
projectKey: manual.projectKey,
project: manual, project: manual,
originProject: manual,
source: "manual", source: "manual",
}; };
const selectionContext = context({ const selectionContext = context({
contextKey: "host:route-project", contextKey: "host:route-project",
manualContextKey: routeProject.viewKey,
initialProject: routeProject, initialProject: routeProject,
projects: [manual], projects: [manual],
routeProject, routeProject,
@@ -1,18 +1,32 @@
import type { HostProjectListItem } from "@/projects/host-projects"; import {
getHostProjectId,
resolveEquivalentHostProjectCandidate,
resolveExactHostProjectCandidate,
type HostProjectListItem,
} from "@/projects/host-projects";
export type ProjectSelectionSource = "initial" | "manual"; export type ProjectSelectionSource = "initial" | "manual";
export type InitialProjectSelectionSource = "route" | "lastActive" | "fallback" | null; export type InitialProjectSelectionSource = "route" | "lastActive" | "fallback" | null;
export interface ProjectSelection { interface InitialProjectSelection {
contextKey: string; contextKey: string;
projectKey: string | null;
project: HostProjectListItem | null; project: HostProjectListItem | null;
source: ProjectSelectionSource; source: "initial";
} }
interface ManualProjectSelection {
contextKey: string;
project: HostProjectListItem;
originProject: HostProjectListItem;
source: "manual";
}
export type ProjectSelection = InitialProjectSelection | ManualProjectSelection;
export interface ProjectSelectionContext { export interface ProjectSelectionContext {
contextKey: string; contextKey: string;
manualContextKey: string; manualContextKey: string;
selectedServerId: string;
initialProject: HostProjectListItem | null; initialProject: HostProjectListItem | null;
initialProjectSource: InitialProjectSelectionSource; initialProjectSource: InitialProjectSelectionSource;
projects: HostProjectListItem[]; projects: HostProjectListItem[];
@@ -23,18 +37,17 @@ export interface ProjectSelectionContext {
export function createProjectSelectionContextKey(input: { export function createProjectSelectionContextKey(input: {
selectedServerId: string; selectedServerId: string;
routeProjectKey: string | null; routeProjectViewKey: string | null;
allowAllProjects: boolean; allowAllProjects: boolean;
}): string { }): string {
const projectScope = input.allowAllProjects ? "all-projects" : "worktree-projects"; const projectScope = input.allowAllProjects ? "all-projects" : "worktree-projects";
return `${input.selectedServerId}:${projectScope}:${input.routeProjectKey ?? ""}`; return `${input.selectedServerId}:${projectScope}:${input.routeProjectViewKey ?? ""}`;
} }
export function createManualProjectSelectionContextKey(input: { export function createManualProjectSelectionContextKey(input: {
selectedServerId: string; routeProjectViewKey: string | null;
routeProjectKey: string | null;
}): string { }): string {
return `${input.selectedServerId}:${input.routeProjectKey ?? ""}`; return input.routeProjectViewKey ?? "";
} }
export function createProjectSelection({ export function createProjectSelection({
@@ -43,7 +56,6 @@ export function createProjectSelection({
}: ProjectSelectionContext): ProjectSelection { }: ProjectSelectionContext): ProjectSelection {
return { return {
contextKey, contextKey,
projectKey: initialProject?.projectKey ?? null,
project: initialProject, project: initialProject,
source: "initial", source: "initial",
}; };
@@ -58,7 +70,7 @@ export function resolveInitialProjectSelectionSource(input: {
return null; return null;
} }
if ( if (
input.routeProject?.projectKey === input.initialProject.projectKey || input.routeProject?.viewKey === input.initialProject.viewKey ||
input.routeProject?.hosts.some((routeHost) => input.routeProject?.hosts.some((routeHost) =>
input.initialProject?.hosts.some( input.initialProject?.hosts.some(
(host) => (host) =>
@@ -70,24 +82,24 @@ export function resolveInitialProjectSelectionSource(input: {
) { ) {
return "route"; return "route";
} }
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) { if (
input.lastActiveProject?.viewKey === input.initialProject.viewKey ||
(input.lastActiveProject?.projectKey !== null &&
input.lastActiveProject?.projectKey !== undefined &&
input.lastActiveProject.projectKey === input.initialProject.projectKey)
) {
return "lastActive"; return "lastActive";
} }
return "fallback"; return "fallback";
} }
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
const projectKey = selection.projectKey ?? "";
return projectKey.trim() ? projectKey : null;
}
function resolveSelectedProjectFromInitialInputs( function resolveSelectedProjectFromInitialInputs(
projectKey: string, project: HostProjectListItem,
context: ProjectSelectionContext, context: ProjectSelectionContext,
): HostProjectListItem | null { ): HostProjectListItem | null {
return ( return (
(context.routeProject?.projectKey === projectKey ? context.routeProject : null) ?? (context.routeProject?.viewKey === project.viewKey ? context.routeProject : null) ??
(context.lastActiveProject?.projectKey === projectKey ? context.lastActiveProject : null) (context.lastActiveProject?.viewKey === project.viewKey ? context.lastActiveProject : null)
); );
} }
@@ -95,16 +107,40 @@ function refreshSelectionProject(
selection: ProjectSelection, selection: ProjectSelection,
project: HostProjectListItem, project: HostProjectListItem,
): ProjectSelection { ): ProjectSelection {
if (selection.projectKey === project.projectKey && selection.project === project) { if (selection.project === project) {
return selection; return selection;
} }
return { return {
...selection, ...selection,
projectKey: project.projectKey,
project, project,
}; };
} }
function resolveManualOriginProject(
selection: ProjectSelection,
context: ProjectSelectionContext,
): HostProjectListItem | null {
if (selection.source !== "manual") {
return null;
}
const exactOriginProject = resolveExactHostProjectCandidate({
candidate: selection.originProject,
projects: context.projects,
serverId: context.selectedServerId,
});
if (exactOriginProject) {
return exactOriginProject;
}
const originBelongsToSelectedHost =
getHostProjectId(selection.originProject, context.selectedServerId) !== null;
return originBelongsToSelectedHost &&
context.shouldPreserveMissingProject(selection.originProject)
? selection.originProject
: null;
}
function shouldResetHydratedInitialSelection( function shouldResetHydratedInitialSelection(
selection: ProjectSelection, selection: ProjectSelection,
context: ProjectSelectionContext, context: ProjectSelectionContext,
@@ -117,32 +153,48 @@ function shouldResetHydratedInitialSelection(
return false; return false;
} }
return selection.projectKey !== context.initialProject.projectKey; return selection.project?.viewKey !== context.initialProject.viewKey;
} }
export function resolveProjectSelection( export function resolveProjectSelection(
selection: ProjectSelection, selection: ProjectSelection,
context: ProjectSelectionContext, context: ProjectSelectionContext,
): HostProjectListItem | null { ): HostProjectListItem | null {
const projectKey = resolveProjectSelectionKey(selection); if (!selection.project) {
if (!projectKey) {
return null; return null;
} }
const selectableProject = context.projects.find((project) => project.projectKey === projectKey); if (selection.source === "manual") {
if (selectableProject) { const originProject = resolveManualOriginProject(selection, context);
return selectableProject; if (originProject) {
return originProject;
}
} }
if ( const exactProject = resolveExactHostProjectCandidate({
selection.project?.projectKey === projectKey && candidate: selection.project,
context.shouldPreserveMissingProject(selection.project) projects: context.projects,
) { serverId: context.selectedServerId,
});
if (exactProject) {
return exactProject;
}
if (context.shouldPreserveMissingProject(selection.project)) {
return selection.project; return selection.project;
} }
const equivalentProject = resolveEquivalentHostProjectCandidate({
candidate: selection.project,
projects: context.projects,
serverId: context.selectedServerId,
});
if (equivalentProject) {
return equivalentProject;
}
if (selection.source !== "manual") { if (selection.source !== "manual") {
return resolveSelectedProjectFromInitialInputs(projectKey, context); return resolveSelectedProjectFromInitialInputs(selection.project, context);
} }
return null; return null;
@@ -163,6 +215,34 @@ export function reconcileProjectSelection(
return initialSelection; return initialSelection;
} }
if (!current.project) {
return initialSelection;
}
if (current.source === "manual") {
const originProject = resolveManualOriginProject(current, context);
if (originProject) {
return {
...current,
project: originProject,
originProject,
};
}
}
const exactProject = resolveExactHostProjectCandidate({
candidate: current.project,
projects: context.projects,
serverId: context.selectedServerId,
});
if (exactProject) {
return refreshSelectionProject(current, exactProject);
}
if (context.shouldPreserveMissingProject(current.project)) {
return current;
}
const resolvedProject = resolveProjectSelection(current, context); const resolvedProject = resolveProjectSelection(current, context);
if (resolvedProject) { if (resolvedProject) {
return refreshSelectionProject(current, resolvedProject); return refreshSelectionProject(current, resolvedProject);
@@ -31,7 +31,7 @@ import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
@@ -233,16 +233,16 @@ function ProjectSettingsBody({
() => [ () => [
{ {
serverId: selectedHost.serverId, serverId: selectedHost.serverId,
projectKey: project.projectKey, projectViewKey: project.viewKey,
iconWorkingDir: selectedHost.repoRoot, iconWorkingDir: selectedHost.repoRoot,
}, },
], ],
[project.projectKey, selectedHost.repoRoot, selectedHost.serverId], [project.viewKey, selectedHost.repoRoot, selectedHost.serverId],
); );
const projectIconDataByKey = useProjectIconDataByProjectKey({ const projectIconDataByKey = useProjectIconDataByProjectViewKey({
projects: projectIconTargets, projects: projectIconTargets,
}); });
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null; const projectIconDataUri = projectIconDataByKey.get(project.viewKey) ?? null;
const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null;
const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null;
const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null;
@@ -262,7 +262,7 @@ function ProjectSettingsBody({
<ProjectTitleIcon <ProjectTitleIcon
iconDataUri={projectIconDataUri} iconDataUri={projectIconDataUri}
projectName={project.projectName} projectName={project.projectName}
projectKey={project.projectKey} projectViewKey={project.viewKey}
/> />
<ProjectNameEditor <ProjectNameEditor
key={`${selectedHost.serverId}:${selectedHost.projectId}`} key={`${selectedHost.serverId}:${selectedHost.projectId}`}
@@ -959,18 +959,18 @@ function ProjectNameEditor({
function ProjectTitleIcon({ function ProjectTitleIcon({
iconDataUri, iconDataUri,
projectName, projectName,
projectKey, projectViewKey,
}: { }: {
iconDataUri: string | null; iconDataUri: string | null;
projectName: string; projectName: string;
projectKey: string; projectViewKey: string;
}) { }) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?"; const initial = projectName.trim().charAt(0).toUpperCase() || "?";
return ( return (
<ProjectIconView <ProjectIconView
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
initial={initial} initial={initial}
projectKey={projectKey} projectViewKey={projectViewKey}
imageStyle={styles.titleIcon} imageStyle={styles.titleIcon}
fallbackStyle={styles.titleIconFallback} fallbackStyle={styles.titleIconFallback}
textStyle={styles.titleIconFallbackText} textStyle={styles.titleIconFallbackText}
@@ -202,7 +202,7 @@ vi.mock("@/hooks/use-projects", () => ({
})); }));
vi.mock("@/projects/project-icons", () => ({ vi.mock("@/projects/project-icons", () => ({
useProjectIconDataByProjectKey: () => new Map(), useProjectIconDataByProjectViewKey: () => new Map(),
})); }));
import ProjectsScreen from "./projects-screen"; import ProjectsScreen from "./projects-screen";
@@ -239,7 +239,7 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0); overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length; const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length;
return { return {
projectKey: "remote:github.com/acme/app", viewKey: "remote:github.com/acme/app",
projectName: "acme/app", projectName: "acme/app",
hosts, hosts,
totalWorkspaceCount, totalWorkspaceCount,
@@ -323,7 +323,7 @@ describe("ProjectsScreen", () => {
it("navigates to the project detail route when the row is pressed", () => { it("navigates to the project detail route when the row is pressed", () => {
setProjectsState({ setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })], projects: [project({ viewKey: "remote:github.com/acme/app" })],
}); });
render({ kind: "projects" }); render({ kind: "projects" });
@@ -339,7 +339,7 @@ describe("ProjectsScreen", () => {
it("does not render a kebab menu on the row", () => { it("does not render a kebab menu on the row", () => {
setProjectsState({ setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })], projects: [project({ viewKey: "remote:github.com/acme/app" })],
}); });
render({ kind: "projects" }); render({ kind: "projects" });
@@ -388,9 +388,9 @@ describe("ProjectsScreen", () => {
it("highlights the selected row when the active view targets a project", () => { it("highlights the selected row when the active view targets a project", () => {
setProjectsState({ setProjectsState({
projects: [ projects: [
project({ projectKey: "remote:github.com/acme/app" }), project({ viewKey: "remote:github.com/acme/app" }),
project({ project({
projectKey: "remote:github.com/acme/other", viewKey: "remote:github.com/acme/other",
projectName: "acme/other", projectName: "acme/other",
githubUrl: "https://github.com/acme/other", githubUrl: "https://github.com/acme/other",
}), }),
+14 -15
View File
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { ProjectIconView } from "@/components/project-icon-view"; import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes"; import { buildProjectSettingsRoute } from "@/utils/host-routes";
import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects"; import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects";
@@ -19,10 +19,9 @@ interface ProjectsScreenProps {
export default function ProjectsScreen({ view }: ProjectsScreenProps) { export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { projects, hostErrors, isLoading } = useProjects(); const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectKey = const selectedProjectViewKey =
view.kind === "project" view.kind === "project"
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ?? ? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.viewKey ?? null)
null)
: null; : null;
const iconTargets = useMemo( const iconTargets = useMemo(
() => () =>
@@ -32,14 +31,14 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
return [ return [
{ {
serverId: host.serverId, serverId: host.serverId,
projectKey: project.projectKey, projectViewKey: project.viewKey,
iconWorkingDir: host.repoRoot, iconWorkingDir: host.repoRoot,
}, },
]; ];
}), }),
[projects], [projects],
); );
const iconDataByProjectKey = useProjectIconDataByProjectKey({ const iconDataByProjectViewKey = useProjectIconDataByProjectViewKey({
projects: iconTargets, projects: iconTargets,
}); });
@@ -65,11 +64,11 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
{projects.map((project, index) => ( {projects.map((project, index) => (
<ProjectRow <ProjectRow
key={project.projectKey} key={project.viewKey}
project={project} project={project}
isFirst={index === 0} isFirst={index === 0}
isSelected={selectedProjectKey === project.projectKey} isSelected={selectedProjectViewKey === project.viewKey}
iconDataUri={iconDataByProjectKey.get(project.projectKey) ?? null} iconDataUri={iconDataByProjectViewKey.get(project.viewKey) ?? null}
/> />
))} ))}
</View> </View>
@@ -103,7 +102,7 @@ interface ProjectRowProps {
function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) { function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { projectKey, projectName } = project; const { viewKey, projectName } = project;
const target = project.hosts[0]; const target = project.hosts[0];
const handleNavigate = useCallback(() => { const handleNavigate = useCallback(() => {
if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId)); if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId));
@@ -127,7 +126,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
onPress={handleNavigate} onPress={handleNavigate}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t("settings.projectList.editProject", { projectName })} accessibilityLabel={t("settings.projectList.editProject", { projectName })}
testID={`project-row-${projectKey}`} testID={`project-row-${viewKey}`}
data-selected={isSelected ? "true" : "false"} data-selected={isSelected ? "true" : "false"}
> >
<View style={styles.rowMain}> <View style={styles.rowMain}>
@@ -135,7 +134,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
<ProjectRowIcon <ProjectRowIcon
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
projectName={projectName} projectName={projectName}
projectKey={projectKey} projectViewKey={viewKey}
/> />
</View> </View>
<Text style={settingsStyles.rowTitle} numberOfLines={1}> <Text style={settingsStyles.rowTitle} numberOfLines={1}>
@@ -150,18 +149,18 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
function ProjectRowIcon({ function ProjectRowIcon({
iconDataUri, iconDataUri,
projectName, projectName,
projectKey, projectViewKey,
}: { }: {
iconDataUri: string | null; iconDataUri: string | null;
projectName: string; projectName: string;
projectKey: string; projectViewKey: string;
}) { }) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?"; const initial = projectName.trim().charAt(0).toUpperCase() || "?";
return ( return (
<ProjectIconView <ProjectIconView
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
initial={initial} initial={initial}
projectKey={projectKey} projectViewKey={projectViewKey}
imageStyle={styles.iconImage} imageStyle={styles.iconImage}
fallbackStyle={styles.iconFallback} fallbackStyle={styles.iconFallback}
textStyle={styles.iconFallbackText} textStyle={styles.iconFallbackText}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { createProjectViewKey } from "@/projects/workspace-structure";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { import {
@@ -25,6 +26,10 @@ import {
const SERVER_ID = "test-server"; const SERVER_ID = "test-server";
function equivalenceViewKey(projectKey: string): string {
return createProjectViewKey({ kind: "equivalence", projectKey });
}
function createWorkspace( function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">, input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor { ): WorkspaceDescriptor {
@@ -114,10 +119,10 @@ function emptySidebarOrder(): SidebarOrderSnapshot {
}; };
} }
function selectWorkspaceStructureProjectKeys( function selectWorkspaceStructureProjectViewKeys(
state: Parameters<typeof selectWorkspaceStructureProjects>[0], state: Parameters<typeof selectWorkspaceStructureProjects>[0],
): string[] { ): string[] {
return selectWorkspaceStructureProjects(state, [SERVER_ID]).map((project) => project.projectKey); return selectWorkspaceStructureProjects(state, [SERVER_ID]).map((project) => project.viewKey);
} }
afterEach(() => { afterEach(() => {
@@ -348,9 +353,11 @@ describe("workspace structure composition", () => {
}; };
initializeWorkspaces([workspace]); initializeWorkspaces([workspace]);
const emittedProjectKeys = [selectWorkspaceStructureProjectKeys(useSessionStore.getState())]; const emittedProjectKeys = [
selectWorkspaceStructureProjectViewKeys(useSessionStore.getState()),
];
const stop = useSessionStore.subscribe((state) => { const stop = useSessionStore.subscribe((state) => {
emittedProjectKeys.push(selectWorkspaceStructureProjectKeys(state)); emittedProjectKeys.push(selectWorkspaceStructureProjectViewKeys(state));
}); });
try { try {
@@ -360,7 +367,10 @@ describe("workspace structure composition", () => {
stop(); stop();
} }
expect(emittedProjectKeys).toEqual([["project-a"], ["project-a"]]); expect(emittedProjectKeys).toEqual([
[equivalenceViewKey("project-a")],
[equivalenceViewKey("project-a")],
]);
}); });
it("changes for membership updates but not status-only updates", () => { it("changes for membership updates but not status-only updates", () => {
@@ -451,10 +461,13 @@ describe("workspace structure composition", () => {
const before = snapshotStructure(SERVER_ID, emptySidebarOrder()); const before = snapshotStructure(SERVER_ID, emptySidebarOrder());
const after = snapshotStructure(SERVER_ID, { const after = snapshotStructure(SERVER_ID, {
...emptySidebarOrder(), ...emptySidebarOrder(),
projectOrder: ["project-b", "project-a"], projectOrder: [equivalenceViewKey("project-b"), equivalenceViewKey("project-a")],
}); });
expect(after.projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]); expect(after.projects.map((project) => project.viewKey)).toEqual([
equivalenceViewKey("project-b"),
equivalenceViewKey("project-a"),
]);
expect(after).not.toEqual(before); expect(after).not.toEqual(before);
}); });
}); });
@@ -214,8 +214,7 @@ export function composeWorkspaceStructure(input: {
const orderedProjects = applyStoredOrdering({ const orderedProjects = applyStoredOrdering({
items: input.projects.map((project) => { items: input.projects.map((project) => {
const workspaceOrder = const workspaceOrder = input.workspaceOrderByScope[project.viewKey] ?? EMPTY_WORKSPACE_KEYS;
input.workspaceOrderByScope[project.projectKey] ?? EMPTY_WORKSPACE_KEYS;
return { return {
...project, ...project,
workspaceKeys: applyStoredOrdering({ workspaceKeys: applyStoredOrdering({
@@ -226,7 +225,7 @@ export function composeWorkspaceStructure(input: {
}; };
}), }),
storedOrder: input.projectOrder, storedOrder: input.projectOrder,
getKey: (project) => project.projectKey, getKey: (project) => project.viewKey,
}); });
return { projects: orderedProjects }; return { projects: orderedProjects };
+14 -14
View File
@@ -7,8 +7,8 @@ interface SidebarOrderStoreState {
workspaceOrderByProject: Record<string, string[]>; workspaceOrderByProject: Record<string, string[]>;
getProjectOrder: () => string[]; getProjectOrder: () => string[];
setProjectOrder: (keys: string[]) => void; setProjectOrder: (keys: string[]) => void;
getWorkspaceOrder: (projectKey: string) => string[]; getWorkspaceOrder: (projectViewKey: string) => string[];
setWorkspaceOrder: (projectKey: string, keys: string[]) => void; setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void;
} }
interface SidebarOrderPersistedState { interface SidebarOrderPersistedState {
@@ -20,7 +20,7 @@ interface SidebarOrderPersistedState {
interface SidebarWorkspaceOrderScope { interface SidebarWorkspaceOrderScope {
serverId: string; serverId: string;
projectKey: string; projectViewKey: string;
} }
function normalizeKeys(keys: string[]): string[] { function normalizeKeys(keys: string[]): string[] {
@@ -43,8 +43,8 @@ function normalizeWorkspaceOrderByProject(
workspaceOrderByProject: Record<string, string[]> | undefined, workspaceOrderByProject: Record<string, string[]> | undefined,
): Record<string, string[]> { ): Record<string, string[]> {
const normalized: Record<string, string[]> = {}; const normalized: Record<string, string[]> = {};
for (const [projectKey, order] of Object.entries(workspaceOrderByProject ?? {})) { for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) {
const scope = projectKey.trim(); const scope = projectViewKey.trim();
if (!scope) continue; if (!scope) continue;
normalized[scope] = normalizeKeys(order); normalized[scope] = normalizeKeys(order);
} }
@@ -55,9 +55,9 @@ function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScop
const separatorIndex = scopeKey.indexOf("::"); const separatorIndex = scopeKey.indexOf("::");
if (separatorIndex < 0) return null; if (separatorIndex < 0) return null;
const serverId = scopeKey.slice(0, separatorIndex).trim(); const serverId = scopeKey.slice(0, separatorIndex).trim();
const projectKey = scopeKey.slice(separatorIndex + 2).trim(); const projectViewKey = scopeKey.slice(separatorIndex + 2).trim();
if (!serverId || !projectKey) return null; if (!serverId || !projectViewKey) return null;
return { serverId, projectKey }; return { serverId, projectViewKey };
} }
function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null { function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null {
@@ -91,7 +91,7 @@ export function migrateSidebarOrderState(persistedState: unknown): {
for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) { for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) {
const scope = extractWorkspaceOrderScope(scopeKey); const scope = extractWorkspaceOrderScope(scopeKey);
if (!scope) continue; if (!scope) continue;
const existing = workspaceOrderByProject[scope.projectKey] ?? []; const existing = workspaceOrderByProject[scope.projectViewKey] ?? [];
const merged = [...existing]; const merged = [...existing];
const seen = new Set(merged); const seen = new Set(merged);
for (const key of order) { for (const key of order) {
@@ -100,7 +100,7 @@ export function migrateSidebarOrderState(persistedState: unknown): {
seen.add(workspaceKey); seen.add(workspaceKey);
merged.push(workspaceKey); merged.push(workspaceKey);
} }
workspaceOrderByProject[scope.projectKey] = merged; workspaceOrderByProject[scope.projectViewKey] = merged;
} }
return { projectOrder, workspaceOrderByProject }; return { projectOrder, workspaceOrderByProject };
@@ -116,13 +116,13 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
const normalized = normalizeKeys(keys); const normalized = normalizeKeys(keys);
set({ projectOrder: normalized }); set({ projectOrder: normalized });
}, },
getWorkspaceOrder: (projectKey) => { getWorkspaceOrder: (projectViewKey) => {
const scope = projectKey.trim(); const scope = projectViewKey.trim();
if (!scope) return []; if (!scope) return [];
return get().workspaceOrderByProject[scope] ?? []; return get().workspaceOrderByProject[scope] ?? [];
}, },
setWorkspaceOrder: (projectKey, keys) => { setWorkspaceOrder: (projectViewKey, keys) => {
const scope = projectKey.trim(); const scope = projectViewKey.trim();
if (!scope) return; if (!scope) return;
const normalized = normalizeKeys(keys); const normalized = normalizeKeys(keys);
set((state) => ({ set((state) => ({
+2 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { createProjectViewKey } from "@/projects/workspace-structure";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, getProjectSummaryForHostProject } from "./projects"; import { buildProjects, getProjectSummaryForHostProject } from "./projects";
@@ -56,7 +57,7 @@ describe("buildProjects", () => {
expect(result.projects).toEqual([ expect(result.projects).toEqual([
expect.objectContaining({ expect.objectContaining({
projectKey: key, viewKey: createProjectViewKey({ kind: "equivalence", projectKey: key }),
hostCount: 2, hostCount: 2,
onlineHostCount: 1, onlineHostCount: 1,
totalWorkspaceCount: 2, totalWorkspaceCount: 2,
+12 -12
View File
@@ -27,7 +27,7 @@ export interface ProjectHostEntry {
} }
export interface ProjectSummary { export interface ProjectSummary {
projectKey: string; viewKey: string;
projectName: string; projectName: string;
projectCustomName?: string | null; projectCustomName?: string | null;
hosts: ProjectHostEntry[]; hosts: ProjectHostEntry[];
@@ -91,7 +91,7 @@ interface HostGroup {
} }
interface ProjectGroup { interface ProjectGroup {
projectKey: string; viewKey: string;
projectName: string; projectName: string;
projectCustomName: string | null; projectCustomName: string | null;
hostsByServerId: Map<string, HostGroup>; hostsByServerId: Map<string, HostGroup>;
@@ -99,10 +99,10 @@ interface ProjectGroup {
function findProjectMetadata( function findProjectMetadata(
host: ProjectHost, host: ProjectHost,
projectKey: string, projectId: string,
): { customName: string | null; displayName: string } | null { ): { customName: string | null; displayName: string } | null {
for (const project of host.projects) { for (const project of host.projects) {
if (project.projectId === projectKey) { if (project.projectId === projectId) {
return { return {
customName: project.projectCustomName ?? null, customName: project.projectCustomName ?? null,
displayName: project.projectDisplayName, displayName: project.projectDisplayName,
@@ -172,7 +172,7 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0); const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = hosts.filter((host) => host.isOnline).length; const onlineHostCount = hosts.filter((host) => host.isOnline).length;
return { return {
projectKey: draft.projectKey, viewKey: draft.viewKey,
projectName: draft.projectName, projectName: draft.projectName,
projectCustomName: draft.projectCustomName, projectCustomName: draft.projectCustomName,
hosts, hosts,
@@ -196,15 +196,15 @@ function addHostProjects(
if (!placement) continue; if (!placement) continue;
const projectId = placement.projectId; const projectId = placement.projectId;
const customName = findProjectMetadata(host, projectId); const customName = findProjectMetadata(host, projectId);
let group = groups.get(hostProject.projectKey); let group = groups.get(hostProject.viewKey);
if (!group) { if (!group) {
group = { group = {
projectKey: hostProject.projectKey, viewKey: hostProject.viewKey,
projectName: customName?.displayName ?? hostProject.projectName, projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null, projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(), hostsByServerId: new Map(),
}; };
groups.set(hostProject.projectKey, group); groups.set(hostProject.viewKey, group);
} else if (customName?.customName && !group.projectCustomName) { } else if (customName?.customName && !group.projectCustomName) {
group.projectCustomName = customName.customName; group.projectCustomName = customName.customName;
group.projectName = customName.displayName; group.projectName = customName.displayName;
@@ -230,16 +230,16 @@ function attachHostWorkspaces(
host: ProjectHost, host: ProjectHost,
hostProjects: HostProjectListItem[], hostProjects: HostProjectListItem[],
): void { ): void {
const projectKeyByProjectId = new Map( const projectViewKeyByProjectId = new Map(
hostProjects.flatMap((project) => hostProjects.flatMap((project) =>
project.hosts project.hosts
.filter((placement) => placement.serverId === host.serverId && placement.projectId) .filter((placement) => placement.serverId === host.serverId && placement.projectId)
.map((placement) => [placement.projectId!, project.projectKey] as const), .map((placement) => [placement.projectId!, project.viewKey] as const),
), ),
); );
for (const workspace of host.workspaces) { for (const workspace of host.workspaces) {
const key = projectKeyByProjectId.get(workspace.projectId); const key = projectViewKeyByProjectId.get(workspace.projectId);
if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace); if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
} }
} }
@@ -262,7 +262,7 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
if (name !== 0) { if (name !== 0) {
return name; return name;
} }
return left.projectKey.localeCompare(right.projectKey); return left.viewKey.localeCompare(right.viewKey);
}); });
return { projects }; return { projects };
@@ -6,6 +6,7 @@ import type {
import { import {
buildSidebarProjectRowModel, buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget, resolveSidebarProjectIconTarget,
resolveSidebarProjectIconTargets,
resolveSidebarProjectLocalPath, resolveSidebarProjectLocalPath,
} from "./sidebar-project-row-model"; } from "./sidebar-project-row-model";
@@ -14,7 +15,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
workspaceKey: "srv:ws-root", workspaceKey: "srv:ws-root",
serverId: "srv", serverId: "srv",
workspaceId: "ws-root", workspaceId: "ws-root",
projectKey: "project-1", projectViewKey: "project-1",
projectName: "paseo", projectName: "paseo",
workspaceDirectory: "/repo", workspaceDirectory: "/repo",
projectKind: "git", projectKind: "git",
@@ -48,7 +49,7 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
(host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }), (host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }),
); );
return { return {
projectKey: "project-1", viewKey: "project-1",
projectName: "paseo", projectName: "paseo",
projectKind, projectKind,
iconWorkingDir: "/repo", iconWorkingDir: "/repo",
@@ -209,6 +210,22 @@ describe("buildSidebarProjectRowModel", () => {
}); });
}); });
it("keys project icon results by the rendered project view", () => {
const [iconTarget] = resolveSidebarProjectIconTargets([
project({
viewKey: '["placement","host-b","project-b"]',
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
}),
]);
expect(iconTarget).toEqual({
projectViewKey: '["placement","host-b","project-b"]',
serverId: "host-b",
projectId: "project-host-b",
iconWorkingDir: "/repo/b",
});
});
it("resolves desktop file actions from the local project placement", () => { it("resolves desktop file actions from the local project placement", () => {
const groupedProject = project({ const groupedProject = project({
iconWorkingDir: "/remote/repo", iconWorkingDir: "/remote/repo",
@@ -44,7 +44,7 @@ export function resolveSidebarProjectIconTarget(
} }
export interface SidebarProjectIconTarget extends SidebarProjectHostTarget { export interface SidebarProjectIconTarget extends SidebarProjectHostTarget {
projectKey: string; projectViewKey: string;
} }
export function resolveSidebarProjectIconTargets( export function resolveSidebarProjectIconTargets(
@@ -52,7 +52,7 @@ export function resolveSidebarProjectIconTargets(
): SidebarProjectIconTarget[] { ): SidebarProjectIconTarget[] {
return projects.flatMap((project) => { return projects.flatMap((project) => {
const target = resolveSidebarProjectIconTarget(project); const target = resolveSidebarProjectIconTarget(project);
return target ? [{ projectKey: project.projectKey, ...target }] : []; return target ? [{ projectViewKey: project.viewKey, ...target }] : [];
}); });
} }
@@ -24,7 +24,7 @@ function workspace(input: {
workspaceKey: `${input.serverId}:${input.workspaceId}`, workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId, serverId: input.serverId,
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
projectKey: input.projectKey ?? "project-default", projectViewKey: input.projectKey ?? "project-default",
projectName: input.projectKey ?? "Project", projectName: input.projectKey ?? "Project",
workspaceDirectory: input.workspaceDirectory, workspaceDirectory: input.workspaceDirectory,
projectKind: "git", projectKind: "git",
@@ -46,7 +46,7 @@ function workspace(input: {
function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): SidebarProjectEntry { function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): SidebarProjectEntry {
return { return {
projectKey, viewKey: projectKey,
projectName: projectKey, projectName: projectKey,
projectKind: "git", projectKind: "git",
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
+1 -1
View File
@@ -36,7 +36,7 @@ export function buildSidebarShortcutModel(input: {
return buildSidebarShortcutSections({ return buildSidebarShortcutSections({
sections: input.projects.map((project) => ({ sections: input.projects.map((project) => ({
workspaces: project.workspaces, workspaces: project.workspaces,
collapsed: input.collapsedProjectKeys.has(project.projectKey), collapsed: input.collapsedProjectKeys.has(project.viewKey),
})), })),
shortcutLimit: input.shortcutLimit, shortcutLimit: input.shortcutLimit,
}); });