From 99eca19a76ff7012ab891b619f8a89881f868ef4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 15:42:08 +0200 Subject: [PATCH] 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 --- .../app/e2e/empty-project-persists.spec.ts | 23 +- packages/app/e2e/helpers/new-workspace.ts | 17 +- packages/app/e2e/helpers/project-view-key.ts | 7 + packages/app/e2e/new-workspace-entry.spec.ts | 9 +- ...orkspace-host-project-preservation.spec.ts | 196 ++++++++++ .../app/e2e/schedules-project-target.spec.ts | 17 +- packages/app/e2e/sidebar-model-b.spec.ts | 5 +- packages/app/e2e/sidebar-workspace.spec.ts | 5 +- .../app/e2e/workspace-multiplicity.spec.ts | 13 +- packages/app/src/components/left-sidebar.tsx | 16 +- .../app/src/components/project-icon-view.tsx | 8 +- .../sidebar-workspace-list.test.tsx | 11 +- .../src/components/sidebar-workspace-list.tsx | 115 +++--- .../src/components/sidebar/sidebar-model.tsx | 6 +- .../sidebar/sidebar-projection.test.ts | 6 +- .../components/sidebar/sidebar-projection.ts | 6 +- .../sidebar/sidebar-status-list.tsx | 52 +-- .../sidebar/sidebar-subtitle-project-icon.tsx | 6 +- .../sidebar/sidebar-workspace-row-content.tsx | 2 +- .../hooks/sidebar-status-view-model.test.ts | 23 +- .../src/hooks/sidebar-status-view-model.ts | 10 +- .../sidebar-workspaces-view-model.test.ts | 15 +- .../hooks/sidebar-workspaces-view-model.ts | 41 +- .../app/src/hooks/use-sidebar-pins.test.ts | 4 +- .../hooks/use-sidebar-shortcut-model.test.tsx | 4 +- .../src/hooks/use-sidebar-workspaces-list.ts | 18 +- .../app/src/projects/host-project-model.ts | 102 ++++- .../app/src/projects/host-projects.test.ts | 3 +- packages/app/src/projects/host-projects.ts | 3 + packages/app/src/projects/project-icons.ts | 8 +- .../app/src/projects/project-remove.test.ts | 1 - packages/app/src/projects/project-remove.ts | 1 - .../src/projects/workspace-structure.test.ts | 35 +- .../app/src/projects/workspace-structure.ts | 81 +++- .../src/schedules/schedule-form-model.test.ts | 2 +- .../schedule-project-targets.test.ts | 2 +- .../src/schedules/schedule-project-targets.ts | 10 +- .../use-schedule-form-model.test.tsx | 2 +- .../new-workspace-initial-context.test.ts | 1 + .../screens/new-workspace-initial-context.ts | 32 +- .../app/src/screens/new-workspace-screen.tsx | 44 +-- .../screens/new-workspace/project-picker.ts | 15 +- .../new-workspace/project-selection.test.ts | 362 ++++++++++++++++-- .../new-workspace/project-selection.ts | 146 +++++-- .../src/screens/project-settings-screen.tsx | 18 +- .../app/src/screens/projects-screen.test.tsx | 12 +- packages/app/src/screens/projects-screen.tsx | 29 +- .../session-store-hooks/selectors.test.ts | 27 +- .../stores/session-store-hooks/selectors.ts | 5 +- .../app/src/stores/sidebar-order-store.ts | 28 +- packages/app/src/utils/projects.test.ts | 3 +- packages/app/src/utils/projects.ts | 24 +- .../utils/sidebar-project-row-model.test.ts | 21 +- .../src/utils/sidebar-project-row-model.ts | 4 +- .../app/src/utils/sidebar-shortcuts.test.ts | 4 +- packages/app/src/utils/sidebar-shortcuts.ts | 2 +- 56 files changed, 1234 insertions(+), 428 deletions(-) create mode 100644 packages/app/e2e/helpers/project-view-key.ts create mode 100644 packages/app/e2e/new-workspace-host-project-preservation.spec.ts diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index 0fead9d1b..9ce761ab3 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -10,6 +10,7 @@ import { import { expectOpenedProject } from "./helpers/project-picker-ui"; import { connectSeedClient, seedWorkspace } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { createTempGitRepo } from "./helpers/workspace"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; @@ -32,12 +33,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro await archiveItem.click(); } -async function removeProjectFromSidebar(page: Page, projectKey: string): Promise { - const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`); +async function removeProjectFromSidebar(page: Page, projectViewKey: string): Promise { + const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`); await expect(projectRow).toBeVisible({ timeout: 30_000 }); 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 kebab.click(); @@ -45,7 +46,7 @@ async function removeProjectFromSidebar(page: Page, projectKey: string): Promise // user-confirmed removal proceeds deterministically. 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 removeItem.click(); } @@ -152,9 +153,10 @@ test.describe("Project with no workspaces persists", () => { const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" }); 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( - `sidebar-project-new-workspace-row-${workspace.projectKey}`, + `sidebar-project-new-workspace-row-${projectViewKey}`, ); const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace"); @@ -198,7 +200,8 @@ test.describe("Project remove", () => { let readdedProjectId: string | null = null; 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 waitForSidebarHydration(page); @@ -207,7 +210,7 @@ test.describe("Project remove", () => { timeout: 30_000, }); - await removeProjectFromSidebar(page, workspace.projectKey); + await removeProjectFromSidebar(page, projectViewKey); await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, { timeout: 30_000, @@ -233,7 +236,9 @@ test.describe("Project remove", () => { await expect(projectRow).toContainText(workspace.projectDisplayName); await expect(projectRow).not.toContainText(workspace.repoPath); await expect( - page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`), + page.getByTestId( + `sidebar-project-new-workspace-row-${projectEquivalenceViewKey(readdedProjectKey)}`, + ), ).toBeVisible({ timeout: 30_000 }); } finally { if (readdedProjectId) { diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index f9245a17c..7a021fb26 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -3,6 +3,7 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { connectDaemonClient } from "./daemon-client-loader"; import { daemonWsRoutePattern } from "./daemon-port"; +import { projectEquivalenceViewKey } from "./project-view-key"; import { expectWorkspaceHeader } from "./workspace-ui"; type NewWorkspaceDaemonClient = Pick< @@ -167,11 +168,12 @@ export async function openNewWorkspaceComposer( page: Page, input: { projectKey: string; projectDisplayName: string }, ): Promise { - 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 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 button.click(); @@ -216,8 +218,10 @@ export async function expectNewWorkspaceDraft(page: Page, draft: string): Promis } export async function selectNewWorkspaceHost(page: Page, hostLabel: string): Promise { - await page.getByTestId("host-picker-trigger").click(); - await page.getByText(hostLabel, { exact: true }).click(); + const trigger = page.getByTestId("host-picker-trigger"); + await trigger.click(); + await page.getByRole("button", { name: hostLabel, exact: true }).click(); + await expect(trigger).toContainText(hostLabel); } export async function submitNewWorkspacePrompt( @@ -244,13 +248,14 @@ export async function clickNewWorkspaceButton( export async function selectNewWorkspaceProject( page: Page, - input: { projectKey: string; projectDisplayName: string }, + input: { projectKey: string; projectDisplayName: string; projectViewKey?: string }, ): Promise { const trigger = page.getByTestId("new-workspace-project-picker-trigger"); await expect(trigger).toBeVisible({ timeout: 30_000 }); 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 option.click(); diff --git a/packages/app/e2e/helpers/project-view-key.ts b/packages/app/e2e/helpers/project-view-key.ts new file mode 100644 index 000000000..1c43d9421 --- /dev/null +++ b/packages/app/e2e/helpers/project-view-key.ts @@ -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]); +} diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index 78aad7ece..920b03415 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -11,6 +11,7 @@ import { getE2EDaemonPort } from "./helpers/daemon-port"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { seedSavedSettingsHosts } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; 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. 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", () => { @@ -195,7 +196,7 @@ test.describe("New workspace entry points", () => { // route-driven navigation targets a different project. await page.getByTestId("new-workspace-project-picker-trigger").click(); 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 optionC.click(); @@ -238,7 +239,7 @@ test.describe("New workspace entry points", () => { await expect(trigger).toBeVisible({ timeout: 30_000 }); await trigger.click(); 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 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. await trigger.click(); 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 gitOption.click(); diff --git a/packages/app/e2e/new-workspace-host-project-preservation.spec.ts b/packages/app/e2e/new-workspace-host-project-preservation.spec.ts new file mode 100644 index 000000000..6ee95a900 --- /dev/null +++ b/packages/app/e2e/new-workspace-host-project-preservation.spec.ts @@ -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 { + 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[0], + scenario: CrossHostProjectScenario, +): Promise { + 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); + }); +}); diff --git a/packages/app/e2e/schedules-project-target.spec.ts b/packages/app/e2e/schedules-project-target.spec.ts index 64c02ee30..29ea4c2c1 100644 --- a/packages/app/e2e/schedules-project-target.spec.ts +++ b/packages/app/e2e/schedules-project-target.spec.ts @@ -7,6 +7,7 @@ import { } from "./helpers/schedule-fake-host"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { escapeRegex } from "./helpers/regex"; import { expectNoTruncation } from "./helpers/no-truncation"; 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 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"); await expect(projectTrigger).toContainText(workspace.projectDisplayName); await expectSettled(projectTrigger); @@ -315,7 +318,9 @@ test.describe("Schedules project target", () => { await expectSettled(hostTrigger); 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 expectSettled(projectTrigger); @@ -337,7 +342,9 @@ test.describe("Schedules project target", () => { await expectSettled(projectTrigger); 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 expectSettled(projectTrigger); 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.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 expect( page.getByText("Off keeps each run's workspace in the sidebar for inspection."), diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts index 9eaeeab60..7d5e0fdc1 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -4,6 +4,7 @@ import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { seedMockAgentWorkspace } from "./helpers/mock-agent"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; @@ -17,11 +18,11 @@ function workspaceRow(page: Page, workspaceId: 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) { - 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 { diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 7494fa239..69c86af84 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -11,6 +11,7 @@ import { import { seedWorkspace } from "./helpers/seed-client"; import { expectWorkspaceHeader } from "./helpers/workspace-ui"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { escapeRegex } from "./helpers/regex"; 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 page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click(); + await page + .getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(workspace.projectKey)}`) + .click(); await waitForSidebarWorkspace(page, lastWorkspaceId); const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll"); diff --git a/packages/app/e2e/workspace-multiplicity.spec.ts b/packages/app/e2e/workspace-multiplicity.spec.ts index bafc4407a..ba7abe549 100644 --- a/packages/app/e2e/workspace-multiplicity.spec.ts +++ b/packages/app/e2e/workspace-multiplicity.spec.ts @@ -12,6 +12,7 @@ import { import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { expectExplorerEntryVisible } from "./helpers/file-explorer"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; // 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); // Model B: a non-git project is an expandable parent like any other, with // its single workspace already rendered as its own row underneath. - await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ - timeout: 30_000, - }); + await expect( + page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`), + ).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ 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 // the same expandable parent. - await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ - timeout: 30_000, - }); + await expect( + page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`), + ).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ timeout: 30_000, }); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 9fc43b44e..d64a695b8 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -77,14 +77,14 @@ interface SidebarSharedProps { pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; + projectNamesByViewKey: Map; isInitialLoad: boolean; isRevalidating: boolean; isManualRefresh: boolean; groupMode: SidebarGroupMode; collapsedProjectKeys: ReadonlySet; shortcutIndexByWorkspaceKey: Map; - toggleProjectCollapsed: (projectKey: string) => void; + toggleProjectCollapsed: (projectViewKey: string) => void; handleRefresh: () => void; handleOpenProject: () => void; handleHome: () => void; @@ -132,7 +132,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const { projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, isInitialLoad, isRevalidating, refreshAll, @@ -238,7 +238,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole pinnedGroups, projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, isInitialLoad, isRevalidating, isManualRefresh, @@ -592,7 +592,7 @@ function MobileSidebar({ pinnedGroups, projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, isInitialLoad, isRevalidating, isManualRefresh, @@ -708,7 +708,7 @@ function MobileSidebar({ pinnedGroups={pinnedGroups} projects={projects} workspaceEntriesByKey={workspaceEntriesByKey} - projectNamesByKey={projectNamesByKey} + projectNamesByViewKey={projectNamesByViewKey} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} onWorkspacePress={handleWorkspacePress} @@ -738,7 +738,7 @@ function DesktopSidebar({ pinnedGroups, projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, isInitialLoad, isRevalidating, isManualRefresh, @@ -875,7 +875,7 @@ function DesktopSidebar({ pinnedGroups={pinnedGroups} projects={projects} workspaceEntriesByKey={workspaceEntriesByKey} - projectNamesByKey={projectNamesByKey} + projectNamesByViewKey={projectNamesByViewKey} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} onAddProject={handleOpenProject} diff --git a/packages/app/src/components/project-icon-view.tsx b/packages/app/src/components/project-icon-view.tsx index 95bc886cb..3d2444b11 100644 --- a/packages/app/src/components/project-icon-view.tsx +++ b/packages/app/src/components/project-icon-view.tsx @@ -15,22 +15,22 @@ const WHITE_TEXT = { color: "#ffffff" } as const; export function ProjectIconView({ iconDataUri, initial, - projectKey, + projectViewKey, imageStyle, fallbackStyle, textStyle, }: { iconDataUri: string | null; initial: string; - projectKey: string; + projectViewKey: string; imageStyle: StyleProp; fallbackStyle: StyleProp; textStyle: StyleProp; }) { const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); const fallbackStyles = useMemo( - () => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectKey) }], - [fallbackStyle, projectKey], + () => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectViewKey) }], + [fallbackStyle, projectViewKey], ); const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 972d46bc1..3773f8629 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRoot, type Root } from "react-dom/client"; import React from "react"; import type { ReactElement } from "react"; +import { createProjectViewKey } from "@/projects/workspace-structure"; vi.hoisted(() => { (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; @@ -196,7 +197,7 @@ function ProjectHeaderProbe({ project: SidebarProjectEntry; counts: RenderCounts; }): null { - incrementRecord(counts.headers, project.projectKey); + incrementRecord(counts.headers, project.viewKey); return null; } @@ -232,7 +233,7 @@ function ProjectActiveProbe({ activeSelection?.serverId === serverId && project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId); void isActive; - incrementRecord(counts.projectSelection, project.projectKey); + incrementRecord(counts.projectSelection, project.viewKey); return null; } @@ -260,7 +261,7 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement { return ( <> {projects.map((project) => ( -
+
{project.workspaces.map((entry) => ( @@ -453,8 +454,8 @@ describe("sidebar workspace render isolation", () => { expect(counts.frame).toBe(1); expect(counts.projectSelection).toEqual({ - "project-a": 1, - "project-b": 1, + [createProjectViewKey({ kind: "equivalence", projectKey: "project-a" })]: 1, + [createProjectViewKey({ kind: "equivalence", projectKey: "project-b" })]: 1, }); expect(counts.rowSelection).toEqual({ "a-main": 1, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index cc9ebfb87..28f94e823 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -57,7 +57,7 @@ import { import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useHostFeatureMap } from "@/runtime/host-features"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; import { buildNewWorkspaceRoute, buildProjectSettingsRoute, @@ -137,7 +137,7 @@ import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; 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 DEFAULT_STATUS_DOT_SIZE = 7; @@ -234,9 +234,9 @@ interface SidebarWorkspaceListProps { pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; + projectNamesByViewKey: Map; collapsedProjectKeys: ReadonlySet; - onToggleProjectCollapsed: (projectKey: string) => void; + onToggleProjectCollapsed: (projectViewKey: string) => void; shortcutIndexByWorkspaceKey: Map; groupMode: "project" | "status"; isRefreshing?: boolean; @@ -431,7 +431,7 @@ function ProjectLeadingVisual({ displayName, iconDataUri, workspace, - projectKey, + projectViewKey, chevron = null, showChevron = false, isArchiving = false, @@ -439,7 +439,7 @@ function ProjectLeadingVisual({ displayName: string; iconDataUri: string | null; workspace: SidebarWorkspaceEntry | null; - projectKey: string; + projectViewKey: string; chevron?: "expand" | "collapse" | null; showChevron?: boolean; isArchiving?: boolean; @@ -467,7 +467,7 @@ function ProjectLeadingVisual({ ); @@ -477,7 +477,7 @@ function ProjectLeadingVisual({ ) : null} {onRemoveProject ? ( @@ -527,7 +527,7 @@ function ProjectRowTrailingActions({ pointerEvents={actionsVisible ? "auto" : "none"} > void; @@ -595,14 +595,14 @@ function ProjectKebabMenu({ style={projectKebabStyle} accessibilityRole={platformIsWeb ? undefined : "button"} accessibilityLabel={t("sidebar.project.actions.menu")} - testID={`sidebar-project-kebab-${projectKey}`} + testID={`sidebar-project-kebab-${projectViewKey}`} > {renderKebabTriggerIcon} {canOpenProjectSettings ? ( @@ -611,7 +611,7 @@ function ProjectKebabMenu({ ) : null} {canOpenInNewWindow ? ( @@ -620,10 +620,10 @@ function ProjectKebabMenu({ ) : null} {dotColorStyle ? ( {({ hovered, pressed }) => ( <> @@ -1040,7 +1040,7 @@ function ProjectHeaderRow({ displayName={displayName} iconDataUri={iconDataUri} workspace={workspace} - projectKey={project.projectKey} + projectViewKey={project.viewKey} chevron={chevron} showChevron={isHovered && chevron !== null} isArchiving={isArchiving} @@ -1088,7 +1088,7 @@ function ProjectHeaderRow({ onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} - testID={`sidebar-project-row-${project.projectKey}`} + testID={`sidebar-project-row-${project.viewKey}`} > {rowChildren} @@ -1111,7 +1111,7 @@ function ProjectHeaderRow({ onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} - testID={`sidebar-project-row-${project.projectKey}`} + testID={`sidebar-project-row-${project.viewKey}`} > {rowChildren} @@ -1622,9 +1622,9 @@ function ProjectBlock({ showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; parentGestureRef?: MutableRefObject; - onToggleCollapsed: (projectKey: string) => void; + onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; - onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspacePlacement[]) => void; + onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; @@ -1725,9 +1725,9 @@ function ProjectBlock({ const handleWorkspaceDragEnd = useCallback( (workspaces: SidebarWorkspacePlacement[]) => { - onWorkspaceReorder(project.projectKey, workspaces); + onWorkspaceReorder(project.viewKey, workspaces); }, - [onWorkspaceReorder, project.projectKey], + [onWorkspaceReorder, project.viewKey], ); const toast = useToast(); @@ -1753,7 +1753,6 @@ function ProjectBlock({ setIsRemovingProject(true); const readiness = getCurrentProjectRemoveReadiness({ - projectKey: project.projectKey, hosts: project.hosts, }); if (readiness.kind === "needs_host_update") { @@ -1785,11 +1784,11 @@ function ProjectBlock({ setIsRemovingProject(false); }); })(); - }, [isRemovingProject, displayName, t, toast, project.projectKey, project.hosts]); + }, [isRemovingProject, displayName, t, toast, project.hosts]); const handleToggleCollapsed = useCallback(() => { - onToggleCollapsed(project.projectKey); - }, [onToggleCollapsed, project.projectKey]); + onToggleCollapsed(project.viewKey); + }, [onToggleCollapsed, project.viewKey]); let projectChildren = null; if (!collapsed) { @@ -1797,7 +1796,7 @@ function ProjectBlock({ projectChildren = ( <> ) : null} @@ -1925,7 +1924,7 @@ export function SidebarWorkspaceList({ pinnedGroups, projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, @@ -1959,7 +1958,7 @@ export function SidebarWorkspaceList({ () => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []), [groupMode, projects], ); - const statusProjectIconByProjectKey = useProjectIconDataByProjectKey({ + const statusProjectIconByProjectViewKey = useProjectIconDataByProjectViewKey({ projects: statusProjectIconTargets, }); @@ -1969,8 +1968,8 @@ export function SidebarWorkspaceList({ statusGroups={statusGroups} pinnedGroups={pinnedGroups} workspaceEntriesByKey={workspaceEntriesByKey} - projectNamesByKey={projectNamesByKey} - projectIconByProjectKey={statusProjectIconByProjectKey} + projectNamesByViewKey={projectNamesByViewKey} + projectIconByProjectViewKey={statusProjectIconByProjectViewKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} onWorkspacePress={onWorkspacePress} hostLabelByServerId={hostLabelByServerId} @@ -2008,8 +2007,8 @@ function SidebarStatusModeWrapper({ statusGroups, pinnedGroups, workspaceEntriesByKey, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, shortcutIndexByWorkspaceKey: _projectShortcutIndex, onWorkspacePress, hostLabelByServerId, @@ -2021,8 +2020,8 @@ function SidebarStatusModeWrapper({ statusGroups: StatusGroup[]; pinnedGroups: PinnedSidebarGroups; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; hostLabelByServerId: ReadonlyMap; @@ -2040,8 +2039,8 @@ function SidebarStatusModeWrapper({ const entry = workspaceEntriesByKey.get(workspace.workspaceKey); return entry ? [entry] : []; })} - projectNamesByKey={projectNamesByKey} - projectIconByProjectKey={projectIconByProjectKey} + projectNamesByViewKey={projectNamesByViewKey} + projectIconByProjectViewKey={projectIconByProjectViewKey} shortcutIndexByWorkspaceKey={_projectShortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} @@ -2074,7 +2073,7 @@ function ProjectModeList({ onToggleWorkspacePin, }: Omit< SidebarWorkspaceListProps, - "statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh" + "statusGroups" | "projectNamesByViewKey" | "groupMode" | "isRefreshing" | "onRefresh" > & { pathname: string; hostLabelByServerId: ReadonlyMap; @@ -2128,7 +2127,7 @@ function ProjectModeList({ [parentGestureRef], ); - const projectIconByProjectKey = useProjectIconDataByProjectKey({ + const projectIconByProjectViewKey = useProjectIconDataByProjectViewKey({ projects: projectIconTargets, }); @@ -2180,7 +2179,7 @@ function ProjectModeList({ const handleProjectDragEnd = useCallback( (reorderedProjects: SidebarProjectEntry[]) => { - const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey); + const reorderedProjectKeys = reorderedProjects.map((project) => project.viewKey); const currentProjectOrder = getProjectOrder(); if ( !hasVisibleOrderChanged({ @@ -2202,9 +2201,9 @@ function ProjectModeList({ ); const handleWorkspaceReorder = useCallback( - (projectKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { + (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); - const currentWorkspaceOrder = getWorkspaceOrder(projectKey); + const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); if ( !hasVisibleOrderChanged({ currentOrder: currentWorkspaceOrder, @@ -2215,7 +2214,7 @@ function ProjectModeList({ } setWorkspaceOrder( - projectKey, + projectViewKey, mergeWithRemainder({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, @@ -2262,12 +2261,12 @@ function ProjectModeList({ ) => { return ( ; - toggleProjectCollapsed: (projectKey: string) => void; + toggleProjectCollapsed: (projectViewKey: string) => void; shortcutModel: SidebarShortcutModel; } @@ -59,7 +59,7 @@ export function SidebarModelProvider({ projects: list.projects, pinnedKeys, workspaceEntriesByKey: projectionWorkspaceEntriesByKey, - projectNamesByKey: list.projectNamesByKey, + projectNamesByViewKey: list.projectNamesByViewKey, groupMode, pinnedCollapsed, collapsedProjectKeys, @@ -69,7 +69,7 @@ export function SidebarModelProvider({ collapsedProjectKeys, collapsedStatusGroupKeys, groupMode, - list.projectNamesByKey, + list.projectNamesByViewKey, list.projects, pinnedCollapsed, pinnedKeys, diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index 2c2856abc..d456efa3b 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -11,7 +11,7 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu workspaceKey: `srv:${id}`, serverId: "srv", workspaceId: id, - projectKey: "project", + projectViewKey: "project", projectName: "Project", projectKind: "git", workspaceKind: "worktree", @@ -36,7 +36,7 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { return { - projectKey: "project", + viewKey: "project", projectName: "Project", projectKind: "git", iconWorkingDir: "/repo", @@ -68,7 +68,7 @@ function projectionInput(options?: { [pinned.entry.workspaceKey, pinned.entry], [unpinned.entry.workspaceKey, unpinned.entry], ]), - projectNamesByKey: new Map([["project", "Project"]]), + projectNamesByViewKey: new Map([["project", "Project"]]), groupMode: options?.groupMode ?? ("project" as const), pinnedCollapsed: options?.pinnedCollapsed ?? false, collapsedProjectKeys: new Set(), diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index 641fb7e2a..be96f5597 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -25,7 +25,7 @@ export function buildSidebarProjection(input: { projects: SidebarProjectEntry[]; pinnedKeys: PinnedSidebarKeys; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; + projectNamesByViewKey: Map; groupMode: SidebarGroupMode; pinnedCollapsed: boolean; collapsedProjectKeys: ReadonlySet; @@ -42,7 +42,7 @@ export function buildSidebarProjection(input: { Array.from(input.workspaceEntriesByKey.values()).filter( (workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey), ), - input.projectNamesByKey, + input.projectNamesByViewKey, ) : []; @@ -61,7 +61,7 @@ export function buildSidebarProjection(input: { sections.push( ...pinnedGroups.unpinnedProjects.map((project) => ({ workspaces: project.workspaces, - collapsed: input.collapsedProjectKeys.has(project.projectKey), + collapsed: input.collapsedProjectKeys.has(project.viewKey), })), ); } diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 35444cc2a..2a622679a 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -64,8 +64,8 @@ const ThemedCircleX = withUnistyles(CircleX); interface StatusWorkspaceListProps { groups: StatusGroup[]; pinnedWorkspaces: SidebarWorkspaceEntry[]; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -79,8 +79,8 @@ interface StatusWorkspaceListProps { export function SidebarStatusWorkspaceList({ groups, pinnedWorkspaces, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, shortcutIndexByWorkspaceKey, showShortcutBadges, onWorkspacePress, @@ -118,8 +118,8 @@ export function SidebarStatusWorkspaceList({ workspace={workspace} {...buildStatusRowProjectPresentation({ workspace, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, hostLabelByServerId, showHostLabels, })} @@ -145,8 +145,8 @@ export function SidebarStatusWorkspaceList({ ; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -215,8 +215,8 @@ function StatusGroupList({ key={group.bucket} group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} - projectNamesByKey={projectNamesByKey} - projectIconByProjectKey={projectIconByProjectKey} + projectNamesByViewKey={projectNamesByViewKey} + projectIconByProjectViewKey={projectIconByProjectViewKey} shortcutIndex={shortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} @@ -233,8 +233,8 @@ function StatusGroupList({ function StatusGroupRows({ group, collapsed, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, shortcutIndex, showShortcutBadges, onWorkspacePress, @@ -245,8 +245,8 @@ function StatusGroupRows({ }: { group: StatusGroup; collapsed: boolean; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -276,8 +276,8 @@ function StatusGroupRows({ workspace={workspace} {...buildStatusRowProjectPresentation({ workspace, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, hostLabelByServerId, showHostLabels, })} @@ -324,25 +324,25 @@ interface StatusRowProjectPresentation { function buildStatusRowProjectPresentation({ workspace, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, hostLabelByServerId, showHostLabels, }: { workspace: SidebarWorkspaceEntry; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; }): StatusRowProjectPresentation { - const projectName = projectNamesByKey.get(workspace.projectKey) ?? ""; + const projectName = projectNamesByViewKey.get(workspace.projectViewKey) ?? ""; const hostLabel = showHostLabels ? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId) : null; return { subtitle: buildStatusRowSubtitle({ projectName, hostLabel }), projectName, - projectIconDataUri: projectIconByProjectKey.get(workspace.projectKey) ?? null, + projectIconDataUri: projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null, }; } diff --git a/packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx b/packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx index 37ce83833..5e609821e 100644 --- a/packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx +++ b/packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx @@ -10,12 +10,12 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp * the meaning, so the icon itself stays out of the accessibility tree. */ export function SidebarSubtitleProjectIcon({ - projectKey, + projectViewKey, projectName, iconDataUri, testID, }: { - projectKey: string; + projectViewKey: string; projectName: string; iconDataUri: string | null; testID?: string; @@ -37,7 +37,7 @@ export function SidebarSubtitleProjectIcon({ {subtitleProjectName ? ( { ]); const workspaces = [ - ws({ workspaceKey: "srv:1", statusBucket: "done", projectKey: "proj-b", name: "zebra" }), - ws({ workspaceKey: "srv:2", statusBucket: "done", projectKey: "proj-a", name: "alpha" }), - ws({ workspaceKey: "srv:3", statusBucket: "done", projectKey: "proj-a", name: "alpha" }), + ws({ + workspaceKey: "srv:1", + 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); diff --git a/packages/app/src/hooks/sidebar-status-view-model.ts b/packages/app/src/hooks/sidebar-status-view-model.ts index f22539edb..7a2738091 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.ts @@ -26,7 +26,7 @@ export interface StatusGroup { export function buildStatusGroups( workspaces: SidebarWorkspaceEntry[], - projectNamesByKey: Map, + projectNamesByViewKey: Map, ): StatusGroup[] { const bucketRows = new Map(); @@ -46,7 +46,7 @@ export function buildStatusGroups( const rows = bucketRows.get(bucket); 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 }); } @@ -56,7 +56,7 @@ export function buildStatusGroups( function compareStatusRows( a: SidebarWorkspaceEntry, b: SidebarWorkspaceEntry, - projectNamesByKey: Map, + projectNamesByViewKey: Map, ): number { const aTime = a.statusEnteredAt?.getTime() ?? null; const bTime = b.statusEnteredAt?.getTime() ?? null; @@ -69,8 +69,8 @@ function compareStatusRows( return 1; } - const aProject = projectNamesByKey.get(a.projectKey) ?? ""; - const bProject = projectNamesByKey.get(b.projectKey) ?? ""; + const aProject = projectNamesByViewKey.get(a.projectViewKey) ?? ""; + const bProject = projectNamesByViewKey.get(b.projectViewKey) ?? ""; const projectCmp = aProject.localeCompare(bProject); if (projectCmp !== 0) return projectCmp; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 981648574..5f4e666d0 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -83,6 +83,7 @@ function project(input: { >; }): WorkspaceStructureProject { return { + viewKey: input.projectKey, projectKey: input.projectKey, projectName: input.projectName ?? input.projectKey, 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", () => { @@ -327,7 +328,7 @@ describe("shared sidebar workspace model", () => { ]); expect(model.projects).toEqual([ expect.objectContaining({ - projectKey: "getpaseo/paseo", + viewKey: "getpaseo/paseo", hosts: [ { serverId: "host-a", @@ -366,7 +367,7 @@ describe("shared sidebar workspace model", () => { ["host-a:main", "done", "main"], ["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", () => { @@ -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.workspaceOrders).toEqual([ - { projectKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] }, - { projectKey: "project-b", order: ["srv:ws-3"] }, + { projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] }, + { projectViewKey: "project-b", order: ["srv:ws-3"] }, ]); }); @@ -550,7 +551,7 @@ describe("computeSidebarOrderUpdates", () => { expect(updates.workspaceOrders).toEqual([ { - projectKey: "project-a", + projectViewKey: "project-a", order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"], }, ]); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 937111e79..6feb14cb8 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -19,7 +19,7 @@ export interface SidebarWorkspacePlacement { workspaceKey: string; serverId: string; workspaceId: string; - projectKey: string; + projectViewKey: string; projectName: string; projectRootPath?: string; workspaceDirectory?: string; @@ -50,7 +50,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement { } export interface SidebarProjectEntry { - projectKey: string; + viewKey: string; projectName: string; projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; @@ -61,7 +61,7 @@ export interface SidebarProjectEntry { export interface SidebarWorkspacePlacementModel { workspaces: SidebarWorkspacePlacement[]; projects: SidebarProjectEntry[]; - projectNamesByKey: Map; + projectNamesByViewKey: Map; } export interface SidebarWorkspaceSession { @@ -122,11 +122,11 @@ interface EffectiveWorkspaceStatus { enteredAt: Date | null; } -function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string { +function projectNameForWorkspace(workspace: WorkspaceDescriptor): string { return ( workspace.projectCustomName ?? workspace.projectDisplayName ?? - projectDisplayNameFromProjectId(projectKey) + projectDisplayNameFromProjectId(workspace.projectId) ); } @@ -141,18 +141,18 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin export function createSidebarWorkspaceEntry(input: { serverId: string; workspace: WorkspaceDescriptor; - projectKey?: string; + projectViewKey?: string; pendingCreateAttempts?: Record; workspaceAgentActivity?: ReadonlyMap; }): SidebarWorkspaceEntry { - const projectKey = input.projectKey ?? input.workspace.projectId; + const projectViewKey = input.projectViewKey ?? input.workspace.projectId; const effectiveStatus = deriveEffectiveWorkspaceStatus(input); return { workspaceKey: `${input.serverId}:${input.workspace.id}`, serverId: input.serverId, workspaceId: input.workspace.id, - projectKey, - projectName: projectNameForWorkspace(input.workspace, projectKey), + projectViewKey, + projectName: projectNameForWorkspace(input.workspace), projectRootPath: input.workspace.projectRootPath, workspaceDirectory: input.workspace.workspaceDirectory, projectKind: input.workspace.projectKind, @@ -228,8 +228,8 @@ export function buildSidebarWorkspacePlacementModel(input: { return { projects, workspaces: projects.flatMap((project) => project.workspaces), - projectNamesByKey: new Map( - projects.map((project) => [project.projectKey, project.projectName]), + projectNamesByViewKey: new Map( + projects.map((project) => [project.viewKey, project.projectName]), ), }; } @@ -247,7 +247,7 @@ function createStructuralWorkspaceEntry(input: { workspaceKey: identity.workspaceKey, serverId: identity.serverId, workspaceId: identity.workspaceId, - projectKey: input.project.projectKey, + projectViewKey: input.project.viewKey, projectName: input.project.projectName, projectRootPath: input.project.iconWorkingDir, workspaceDirectory: undefined, @@ -324,7 +324,7 @@ export function buildSidebarWorkspaceEntries(input: { const entry = createSidebarWorkspaceEntry({ serverId: placement.serverId, workspace, - projectKey: placement.projectKey, + projectViewKey: placement.projectViewKey, pendingCreateAttempts: input.pendingCreateAttempts, workspaceAgentActivity: session.workspaceAgentActivity, }); @@ -369,6 +369,7 @@ export function buildSidebarProjectsFromStructure(input: { }): SidebarProjectEntry[] { return buildSidebarProjectsFromHostProjects({ projects: input.projects.map((project) => ({ + viewKey: project.viewKey, projectKey: project.projectKey, projectName: project.projectName, projectKind: project.projectKind, @@ -387,7 +388,7 @@ export function buildSidebarProjectsFromHostProjects(input: { } return input.projects.map((project) => ({ - projectKey: project.projectKey, + viewKey: project.viewKey, projectName: project.projectName, projectKind: project.projectKind, iconWorkingDir: project.iconWorkingDir, @@ -498,13 +499,13 @@ export function prependMissingOrderKeys(input: { export interface SidebarOrderUpdates { projectOrder: string[] | null; - workspaceOrders: Array<{ projectKey: string; order: string[] }>; + workspaceOrders: Array<{ projectViewKey: string; order: string[] }>; } export function computeSidebarOrderUpdates(input: { projects: SidebarProjectEntry[]; persistedProjectOrder: string[]; - getWorkspaceOrder: (projectKey: string) => string[]; + getWorkspaceOrder: (projectViewKey: string) => string[]; }): SidebarOrderUpdates { if (input.projects.length === 0) { return { projectOrder: null, workspaceOrders: [] }; @@ -512,19 +513,19 @@ export function computeSidebarOrderUpdates(input: { const nextProjectOrder = appendMissingOrderKeys({ 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 workspaceOrders: Array<{ projectKey: string; order: string[] }> = []; + const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = []; for (const project of input.projects) { - const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey); + const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey); const nextWorkspaceOrder = prependMissingOrderKeys({ currentOrder: persistedWorkspaceOrder, visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), }); if (nextWorkspaceOrder !== persistedWorkspaceOrder) { - workspaceOrders.push({ projectKey: project.projectKey, order: nextWorkspaceOrder }); + workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder }); } } diff --git a/packages/app/src/hooks/use-sidebar-pins.test.ts b/packages/app/src/hooks/use-sidebar-pins.test.ts index 3c2962314..80af801e0 100644 --- a/packages/app/src/hooks/use-sidebar-pins.test.ts +++ b/packages/app/src/hooks/use-sidebar-pins.test.ts @@ -10,7 +10,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement { workspaceKey, serverId: "s1", workspaceId: workspaceKey, - projectKey: "p1", + projectViewKey: "p1", projectName: "Project 1", projectKind: "git", workspaceKind: "worktree", @@ -20,7 +20,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement { function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { return { - projectKey, + viewKey: projectKey, projectName: projectKey, projectKind: "git", iconWorkingDir: "", diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx index d8a59e08b..c54fa0cd2 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx @@ -17,7 +17,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt workspaceKey: `srv:${workspaceId}`, serverId: "srv", workspaceId, - projectKey, + projectViewKey: projectKey, projectName: projectKey, projectRootPath: `/repo/${projectKey}`, workspaceDirectory: `/repo/${projectKey}/${workspaceId}`, @@ -40,7 +40,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt function project(projectKey: string): SidebarProjectEntry { return { - projectKey, + viewKey: projectKey, projectName: projectKey, projectKind: "git", iconWorkingDir: `/repo/${projectKey}`, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index a47d6d277..5491aadf3 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -41,7 +41,7 @@ const EMPTY_PROJECT_NAMES = new Map(); export interface SidebarWorkspacesListResult { workspacePlacements: SidebarWorkspacePlacement[]; projects: SidebarProjectEntry[]; - projectNamesByKey: Map; + projectNamesByViewKey: Map; isLoading: boolean; isInitialLoad: boolean; isRevalidating: boolean; @@ -100,23 +100,25 @@ export function useSidebarWorkspacesList(options?: { const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS; const workspacePlacements = sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES; - const projectNamesByKey = - sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES; + const projectNamesByViewKey = + sidebarModel.projectNamesByViewKey.size > 0 + ? sidebarModel.projectNamesByViewKey + : EMPTY_PROJECT_NAMES; useEffect(() => { const orderStore = useSidebarOrderStore.getState(); const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder, - getWorkspaceOrder: (projectKey) => - orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER, + getWorkspaceOrder: (projectViewKey) => + orderStore.workspaceOrderByProject[projectViewKey] ?? EMPTY_ORDER, }); if (updates.projectOrder) { orderStore.setProjectOrder(updates.projectOrder); } - for (const { projectKey, order } of updates.workspaceOrders) { - orderStore.setWorkspaceOrder(projectKey, order); + for (const { projectViewKey, order } of updates.workspaceOrders) { + orderStore.setWorkspaceOrder(projectViewKey, order); } }, [persistedProjectOrder, projects]); @@ -144,7 +146,7 @@ export function useSidebarWorkspacesList(options?: { return { workspacePlacements, projects, - projectNamesByKey, + projectNamesByViewKey, ...loadingState, refreshAll, }; diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts index f87c0a19e..c0a2d8c1a 100644 --- a/packages/app/src/projects/host-project-model.ts +++ b/packages/app/src/projects/host-project-model.ts @@ -3,6 +3,7 @@ import type { WorkspaceStructureHostPlacement, WorkspaceStructureProject, } from "@/projects/workspace-structure"; +import { createProjectViewKey } from "@/projects/workspace-structure"; export type HostProjectListItem = WorkspaceStructureProject; @@ -31,7 +32,8 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec return null; } return { - projectKey: projectId, + viewKey: createProjectViewKey({ kind: "placement", serverId: route.serverId, projectId }), + projectKey: null, projectName: trimOptional(route.displayName) || projectId, projectKind: "git", iconWorkingDir, @@ -64,7 +66,12 @@ export function hostProjectFromWorkspace(input: { } const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind); return { - projectKey: projectId, + viewKey: createProjectViewKey({ + kind: "placement", + serverId: input.serverId, + projectId, + }), + projectKey: null, projectName: input.workspace.projectDisplayName || projectId, projectKind: input.workspace.projectKind, 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: { routeProject: HostProjectListItem | null; lastActiveProject: HostProjectListItem | null; @@ -143,20 +211,12 @@ export function resolveInitialWorkspaceProject(input: { if (!candidate) { continue; } - const candidatePlacement = candidate.hosts.find( - (host) => host.serverId === input.serverId && host.projectId, - ); const hydratedProject = - (candidatePlacement - ? input.projects.find((project) => - project.hosts.some( - (host) => - host.serverId === input.serverId && host.projectId === candidatePlacement.projectId, - ), - ) - : undefined) ?? - input.projects.find((project) => project.projectKey === candidate.projectKey) ?? - candidate; + resolveHostProjectCandidate({ + candidate, + projects: input.projects, + serverId: input.serverId, + }) ?? candidate; if ( canCreateWorkspaceForHostProject({ project: hydratedProject, @@ -186,19 +246,19 @@ export function resolveInitialWorktreeProject(input: { } export function resolveSelectedHostProject(input: { - selectedProjectKey: string | null; + selectedViewKey: string | null; projects: readonly HostProjectListItem[]; routeProject: HostProjectListItem | null; lastActiveProject: HostProjectListItem | null; }): HostProjectListItem | null { - const selectedProjectKey = input.selectedProjectKey?.trim() ?? ""; - if (!selectedProjectKey) { + const selectedViewKey = input.selectedViewKey?.trim() ?? ""; + if (!selectedViewKey) { return null; } return ( - input.projects.find((project) => project.projectKey === selectedProjectKey) ?? - (input.routeProject?.projectKey === selectedProjectKey ? input.routeProject : null) ?? - (input.lastActiveProject?.projectKey === selectedProjectKey ? input.lastActiveProject : null) + input.projects.find((project) => project.viewKey === selectedViewKey) ?? + (input.routeProject?.viewKey === selectedViewKey ? input.routeProject : null) ?? + (input.lastActiveProject?.viewKey === selectedViewKey ? input.lastActiveProject : null) ); } diff --git a/packages/app/src/projects/host-projects.test.ts b/packages/app/src/projects/host-projects.test.ts index c16deb34f..6267ea267 100644 --- a/packages/app/src/projects/host-projects.test.ts +++ b/packages/app/src/projects/host-projects.test.ts @@ -9,6 +9,7 @@ import { function project(): HostProjectListItem { return { + viewKey: "view:acme/app", projectKey: "remote:github.com/acme/app", projectName: "acme/app", projectKind: "git", @@ -57,7 +58,7 @@ describe("host project lookups", () => { sourceDirectory: "/repo/a", }), ).toMatchObject({ - projectKey: "prj_a", + projectKey: null, hosts: [{ serverId: "host-a", projectId: "prj_a" }], }); }); diff --git a/packages/app/src/projects/host-projects.ts b/packages/app/src/projects/host-projects.ts index fb12ebe7d..094a459b0 100644 --- a/packages/app/src/projects/host-projects.ts +++ b/packages/app/src/projects/host-projects.ts @@ -9,6 +9,9 @@ export { getHostProjectId, hostProjectFromRoute, hostProjectFromWorkspace, + resolveHostProjectCandidate, + resolveExactHostProjectCandidate, + resolveEquivalentHostProjectCandidate, resolveInitialWorkspaceProject, resolveInitialWorktreeProject, resolveSelectedHostProject, diff --git a/packages/app/src/projects/project-icons.ts b/packages/app/src/projects/project-icons.ts index f0b4e4c02..af5ee498f 100644 --- a/packages/app/src/projects/project-icons.ts +++ b/packages/app/src/projects/project-icons.ts @@ -5,7 +5,7 @@ import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-i export interface ProjectIconRequestTarget { serverId: string; - projectKey: string; + projectViewKey: string; iconWorkingDir: string; } @@ -20,7 +20,7 @@ function useStableProjectIconData( return stableRef.current.data; } -export function useProjectIconDataByProjectKey(input: { +export function useProjectIconDataByProjectViewKey(input: { projects: readonly ProjectIconRequestTarget[]; }): Map { const projectIconRequests = useMemo(() => { @@ -80,11 +80,11 @@ export function useProjectIconDataByProjectKey(input: { for (const project of input.projects) { const cwd = project.iconWorkingDir.trim(); if (!cwd) { - byProject.set(project.projectKey, null); + byProject.set(project.projectViewKey, null); continue; } byProject.set( - project.projectKey, + project.projectViewKey, iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null, ); } diff --git a/packages/app/src/projects/project-remove.test.ts b/packages/app/src/projects/project-remove.test.ts index 3a458a42a..e94efc9a1 100644 --- a/packages/app/src/projects/project-remove.test.ts +++ b/packages/app/src/projects/project-remove.test.ts @@ -6,7 +6,6 @@ import { } from "./project-remove"; const project: ProjectRemoveProject = { - projectKey: "remote:github.com/acme/app", hosts: [ { serverId: "host-a", projectId: "prj_host_a" }, { serverId: "host-b", projectId: "prj_host_b" }, diff --git a/packages/app/src/projects/project-remove.ts b/packages/app/src/projects/project-remove.ts index 330ea0923..f80b068cf 100644 --- a/packages/app/src/projects/project-remove.ts +++ b/packages/app/src/projects/project-remove.ts @@ -8,7 +8,6 @@ interface ProjectRemoveHost { } export interface ProjectRemoveProject { - projectKey: string; hosts: readonly ProjectRemoveHost[]; } diff --git a/packages/app/src/projects/workspace-structure.test.ts b/packages/app/src/projects/workspace-structure.test.ts index 7b621198e..350f8855a 100644 --- a/packages/app/src/projects/workspace-structure.test.ts +++ b/packages/app/src/projects/workspace-structure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; -import { buildWorkspaceStructureProjects } from "./workspace-structure"; +import { buildWorkspaceStructureProjects, createProjectViewKey } from "./workspace-structure"; function project(input: { id: string; @@ -57,6 +57,7 @@ describe("buildWorkspaceStructureProjects", () => { expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ + viewKey: key, projectKey: key, hosts: [ { serverId: "host-a", projectId: "prj_a" }, @@ -85,6 +86,7 @@ describe("buildWorkspaceStructureProjects", () => { }); 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.workspaceKeys[0]).sort()).toEqual([ "host-a:ws-one", @@ -112,7 +114,7 @@ describe("buildWorkspaceStructureProjects", () => { const identitiesByProjectId = (projects: ReturnType) => { const identities: Record = {}; 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; }; @@ -142,6 +144,33 @@ describe("buildWorkspaceStructureProjects", () => { }); 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, + ); }); }); diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 9fc635063..db25238c3 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -9,7 +9,8 @@ export interface WorkspaceStructureHostPlacement { } export interface WorkspaceStructureProject { - projectKey: string; + viewKey: string; + projectKey: string | null; projectName: string; projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; @@ -28,7 +29,8 @@ interface WorkspaceStructureSession { } interface ProjectDraft { - projectKey: string; + viewKey: string; + projectKey: string | null; projectName: string; hasCustomName: boolean; 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) { - const projectKey = addProjectToView({ byProject, keyCountsByServer, serverId, project }); + const viewKey = addProjectToView({ + byProject, + keyCountsByServer, + allocatedViewKeys, + serverId, + project, + }); getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set( project.projectId, - projectKey, + viewKey, ); } for (const session of input.sessions) { for (const workspace of session.workspaces) { - const projectKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); - if (!projectKey) continue; - byProject.get(projectKey)?.workspaces.push({ + const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); + if (!viewKey) continue; + byProject.get(viewKey)?.workspaces.push({ workspaceId: workspace.id, workspaceName: workspace.name, workspaceKey: `${session.serverId}:${workspace.id}`, @@ -79,6 +91,7 @@ export function buildWorkspaceStructureProjects(input: { return Array.from(byProject.values()) .map((draft) => ({ + viewKey: draft.viewKey, projectKey: draft.projectKey, projectName: draft.projectName, projectKind: draft.projectKind, @@ -88,17 +101,48 @@ export function buildWorkspaceStructureProjects(input: { .sort(compareWorkspaceStructureItems) .map((workspace) => workspace.workspaceKey), })) - .sort((left, right) => - left.projectName.localeCompare(right.projectName, undefined, { - numeric: true, - sensitivity: "base", - }), + .sort( + (left, right) => + left.projectName.localeCompare(right.projectName, undefined, { + 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, + 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: { byProject: Map; keyCountsByServer: Map>; + allocatedViewKeys: Set; serverId: string; project: ProjectDescriptor; }): string { @@ -106,17 +150,20 @@ function addProjectToView(input: { const sharedKey = project.projectKey ?? null; const canUseSharedKey = 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 = { serverId, projectId: project.projectId, iconWorkingDir: project.projectRootPath, canCreateWorktree: project.projectKind === "git", }; - const draft = byProject.get(projectKey); + const draft = byProject.get(viewKey); if (!draft) { - byProject.set(projectKey, { - projectKey, + byProject.set(viewKey, { + viewKey, + projectKey: sharedKey, projectName: project.projectCustomName ?? project.projectDisplayName ?? @@ -134,7 +181,7 @@ function addProjectToView(input: { } draft.hosts.set(serverId, placement); } - return projectKey; + return viewKey; } function getOrCreate(map: Map, key: K, create: () => V): V { diff --git a/packages/app/src/schedules/schedule-form-model.test.ts b/packages/app/src/schedules/schedule-form-model.test.ts index d8daeffee..51307fc0c 100644 --- a/packages/app/src/schedules/schedule-form-model.test.ts +++ b/packages/app/src/schedules/schedule-form-model.test.ts @@ -54,7 +54,7 @@ function target(input: { optionId: buildProjectOptionId(input.serverId, input.projectKey), serverId: input.serverId, serverName: input.serverId === "host-a" ? "Host A" : "Host B", - projectKey: input.projectKey, + projectViewKey: input.projectKey, projectName: input.projectName, cwd: input.cwd, isGit: input.isGit ?? true, diff --git a/packages/app/src/schedules/schedule-project-targets.test.ts b/packages/app/src/schedules/schedule-project-targets.test.ts index 3dd3be0b2..2cb6c6525 100644 --- a/packages/app/src/schedules/schedule-project-targets.test.ts +++ b/packages/app/src/schedules/schedule-project-targets.test.ts @@ -8,7 +8,7 @@ import { function makeProject(overrides: Partial): ProjectSummary { return { - projectKey: "proj", + viewKey: "proj", projectName: "Project", hosts: [], totalWorkspaceCount: 0, diff --git a/packages/app/src/schedules/schedule-project-targets.ts b/packages/app/src/schedules/schedule-project-targets.ts index 4ed5e3c5d..a810e3091 100644 --- a/packages/app/src/schedules/schedule-project-targets.ts +++ b/packages/app/src/schedules/schedule-project-targets.ts @@ -7,14 +7,14 @@ export interface ScheduleProjectTarget { optionId: string; serverId: string; serverName: string; - projectKey: string; + projectViewKey: string; projectName: string; cwd: string; isGit: boolean; } -export function buildProjectOptionId(serverId: string, projectKey: string): string { - return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`; +export function buildProjectOptionId(serverId: string, projectViewKey: string): string { + return `${PROJECT_OPTION_PREFIX}${serverId}:${projectViewKey}`; } /** @@ -33,10 +33,10 @@ export function buildScheduleProjectTargets( continue; } targets.push({ - optionId: buildProjectOptionId(host.serverId, project.projectKey), + optionId: buildProjectOptionId(host.serverId, project.viewKey), serverId: host.serverId, serverName: host.serverName, - projectKey: project.projectKey, + projectViewKey: project.viewKey, projectName: host.projectName, cwd, isGit: Boolean(host.gitRuntime), diff --git a/packages/app/src/schedules/use-schedule-form-model.test.tsx b/packages/app/src/schedules/use-schedule-form-model.test.tsx index a9cd3ac4e..50c01645d 100644 --- a/packages/app/src/schedules/use-schedule-form-model.test.tsx +++ b/packages/app/src/schedules/use-schedule-form-model.test.tsx @@ -17,7 +17,7 @@ function projectTarget(input: { optionId: buildProjectOptionId("host-a", input.projectKey), serverId: "host-a", serverName: "Host A", - projectKey: input.projectKey, + projectViewKey: input.projectKey, projectName: input.projectName, cwd: input.cwd, isGit: true, diff --git a/packages/app/src/screens/new-workspace-initial-context.test.ts b/packages/app/src/screens/new-workspace-initial-context.test.ts index c48f35779..969aae6d9 100644 --- a/packages/app/src/screens/new-workspace-initial-context.test.ts +++ b/packages/app/src/screens/new-workspace-initial-context.test.ts @@ -8,6 +8,7 @@ import { function projectFor(serverId: string, key = "project"): HostProjectListItem { return { + viewKey: `view:${serverId}:${key}`, projectKey: key, projectName: key, projectKind: "git", diff --git a/packages/app/src/screens/new-workspace-initial-context.ts b/packages/app/src/screens/new-workspace-initial-context.ts index 4ed87931e..9edb2f2ca 100644 --- a/packages/app/src/screens/new-workspace-initial-context.ts +++ b/packages/app/src/screens/new-workspace-initial-context.ts @@ -1,5 +1,7 @@ import { canCreateWorkspaceForHostProject, + filterWorkspaceProjectsForHost, + resolveHostProjectCandidate, type HostProjectListItem, } from "@/projects/host-projects"; import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; @@ -25,35 +27,31 @@ function supportsAllProjects( 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: { project: HostProjectListItem; projects: readonly HostProjectListItem[]; serverId: string; workspaceMultiplicityByServerId: ReadonlyMap; }) { - const project = getProjectForServer({ - candidate: input.project, + const allowAllProjects = supportsAllProjects( + input.workspaceMultiplicityByServerId, + input.serverId, + ); + const selectableProjects = filterWorkspaceProjectsForHost({ projects: input.projects, serverId: input.serverId, + allowAllProjects, }); + const project = + resolveHostProjectCandidate({ + candidate: input.project, + projects: selectableProjects, + serverId: input.serverId, + }) ?? input.project; return canCreateWorkspaceForHostProject({ project, serverId: input.serverId, - allowAllProjects: supportsAllProjects(input.workspaceMultiplicityByServerId, input.serverId), + allowAllProjects, }); } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 5598633af..6948f8370 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -72,7 +72,7 @@ import { useHostProjects, type HostProjectListItem, } 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 type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; @@ -273,7 +273,7 @@ function ProjectPickerTrigger({ disabled, badgePressableStyle, label, - projectKey, + projectViewKey, iconDataUri, iconColor, iconSize, @@ -283,7 +283,7 @@ function ProjectPickerTrigger({ disabled: boolean; badgePressableStyle: React.ComponentProps["style"]; label: string; - projectKey: string | null; + projectViewKey: string | null; iconDataUri: string | null; iconColor: string; iconSize: number; @@ -303,11 +303,11 @@ function ProjectPickerTrigger({ accessibilityLabel="Workspace project" > - {projectKey ? ( + {projectViewKey ? ( ), - [iconDataUri, placeholderInitial, projectKey], + [iconDataUri, placeholderInitial, projectViewKey], ); return ( @@ -533,7 +533,7 @@ function NewWorkspaceProjectPickerOption({ active, onPress, projectByOptionId, - projectIconDataByProjectKey, + projectIconDataByProjectViewKey, selectedServerId, isPending, supportsWorkspaceMultiplicity, @@ -543,7 +543,7 @@ function NewWorkspaceProjectPickerOption({ active: boolean; onPress: () => void; projectByOptionId: Map; - projectIconDataByProjectKey: Map; + projectIconDataByProjectViewKey: Map; selectedServerId: string; isPending: boolean; supportsWorkspaceMultiplicity: boolean; @@ -555,9 +555,9 @@ function NewWorkspaceProjectPickerOption({ return ( ; + iconDataByProjectViewKey: Map; selectedOptionId: string; onSelect: (id: string) => void; onAddProject: () => void; @@ -1369,10 +1369,10 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme disabled={isPending} badgePressableStyle={badgePressableStyle} label={project.triggerLabel} - projectKey={project.selectedProject?.projectKey ?? null} + projectViewKey={project.selectedProject?.viewKey ?? null} iconDataUri={ project.selectedProject - ? (project.iconDataByProjectKey.get(project.selectedProject.projectKey) ?? null) + ? (project.iconDataByProjectViewKey.get(project.selectedProject.viewKey) ?? null) : null } iconColor={theme.colors.foregroundMuted} @@ -1592,12 +1592,12 @@ export function NewWorkspaceScreen({ if (!iconWorkingDir) { return []; } - return [{ projectKey: project.projectKey, serverId: selectedServerId, iconWorkingDir }]; + return [{ projectViewKey: project.viewKey, serverId: selectedServerId, iconWorkingDir }]; }), [projects, selectedServerId], ); - const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ + const projectIconDataByProjectViewKey = useProjectIconDataByProjectViewKey({ projects: projectIconTargets, }); const draftKey = buildNewWorkspaceDraftKey(draftId); @@ -2039,7 +2039,7 @@ export function NewWorkspaceScreen({ (); const options = projects.map((project) => { - const id = projectOptionId(project.projectKey); + const id = projectOptionId(project.viewKey); projectByOptionId.set(id, project); return { id, label: project.projectName }; }); @@ -104,15 +104,14 @@ export function useNewWorkspaceProjectPicker({ [allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId], ); - const routeProjectKey = routeProject?.projectKey ?? null; + const routeProjectViewKey = routeProject?.viewKey ?? null; const selectionContextKey = createProjectSelectionContextKey({ selectedServerId, - routeProjectKey, + routeProjectViewKey, allowAllProjects, }); const manualSelectionContextKey = createManualProjectSelectionContextKey({ - selectedServerId, - routeProjectKey, + routeProjectViewKey, }); const shouldPreserveMissingProject = useCallback( (project: HostProjectListItem) => @@ -126,6 +125,7 @@ export function useNewWorkspaceProjectPicker({ () => ({ contextKey: selectionContextKey, manualContextKey: manualSelectionContextKey, + selectedServerId, initialProject, initialProjectSource: resolveInitialProjectSelectionSource({ initialProject, @@ -143,6 +143,7 @@ export function useNewWorkspaceProjectPicker({ manualSelectionContextKey, routeProject, selectableProjects, + selectedServerId, selectionContextKey, shouldPreserveMissingProject, ], @@ -168,8 +169,8 @@ export function useNewWorkspaceProjectPicker({ if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return; setProjectSelection({ contextKey: manualSelectionContextKey, - projectKey: project.projectKey, project, + originProject: project, source: "manual", }); }, @@ -183,7 +184,7 @@ export function useNewWorkspaceProjectPicker({ : null, projectPickerOptions, projectByOptionId, - selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "", + selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.viewKey) : "", projectTriggerLabel: selectedProject?.projectName ?? "Choose project", handleSelectProjectOption, }; diff --git a/packages/app/src/screens/new-workspace/project-selection.test.ts b/packages/app/src/screens/new-workspace/project-selection.test.ts index 438741231..0ec8caea8 100644 --- a/packages/app/src/screens/new-workspace/project-selection.test.ts +++ b/packages/app/src/screens/new-workspace/project-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { HostProjectListItem } from "@/projects/host-projects"; +import { filterWorkspaceProjectsForHost, type HostProjectListItem } from "@/projects/host-projects"; import { createManualProjectSelectionContextKey, createProjectSelectionContextKey, @@ -13,6 +13,7 @@ import { function project(projectKey: string, serverId = "host"): HostProjectListItem { return { + viewKey: `view:${serverId}:${projectKey}`, projectKey, projectName: projectKey, projectKind: "git", @@ -40,7 +41,8 @@ function context( const lastActiveProject = input.lastActiveProject ?? null; return { contextKey, - manualContextKey: input.manualContextKey ?? contextKey, + manualContextKey: input.manualContextKey ?? "", + selectedServerId: input.selectedServerId ?? "host", routeProject, lastActiveProject, initialProjectSource: @@ -71,7 +73,6 @@ describe("reconcileProjectSelection", () => { expect(reconciled).toEqual({ contextKey: "host:", - projectKey: remembered.projectKey, project: remembered, source: "initial", }); @@ -80,15 +81,16 @@ describe("reconcileProjectSelection", () => { it("resets stale selection when the route project context changes", () => { const manual = project("manual"); - const routeProject = project("route-project"); + const routeProject = { ...project("route-project"), projectKey: null }; const current: ProjectSelection = { - contextKey: "host:previous-route", - projectKey: manual.projectKey, + contextKey: "previous-route", project: manual, + originProject: manual, source: "manual", }; const nextContext = context({ contextKey: "host:route-project", + manualContextKey: routeProject.viewKey, initialProject: routeProject, projects: [manual, routeProject], routeProject, @@ -96,7 +98,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, nextContext)).toEqual({ contextKey: "host:route-project", - projectKey: routeProject.projectKey, project: routeProject, source: "initial", }); @@ -112,7 +113,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, hydratedContext)).toEqual({ contextKey: "host:", - projectKey: initialProject.projectKey, project: initialProject, source: "initial", }); @@ -121,6 +121,7 @@ describe("reconcileProjectSelection", () => { it("adopts a routed project's hydrated cross-host key", () => { const routedProject = { ...project("local-project-id"), + projectKey: null, hosts: [ { serverId: "host", @@ -152,7 +153,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, hydratedContext)).toEqual({ contextKey: "host:", - projectKey: hydratedProject.projectKey, project: hydratedProject, source: "initial", }); @@ -177,7 +177,6 @@ describe("reconcileProjectSelection", () => { expect(hydratedSelection).toEqual({ contextKey: "host:", - projectKey: hydratedProject.projectKey, project: hydratedProject, source: "initial", }); @@ -207,7 +206,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual({ contextKey: "host:", - projectKey: remembered.projectKey, project: remembered, source: "initial", }); @@ -217,9 +215,9 @@ describe("reconcileProjectSelection", () => { const manual = project("manual"); const remembered = project("remembered"); const current: ProjectSelection = { - contextKey: "host:", - projectKey: manual.projectKey, + contextKey: "", project: manual, + originProject: manual, source: "manual", }; const afterRememberedHydration = context({ @@ -231,6 +229,295 @@ describe("reconcileProjectSelection", () => { 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", () => { const selected = project("host:project "); const current = createProjectSelection( @@ -249,7 +536,7 @@ describe("reconcileProjectSelection", () => { context({ contextKey: createProjectSelectionContextKey({ selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, allowAllProjects: false, }), initialProject: fallback, @@ -259,7 +546,7 @@ describe("reconcileProjectSelection", () => { const afterCapabilityHydration = context({ contextKey: createProjectSelectionContextKey({ selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, allowAllProjects: true, }), initialProject: remembered, @@ -268,7 +555,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({ contextKey: "host:all-projects:", - projectKey: remembered.projectKey, project: remembered, source: "initial", }); @@ -280,22 +566,20 @@ describe("reconcileProjectSelection", () => { const remembered = project("remembered-directory"); const current: ProjectSelection = { contextKey: createManualProjectSelectionContextKey({ - selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, }), - projectKey: manual.projectKey, project: manual, + originProject: manual, source: "manual", }; const afterCapabilityHydration = context({ contextKey: createProjectSelectionContextKey({ selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, allowAllProjects: true, }), manualContextKey: createManualProjectSelectionContextKey({ - selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, }), initialProject: remembered, projects: [fallback, manual, remembered], @@ -325,6 +609,34 @@ describe("reconcileProjectSelection", () => { 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", () => { const remembered = project("remembered"); const fallback = project("fallback"); @@ -338,7 +650,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, withoutRemembered)).toEqual({ contextKey: "host:", - projectKey: fallback.projectKey, project: fallback, source: "initial", }); @@ -349,13 +660,14 @@ describe("reconcileProjectSelection", () => { const routeProject = project("route-project"); const remembered = project("remembered"); const current: ProjectSelection = { - contextKey: "host:route-project", - projectKey: manual.projectKey, + contextKey: routeProject.viewKey, project: manual, + originProject: manual, source: "manual", }; const selectionContext = context({ contextKey: "host:route-project", + manualContextKey: routeProject.viewKey, initialProject: routeProject, projects: [manual], routeProject, diff --git a/packages/app/src/screens/new-workspace/project-selection.ts b/packages/app/src/screens/new-workspace/project-selection.ts index e6eacd6e6..d3af9c970 100644 --- a/packages/app/src/screens/new-workspace/project-selection.ts +++ b/packages/app/src/screens/new-workspace/project-selection.ts @@ -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 InitialProjectSelectionSource = "route" | "lastActive" | "fallback" | null; -export interface ProjectSelection { +interface InitialProjectSelection { contextKey: string; - projectKey: string | 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 { contextKey: string; manualContextKey: string; + selectedServerId: string; initialProject: HostProjectListItem | null; initialProjectSource: InitialProjectSelectionSource; projects: HostProjectListItem[]; @@ -23,18 +37,17 @@ export interface ProjectSelectionContext { export function createProjectSelectionContextKey(input: { selectedServerId: string; - routeProjectKey: string | null; + routeProjectViewKey: string | null; allowAllProjects: boolean; }): string { const projectScope = input.allowAllProjects ? "all-projects" : "worktree-projects"; - return `${input.selectedServerId}:${projectScope}:${input.routeProjectKey ?? ""}`; + return `${input.selectedServerId}:${projectScope}:${input.routeProjectViewKey ?? ""}`; } export function createManualProjectSelectionContextKey(input: { - selectedServerId: string; - routeProjectKey: string | null; + routeProjectViewKey: string | null; }): string { - return `${input.selectedServerId}:${input.routeProjectKey ?? ""}`; + return input.routeProjectViewKey ?? ""; } export function createProjectSelection({ @@ -43,7 +56,6 @@ export function createProjectSelection({ }: ProjectSelectionContext): ProjectSelection { return { contextKey, - projectKey: initialProject?.projectKey ?? null, project: initialProject, source: "initial", }; @@ -58,7 +70,7 @@ export function resolveInitialProjectSelectionSource(input: { return null; } if ( - input.routeProject?.projectKey === input.initialProject.projectKey || + input.routeProject?.viewKey === input.initialProject.viewKey || input.routeProject?.hosts.some((routeHost) => input.initialProject?.hosts.some( (host) => @@ -70,24 +82,24 @@ export function resolveInitialProjectSelectionSource(input: { ) { 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 "fallback"; } -function resolveProjectSelectionKey(selection: ProjectSelection): string | null { - const projectKey = selection.projectKey ?? ""; - return projectKey.trim() ? projectKey : null; -} - function resolveSelectedProjectFromInitialInputs( - projectKey: string, + project: HostProjectListItem, context: ProjectSelectionContext, ): HostProjectListItem | null { return ( - (context.routeProject?.projectKey === projectKey ? context.routeProject : null) ?? - (context.lastActiveProject?.projectKey === projectKey ? context.lastActiveProject : null) + (context.routeProject?.viewKey === project.viewKey ? context.routeProject : null) ?? + (context.lastActiveProject?.viewKey === project.viewKey ? context.lastActiveProject : null) ); } @@ -95,16 +107,40 @@ function refreshSelectionProject( selection: ProjectSelection, project: HostProjectListItem, ): ProjectSelection { - if (selection.projectKey === project.projectKey && selection.project === project) { + if (selection.project === project) { return selection; } return { ...selection, - projectKey: project.projectKey, 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( selection: ProjectSelection, context: ProjectSelectionContext, @@ -117,32 +153,48 @@ function shouldResetHydratedInitialSelection( return false; } - return selection.projectKey !== context.initialProject.projectKey; + return selection.project?.viewKey !== context.initialProject.viewKey; } export function resolveProjectSelection( selection: ProjectSelection, context: ProjectSelectionContext, ): HostProjectListItem | null { - const projectKey = resolveProjectSelectionKey(selection); - if (!projectKey) { + if (!selection.project) { return null; } - const selectableProject = context.projects.find((project) => project.projectKey === projectKey); - if (selectableProject) { - return selectableProject; + if (selection.source === "manual") { + const originProject = resolveManualOriginProject(selection, context); + if (originProject) { + return originProject; + } } - if ( - selection.project?.projectKey === projectKey && - context.shouldPreserveMissingProject(selection.project) - ) { + const exactProject = resolveExactHostProjectCandidate({ + candidate: selection.project, + projects: context.projects, + serverId: context.selectedServerId, + }); + if (exactProject) { + return exactProject; + } + + if (context.shouldPreserveMissingProject(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") { - return resolveSelectedProjectFromInitialInputs(projectKey, context); + return resolveSelectedProjectFromInitialInputs(selection.project, context); } return null; @@ -163,6 +215,34 @@ export function reconcileProjectSelection( 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); if (resolvedProject) { return refreshSelectionProject(current, resolvedProject); diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 0780c1b6a..7eb03bda8 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -31,7 +31,7 @@ import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; 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 { useToast } from "@/contexts/toast-context"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -233,16 +233,16 @@ function ProjectSettingsBody({ () => [ { serverId: selectedHost.serverId, - projectKey: project.projectKey, + projectViewKey: project.viewKey, iconWorkingDir: selectedHost.repoRoot, }, ], - [project.projectKey, selectedHost.repoRoot, selectedHost.serverId], + [project.viewKey, selectedHost.repoRoot, selectedHost.serverId], ); - const projectIconDataByKey = useProjectIconDataByProjectKey({ + const projectIconDataByKey = useProjectIconDataByProjectViewKey({ 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 loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; @@ -262,7 +262,7 @@ function ProjectSettingsBody({ ({ })); vi.mock("@/projects/project-icons", () => ({ - useProjectIconDataByProjectKey: () => new Map(), + useProjectIconDataByProjectViewKey: () => new Map(), })); import ProjectsScreen from "./projects-screen"; @@ -239,7 +239,7 @@ function project(overrides: Partial = {}): ProjectSummary { overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0); const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length; return { - projectKey: "remote:github.com/acme/app", + viewKey: "remote:github.com/acme/app", projectName: "acme/app", hosts, totalWorkspaceCount, @@ -323,7 +323,7 @@ describe("ProjectsScreen", () => { it("navigates to the project detail route when the row is pressed", () => { setProjectsState({ - projects: [project({ projectKey: "remote:github.com/acme/app" })], + projects: [project({ viewKey: "remote:github.com/acme/app" })], }); render({ kind: "projects" }); @@ -339,7 +339,7 @@ describe("ProjectsScreen", () => { it("does not render a kebab menu on the row", () => { setProjectsState({ - projects: [project({ projectKey: "remote:github.com/acme/app" })], + projects: [project({ viewKey: "remote:github.com/acme/app" })], }); render({ kind: "projects" }); @@ -388,9 +388,9 @@ describe("ProjectsScreen", () => { it("highlights the selected row when the active view targets a project", () => { setProjectsState({ projects: [ - project({ projectKey: "remote:github.com/acme/app" }), + project({ viewKey: "remote:github.com/acme/app" }), project({ - projectKey: "remote:github.com/acme/other", + viewKey: "remote:github.com/acme/other", projectName: "acme/other", githubUrl: "https://github.com/acme/other", }), diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx index 7dc76e952..e30d3bc5a 100644 --- a/packages/app/src/screens/projects-screen.tsx +++ b/packages/app/src/screens/projects-screen.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; 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 { buildProjectSettingsRoute } from "@/utils/host-routes"; import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects"; @@ -19,10 +19,9 @@ interface ProjectsScreenProps { export default function ProjectsScreen({ view }: ProjectsScreenProps) { const { t } = useTranslation(); const { projects, hostErrors, isLoading } = useProjects(); - const selectedProjectKey = + const selectedProjectViewKey = view.kind === "project" - ? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ?? - null) + ? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.viewKey ?? null) : null; const iconTargets = useMemo( () => @@ -32,14 +31,14 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) { return [ { serverId: host.serverId, - projectKey: project.projectKey, + projectViewKey: project.viewKey, iconWorkingDir: host.repoRoot, }, ]; }), [projects], ); - const iconDataByProjectKey = useProjectIconDataByProjectKey({ + const iconDataByProjectViewKey = useProjectIconDataByProjectViewKey({ projects: iconTargets, }); @@ -65,11 +64,11 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) { {projects.map((project, index) => ( ))} @@ -103,7 +102,7 @@ interface ProjectRowProps { function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); - const { projectKey, projectName } = project; + const { viewKey, projectName } = project; const target = project.hosts[0]; const handleNavigate = useCallback(() => { if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId)); @@ -127,7 +126,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro onPress={handleNavigate} accessibilityRole="button" accessibilityLabel={t("settings.projectList.editProject", { projectName })} - testID={`project-row-${projectKey}`} + testID={`project-row-${viewKey}`} data-selected={isSelected ? "true" : "false"} > @@ -135,7 +134,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro @@ -150,18 +149,18 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro function ProjectRowIcon({ iconDataUri, projectName, - projectKey, + projectViewKey, }: { iconDataUri: string | null; projectName: string; - projectKey: string; + projectViewKey: string; }) { const initial = projectName.trim().charAt(0).toUpperCase() || "?"; return ( & Pick, ): WorkspaceDescriptor { @@ -114,10 +119,10 @@ function emptySidebarOrder(): SidebarOrderSnapshot { }; } -function selectWorkspaceStructureProjectKeys( +function selectWorkspaceStructureProjectViewKeys( state: Parameters[0], ): string[] { - return selectWorkspaceStructureProjects(state, [SERVER_ID]).map((project) => project.projectKey); + return selectWorkspaceStructureProjects(state, [SERVER_ID]).map((project) => project.viewKey); } afterEach(() => { @@ -348,9 +353,11 @@ describe("workspace structure composition", () => { }; initializeWorkspaces([workspace]); - const emittedProjectKeys = [selectWorkspaceStructureProjectKeys(useSessionStore.getState())]; + const emittedProjectKeys = [ + selectWorkspaceStructureProjectViewKeys(useSessionStore.getState()), + ]; const stop = useSessionStore.subscribe((state) => { - emittedProjectKeys.push(selectWorkspaceStructureProjectKeys(state)); + emittedProjectKeys.push(selectWorkspaceStructureProjectViewKeys(state)); }); try { @@ -360,7 +367,10 @@ describe("workspace structure composition", () => { 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", () => { @@ -451,10 +461,13 @@ describe("workspace structure composition", () => { const before = snapshotStructure(SERVER_ID, emptySidebarOrder()); const after = snapshotStructure(SERVER_ID, { ...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); }); }); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index bbc243423..f911e30d4 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -214,8 +214,7 @@ export function composeWorkspaceStructure(input: { const orderedProjects = applyStoredOrdering({ items: input.projects.map((project) => { - const workspaceOrder = - input.workspaceOrderByScope[project.projectKey] ?? EMPTY_WORKSPACE_KEYS; + const workspaceOrder = input.workspaceOrderByScope[project.viewKey] ?? EMPTY_WORKSPACE_KEYS; return { ...project, workspaceKeys: applyStoredOrdering({ @@ -226,7 +225,7 @@ export function composeWorkspaceStructure(input: { }; }), storedOrder: input.projectOrder, - getKey: (project) => project.projectKey, + getKey: (project) => project.viewKey, }); return { projects: orderedProjects }; diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 8ad128eee..307e432c0 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -7,8 +7,8 @@ interface SidebarOrderStoreState { workspaceOrderByProject: Record; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; - getWorkspaceOrder: (projectKey: string) => string[]; - setWorkspaceOrder: (projectKey: string, keys: string[]) => void; + getWorkspaceOrder: (projectViewKey: string) => string[]; + setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { @@ -20,7 +20,7 @@ interface SidebarOrderPersistedState { interface SidebarWorkspaceOrderScope { serverId: string; - projectKey: string; + projectViewKey: string; } function normalizeKeys(keys: string[]): string[] { @@ -43,8 +43,8 @@ function normalizeWorkspaceOrderByProject( workspaceOrderByProject: Record | undefined, ): Record { const normalized: Record = {}; - for (const [projectKey, order] of Object.entries(workspaceOrderByProject ?? {})) { - const scope = projectKey.trim(); + for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) { + const scope = projectViewKey.trim(); if (!scope) continue; normalized[scope] = normalizeKeys(order); } @@ -55,9 +55,9 @@ function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScop const separatorIndex = scopeKey.indexOf("::"); if (separatorIndex < 0) return null; const serverId = scopeKey.slice(0, separatorIndex).trim(); - const projectKey = scopeKey.slice(separatorIndex + 2).trim(); - if (!serverId || !projectKey) return null; - return { serverId, projectKey }; + const projectViewKey = scopeKey.slice(separatorIndex + 2).trim(); + if (!serverId || !projectViewKey) return null; + return { serverId, projectViewKey }; } 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 ?? {})) { const scope = extractWorkspaceOrderScope(scopeKey); if (!scope) continue; - const existing = workspaceOrderByProject[scope.projectKey] ?? []; + const existing = workspaceOrderByProject[scope.projectViewKey] ?? []; const merged = [...existing]; const seen = new Set(merged); for (const key of order) { @@ -100,7 +100,7 @@ export function migrateSidebarOrderState(persistedState: unknown): { seen.add(workspaceKey); merged.push(workspaceKey); } - workspaceOrderByProject[scope.projectKey] = merged; + workspaceOrderByProject[scope.projectViewKey] = merged; } return { projectOrder, workspaceOrderByProject }; @@ -116,13 +116,13 @@ export const useSidebarOrderStore = create()( const normalized = normalizeKeys(keys); set({ projectOrder: normalized }); }, - getWorkspaceOrder: (projectKey) => { - const scope = projectKey.trim(); + getWorkspaceOrder: (projectViewKey) => { + const scope = projectViewKey.trim(); if (!scope) return []; return get().workspaceOrderByProject[scope] ?? []; }, - setWorkspaceOrder: (projectKey, keys) => { - const scope = projectKey.trim(); + setWorkspaceOrder: (projectViewKey, keys) => { + const scope = projectViewKey.trim(); if (!scope) return; const normalized = normalizeKeys(keys); set((state) => ({ diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index c8a41b297..926b59c30 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import { createProjectViewKey } from "@/projects/workspace-structure"; import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; import { buildProjects, getProjectSummaryForHostProject } from "./projects"; @@ -56,7 +57,7 @@ describe("buildProjects", () => { expect(result.projects).toEqual([ expect.objectContaining({ - projectKey: key, + viewKey: createProjectViewKey({ kind: "equivalence", projectKey: key }), hostCount: 2, onlineHostCount: 1, totalWorkspaceCount: 2, diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index 1e1f48a8b..7ced12425 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -27,7 +27,7 @@ export interface ProjectHostEntry { } export interface ProjectSummary { - projectKey: string; + viewKey: string; projectName: string; projectCustomName?: string | null; hosts: ProjectHostEntry[]; @@ -91,7 +91,7 @@ interface HostGroup { } interface ProjectGroup { - projectKey: string; + viewKey: string; projectName: string; projectCustomName: string | null; hostsByServerId: Map; @@ -99,10 +99,10 @@ interface ProjectGroup { function findProjectMetadata( host: ProjectHost, - projectKey: string, + projectId: string, ): { customName: string | null; displayName: string } | null { for (const project of host.projects) { - if (project.projectId === projectKey) { + if (project.projectId === projectId) { return { customName: project.projectCustomName ?? null, displayName: project.projectDisplayName, @@ -172,7 +172,7 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary { const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0); const onlineHostCount = hosts.filter((host) => host.isOnline).length; return { - projectKey: draft.projectKey, + viewKey: draft.viewKey, projectName: draft.projectName, projectCustomName: draft.projectCustomName, hosts, @@ -196,15 +196,15 @@ function addHostProjects( if (!placement) continue; const projectId = placement.projectId; const customName = findProjectMetadata(host, projectId); - let group = groups.get(hostProject.projectKey); + let group = groups.get(hostProject.viewKey); if (!group) { group = { - projectKey: hostProject.projectKey, + viewKey: hostProject.viewKey, projectName: customName?.displayName ?? hostProject.projectName, projectCustomName: customName?.customName ?? null, hostsByServerId: new Map(), }; - groups.set(hostProject.projectKey, group); + groups.set(hostProject.viewKey, group); } else if (customName?.customName && !group.projectCustomName) { group.projectCustomName = customName.customName; group.projectName = customName.displayName; @@ -230,16 +230,16 @@ function attachHostWorkspaces( host: ProjectHost, hostProjects: HostProjectListItem[], ): void { - const projectKeyByProjectId = new Map( + const projectViewKeyByProjectId = new Map( hostProjects.flatMap((project) => project.hosts .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) { - const key = projectKeyByProjectId.get(workspace.projectId); + const key = projectViewKeyByProjectId.get(workspace.projectId); 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) { return name; } - return left.projectKey.localeCompare(right.projectKey); + return left.viewKey.localeCompare(right.viewKey); }); return { projects }; diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index a4054fa8c..429502014 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -6,6 +6,7 @@ import type { import { buildSidebarProjectRowModel, resolveSidebarProjectIconTarget, + resolveSidebarProjectIconTargets, resolveSidebarProjectLocalPath, } from "./sidebar-project-row-model"; @@ -14,7 +15,7 @@ function workspace(overrides: Partial = {}): SidebarWorks workspaceKey: "srv:ws-root", serverId: "srv", workspaceId: "ws-root", - projectKey: "project-1", + projectViewKey: "project-1", projectName: "paseo", workspaceDirectory: "/repo", projectKind: "git", @@ -48,7 +49,7 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry { (host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }), ); return { - projectKey: "project-1", + viewKey: "project-1", projectName: "paseo", projectKind, 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", () => { const groupedProject = project({ iconWorkingDir: "/remote/repo", diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index fac2384e1..0333ca3d1 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -44,7 +44,7 @@ export function resolveSidebarProjectIconTarget( } export interface SidebarProjectIconTarget extends SidebarProjectHostTarget { - projectKey: string; + projectViewKey: string; } export function resolveSidebarProjectIconTargets( @@ -52,7 +52,7 @@ export function resolveSidebarProjectIconTargets( ): SidebarProjectIconTarget[] { return projects.flatMap((project) => { const target = resolveSidebarProjectIconTarget(project); - return target ? [{ projectKey: project.projectKey, ...target }] : []; + return target ? [{ projectViewKey: project.viewKey, ...target }] : []; }); } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index d5f11d779..7e91d5243 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -24,7 +24,7 @@ function workspace(input: { workspaceKey: `${input.serverId}:${input.workspaceId}`, serverId: input.serverId, workspaceId: input.workspaceId, - projectKey: input.projectKey ?? "project-default", + projectViewKey: input.projectKey ?? "project-default", projectName: input.projectKey ?? "Project", workspaceDirectory: input.workspaceDirectory, projectKind: "git", @@ -46,7 +46,7 @@ function workspace(input: { function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): SidebarProjectEntry { return { - projectKey, + viewKey: projectKey, projectName: projectKey, projectKind: "git", iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index fd2720076..86540357e 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -36,7 +36,7 @@ export function buildSidebarShortcutModel(input: { return buildSidebarShortcutSections({ sections: input.projects.map((project) => ({ workspaces: project.workspaces, - collapsed: input.collapsedProjectKeys.has(project.projectKey), + collapsed: input.collapsedProjectKeys.has(project.viewKey), })), shortcutLimit: input.shortcutLimit, });