Keep selected projects when switching hosts (#2700)

* fix(app): preserve project selection across hosts

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

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

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

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

* fix(app): preserve origin during archive gaps
This commit is contained in:
Mohamed Boudra
2026-07-31 15:42:08 +02:00
committed by GitHub
parent 87ef631ac4
commit 99eca19a76
56 changed files with 1234 additions and 428 deletions
@@ -10,6 +10,7 @@ import {
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { 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<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`);
async function removeProjectFromSidebar(page: Page, projectViewKey: string): Promise<void> {
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) {
+11 -6
View File
@@ -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<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first();
const projectViewKey = projectEquivalenceViewKey(input.projectKey);
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`).first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await 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<void> {
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<void> {
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();
@@ -0,0 +1,7 @@
export function projectEquivalenceViewKey(projectKey: string): string {
return projectKey;
}
export function projectPlacementViewKey(serverId: string, projectId: string): string {
return JSON.stringify([serverId, projectId]);
}
+5 -4
View File
@@ -11,6 +11,7 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { 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();
@@ -0,0 +1,196 @@
import { expect, test as base } from "./fixtures";
import { openProjectDirectoryWithHosts } from "./helpers/project-grouping";
import { projectPlacementViewKey } from "./helpers/project-view-key";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceHost,
selectNewWorkspaceProject,
} from "./helpers/new-workspace";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { createTempGitRepo } from "./helpers/workspace";
const PRIMARY_HOST_LABEL = "Primary host";
const SECONDARY_HOST_LABEL = "Secondary host";
const SHARED_PROJECT_NAME = "Paseo";
const SHARED_REMOTE_URL = "https://github.com/getpaseo/paseo.git";
interface CreatedProject {
projectId: string;
projectKey: string;
workspaceId: string;
}
interface CrossHostProjectScenario {
contextWorkspaceId: string;
selectedProjectViewKey: string;
sharedProjectKey: string;
secondaryHost: {
serverId: string;
label: string;
port: number;
};
}
async function createProject(
client: SeedDaemonClient,
input: { projectPath: string; projectName: string; workspaceName: string },
): Promise<CreatedProject> {
const created = await client.createWorkspace({
source: { kind: "directory", path: input.projectPath },
title: input.workspaceName,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project ${input.projectName}`);
}
await client.renameProject(created.workspace.projectId, input.projectName);
const listed = await client.listProjects();
const project = listed.projects.find(
(candidate) => candidate.projectId === created.workspace?.projectId,
);
if (!project?.projectKey) {
throw new Error(`Created project ${created.workspace.projectId} has no project key`);
}
return {
projectId: created.workspace.projectId,
projectKey: project.projectKey,
workspaceId: created.workspace.id,
};
}
async function openNewWorkspaceFromContextProject(
page: Parameters<typeof openProjectDirectoryWithHosts>[0],
scenario: CrossHostProjectScenario,
): Promise<void> {
await openProjectDirectoryWithHosts(page, {
hosts: [scenario.secondaryHost],
primaryLabel: PRIMARY_HOST_LABEL,
});
await expect(
page.getByTestId(`sidebar-project-row-${scenario.selectedProjectViewKey}`),
).toBeVisible();
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: scenario.contextWorkspaceId,
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, "Context project");
}
const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon(
"new-workspace-project-preservation-secondary",
);
const contextRepo = await createTempGitRepo("new-workspace-context-");
const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryDuplicateRepo = await createTempGitRepo("new-workspace-paseo-duplicate-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryFallbackRepo = await createTempGitRepo("new-workspace-fallback-");
const secondarySharedRepo = await createTempGitRepo("new-workspace-paseo-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryDaemon.port });
const primaryProjects: CreatedProject[] = [];
const secondaryProjects: CreatedProject[] = [];
try {
const contextProject = await createProject(primaryClient, {
projectPath: contextRepo.path,
projectName: "Context project",
workspaceName: "Context workspace",
});
primaryProjects.push(contextProject);
const primarySharedProject = await createProject(primaryClient, {
projectPath: primarySharedRepo.path,
projectName: SHARED_PROJECT_NAME,
workspaceName: "Primary Paseo workspace",
});
primaryProjects.push(primarySharedProject);
const primaryDuplicateProject = await createProject(primaryClient, {
projectPath: primaryDuplicateRepo.path,
projectName: "Paseo duplicate",
workspaceName: "Duplicate Paseo workspace",
});
primaryProjects.push(primaryDuplicateProject);
secondaryProjects.push(
await createProject(secondaryClient, {
projectPath: secondaryFallbackRepo.path,
projectName: "Aardvark project",
workspaceName: "Secondary fallback workspace",
}),
);
const secondarySharedProject = await createProject(secondaryClient, {
projectPath: secondarySharedRepo.path,
projectName: SHARED_PROJECT_NAME,
workspaceName: "Secondary Paseo workspace",
});
secondaryProjects.push(secondarySharedProject);
if (
primarySharedProject.projectKey !== primaryDuplicateProject.projectKey ||
primarySharedProject.projectKey !== secondarySharedProject.projectKey
) {
throw new Error("Expected the Paseo clones to have the same equivalence key");
}
await provide({
contextWorkspaceId: contextProject.workspaceId,
selectedProjectViewKey: projectPlacementViewKey(
getServerId(),
primarySharedProject.projectId,
),
sharedProjectKey: primarySharedProject.projectKey,
secondaryHost: {
serverId: secondaryDaemon.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryDaemon.port,
},
});
} finally {
for (const project of primaryProjects) {
await primaryClient.removeProject(project.projectId).catch(() => undefined);
}
for (const project of secondaryProjects) {
await secondaryClient.removeProject(project.projectId).catch(() => undefined);
}
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await contextRepo.cleanup().catch(() => undefined);
await primarySharedRepo.cleanup().catch(() => undefined);
await primaryDuplicateRepo.cleanup().catch(() => undefined);
await secondaryFallbackRepo.cleanup().catch(() => undefined);
await secondarySharedRepo.cleanup().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
}
},
});
test.describe("New workspace host project preservation", () => {
test.describe.configure({ timeout: 180_000 });
test("keeps the equivalent project selected when one host has another clone", async ({
page,
crossHostProject,
}) => {
await openNewWorkspaceFromContextProject(page, crossHostProject);
await selectNewWorkspaceProject(page, {
projectKey: crossHostProject.sharedProjectKey,
projectViewKey: crossHostProject.selectedProjectViewKey,
projectDisplayName: SHARED_PROJECT_NAME,
});
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
});
});
@@ -7,6 +7,7 @@ import {
} from "./helpers/schedule-fake-host";
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."),
+3 -2
View File
@@ -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<string> {
+4 -1
View File
@@ -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");
@@ -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,
});
+8 -8
View File
@@ -77,14 +77,14 @@ interface SidebarSharedProps {
pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
projectNamesByViewKey: Map<string, string>;
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
groupMode: SidebarGroupMode;
collapsedProjectKeys: ReadonlySet<string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
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}
@@ -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<ImageStyle>;
fallbackStyle: StyleProp<ViewStyle>;
textStyle: StyleProp<TextStyle>;
}) {
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]);
@@ -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) => (
<div key={project.projectKey}>
<div key={project.viewKey}>
<ProjectHeaderProbe project={project} counts={counts} />
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
{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,
@@ -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<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
projectNamesByViewKey: Map<string, string>;
collapsedProjectKeys: ReadonlySet<string>;
onToggleProjectCollapsed: (projectKey: string) => void;
onToggleProjectCollapsed: (projectViewKey: string) => void;
shortcutIndexByWorkspaceKey: Map<string, number>;
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({
<ProjectIcon
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectKey={projectKey}
projectViewKey={projectViewKey}
/>
</View>
);
@@ -477,7 +477,7 @@ function ProjectLeadingVisual({
<ProjectLeadingVisualStatus
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectKey={projectKey}
projectViewKey={projectViewKey}
isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace}
@@ -518,7 +518,7 @@ function ProjectRowTrailingActions({
onPress={onBeginWorkspaceSetup}
visible={actionsVisible}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
testID={`sidebar-project-new-worktree-${project.viewKey}`}
/>
) : null}
{onRemoveProject ? (
@@ -527,7 +527,7 @@ function ProjectRowTrailingActions({
pointerEvents={actionsVisible ? "auto" : "none"}
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectViewKey={project.viewKey}
settingsTarget={settingsTarget}
projectPath={localProjectPath}
onRemoveProject={onRemoveProject}
@@ -555,13 +555,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
}
function ProjectKebabMenu({
projectKey,
projectViewKey,
settingsTarget,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
projectViewKey: string;
settingsTarget: { serverId: string; projectId: string } | null;
projectPath: string;
onRemoveProject: () => 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}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{canOpenProjectSettings ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-settings-${projectKey}`}
testID={`sidebar-project-menu-open-settings-${projectViewKey}`}
leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings}
>
@@ -611,7 +611,7 @@ function ProjectKebabMenu({
) : null}
{canOpenInNewWindow ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-new-window-${projectKey}`}
testID={`sidebar-project-menu-open-new-window-${projectViewKey}`}
leading={openInNewWindowLeadingIcon}
onSelect={handleOpenInNewWindow}
>
@@ -620,10 +620,10 @@ function ProjectKebabMenu({
) : null}
<OpenInFileManagerMenuItem
path={projectPath}
testID={`sidebar-project-menu-open-folder-${projectKey}`}
testID={`sidebar-project-menu-open-folder-${projectViewKey}`}
/>
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
testID={`sidebar-project-menu-remove-${projectViewKey}`}
leading={trash2LeadingIcon}
status={removeProjectStatus}
pendingLabel={t("sidebar.project.actions.removing")}
@@ -725,17 +725,17 @@ function WorkspaceRowRightGroup({
function ProjectIcon({
iconDataUri,
placeholderInitial,
projectKey,
projectViewKey,
}: {
iconDataUri: string | null;
placeholderInitial: string;
projectKey: string;
projectViewKey: string;
}) {
return (
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText}
@@ -746,14 +746,14 @@ function ProjectIcon({
function ProjectLeadingVisualStatus({
iconDataUri,
placeholderInitial,
projectKey,
projectViewKey,
isArchiving,
shouldShowSyncedLoader,
activeWorkspace,
}: {
iconDataUri: string | null;
placeholderInitial: string;
projectKey: string;
projectViewKey: string;
isArchiving: boolean;
shouldShowSyncedLoader: boolean;
activeWorkspace: SidebarWorkspaceEntry;
@@ -796,7 +796,7 @@ function ProjectLeadingVisualStatus({
<ProjectIcon
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectKey={projectKey}
projectViewKey={projectViewKey}
/>
{dotColorStyle ? (
<StatusDotOverlay
@@ -936,7 +936,7 @@ function NewWorkspaceGhostRow({
})}
onPress={handlePress}
style={rowStyle}
testID={`sidebar-project-new-workspace-row-${project.projectKey}`}
testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
>
{({ 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}
</ContextMenuTrigger>
@@ -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}
</Pressable>
@@ -1622,9 +1622,9 @@ function ProjectBlock({
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
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 = (
<>
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
@@ -1813,7 +1812,7 @@ function ProjectBlock({
<SidebarGroupToggleRow
expanded={workspacesExpanded}
onPress={toggleWorkspacesExpanded}
testID={`sidebar-project-show-more-${project.projectKey}`}
testID={`sidebar-project-show-more-${project.viewKey}`}
/>
) : 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<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
@@ -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<string, string>;
@@ -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 (
<MemoProjectBlock
key={item.projectKey}
key={item.viewKey}
project={item}
workspaceEntriesByKey={workspaceEntriesByKey}
collapsed={collapsedProjectKeys.has(item.projectKey)}
collapsed={collapsedProjectKeys.has(item.viewKey)}
displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
iconDataUri={projectIconByProjectViewKey.get(item.viewKey) ?? null}
selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
@@ -2303,7 +2302,7 @@ function ProjectModeList({
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
projectIconByProjectKey,
projectIconByProjectViewKey,
selectionEnabled,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
@@ -2392,7 +2391,7 @@ function ProjectModeList({
<DraggableList
testID="sidebar-project-list"
data={unpinnedProjects}
keyExtractor={projectKeyExtractor}
keyExtractor={projectViewKeyExtractor}
renderItem={renderProject}
onDragEnd={handleProjectDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
@@ -18,7 +18,7 @@ interface SidebarModel extends SidebarWorkspacesListResult {
statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups;
collapsedProjectKeys: ReadonlySet<string>;
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,
@@ -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<string>(),
@@ -25,7 +25,7 @@ export function buildSidebarProjection(input: {
projects: SidebarProjectEntry[];
pinnedKeys: PinnedSidebarKeys;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
projectNamesByViewKey: Map<string, string>;
groupMode: SidebarGroupMode;
pinnedCollapsed: boolean;
collapsedProjectKeys: ReadonlySet<string>;
@@ -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),
})),
);
}
@@ -64,8 +64,8 @@ const ThemedCircleX = withUnistyles(CircleX);
interface StatusWorkspaceListProps {
groups: StatusGroup[];
pinnedWorkspaces: SidebarWorkspaceEntry[];
projectNamesByKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndexByWorkspaceKey: Map<string, number>;
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({
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
projectIconByProjectKey={projectIconByProjectKey}
projectNamesByViewKey={projectNamesByViewKey}
projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -186,8 +186,8 @@ export function SidebarStatusWorkspaceList({
function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByKey,
projectIconByProjectKey,
projectNamesByViewKey,
projectIconByProjectViewKey,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -198,8 +198,8 @@ function StatusGroupList({
}: {
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndex: Map<string, number>;
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<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
shortcutIndex: Map<string, number>;
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<string, string>;
projectIconByProjectKey: ReadonlyMap<string, string | null>;
projectNamesByViewKey: Map<string, string>;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
hostLabelByServerId: ReadonlyMap<string, string>;
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,
};
}
@@ -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({
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.icon}
fallbackStyle={styles.fallback}
textStyle={styles.fallbackText}
@@ -157,7 +157,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<View style={styles.workspaceSubtitleRow}>
{subtitleProjectName ? (
<SidebarSubtitleProjectIcon
projectKey={workspace.projectKey}
projectViewKey={workspace.projectViewKey}
projectName={subtitleProjectName}
iconDataUri={subtitleProjectIconDataUri}
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
@@ -14,7 +14,7 @@ function ws(
return {
serverId: input.serverId ?? "srv",
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
projectKey: input.projectKey ?? "proj",
projectViewKey: input.projectViewKey ?? "proj",
projectName: input.projectName ?? "Project",
projectRootPath: input.projectRootPath,
workspaceDirectory: input.workspaceDirectory,
@@ -124,9 +124,24 @@ describe("buildStatusGroups", () => {
]);
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);
@@ -26,7 +26,7 @@ export interface StatusGroup {
export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[],
projectNamesByKey: Map<string, string>,
projectNamesByViewKey: Map<string, string>,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
@@ -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<string, string>,
projectNamesByViewKey: Map<string, string>,
): 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;
@@ -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"],
},
]);
@@ -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<string, string>;
projectNamesByViewKey: Map<string, string>;
}
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<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): 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 });
}
}
@@ -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: "",
@@ -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}`,
@@ -41,7 +41,7 @@ const EMPTY_PROJECT_NAMES = new Map<string, string>();
export interface SidebarWorkspacesListResult {
workspacePlacements: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
projectNamesByViewKey: Map<string, string>;
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,
};
+81 -21
View File
@@ -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)
);
}
@@ -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" }],
});
});
@@ -9,6 +9,9 @@ export {
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveHostProjectCandidate,
resolveExactHostProjectCandidate,
resolveEquivalentHostProjectCandidate,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
+4 -4
View File
@@ -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<string, string | null> {
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,
);
}
@@ -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" },
@@ -8,7 +8,6 @@ interface ProjectRemoveHost {
}
export interface ProjectRemoveProject {
projectKey: string;
hosts: readonly ProjectRemoveHost[];
}
@@ -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<typeof build>) => {
const identities: Record<string, string> = {};
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,
);
});
});
@@ -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<string>,
serverId: string,
projectId: string,
): string {
const legacyKey = createProjectViewKey({ kind: "placement", serverId, projectId });
if (!allocatedViewKeys.has(legacyKey)) {
allocatedViewKeys.add(legacyKey);
return legacyKey;
}
for (let suffix = 0; ; suffix += 1) {
const collisionKey = JSON.stringify(["placement", serverId, projectId, suffix]);
if (allocatedViewKeys.has(collisionKey)) continue;
allocatedViewKeys.add(collisionKey);
return collisionKey;
}
}
function addProjectToView(input: {
byProject: Map<string, ProjectDraft>;
keyCountsByServer: Map<string, Map<string, number>>;
allocatedViewKeys: Set<string>;
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<K, V>(map: Map<K, V>, key: K, create: () => V): V {
@@ -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,
@@ -8,7 +8,7 @@ import {
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
return {
projectKey: "proj",
viewKey: "proj",
projectName: "Project",
hosts: [],
totalWorkspaceCount: 0,
@@ -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),
@@ -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,
@@ -8,6 +8,7 @@ import {
function projectFor(serverId: string, key = "project"): HostProjectListItem {
return {
viewKey: `view:${serverId}:${key}`,
projectKey: key,
projectName: key,
projectKind: "git",
@@ -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<string, boolean>;
}) {
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,
});
}
@@ -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<typeof Pressable>["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"
>
<View style={styles.badgeIconBox}>
{projectKey ? (
{projectViewKey ? (
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText}
@@ -423,7 +423,7 @@ function IsolationOptionItem({
function ProjectOptionItem({
testID,
projectKey,
projectViewKey,
iconDataUri,
label,
description,
@@ -433,7 +433,7 @@ function ProjectOptionItem({
onPress,
}: {
testID: string;
projectKey: string;
projectViewKey: string;
iconDataUri: string | null;
label: string;
description: string | undefined;
@@ -450,14 +450,14 @@ function ProjectOptionItem({
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText}
/>
</View>
),
[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<string, HostProjectListItem>;
projectIconDataByProjectKey: Map<string, string | null>;
projectIconDataByProjectViewKey: Map<string, string | null>;
selectedServerId: string;
isPending: boolean;
supportsWorkspaceMultiplicity: boolean;
@@ -555,9 +555,9 @@ function NewWorkspaceProjectPickerOption({
return (
<ProjectOptionItem
testID={`new-workspace-project-picker-option-${project.projectKey}`}
projectKey={project.projectKey}
iconDataUri={projectIconDataByProjectKey.get(project.projectKey) ?? null}
testID={`new-workspace-project-picker-option-${project.viewKey}`}
projectViewKey={project.viewKey}
iconDataUri={projectIconDataByProjectViewKey.get(project.viewKey) ?? null}
label={project.projectName}
description={sourceDirectory}
selected={selected}
@@ -1305,7 +1305,7 @@ interface NewWorkspaceFormStackInput {
options: ComboboxOptionType[];
triggerLabel: string;
selectedProject: HostProjectListItem | null;
iconDataByProjectKey: Map<string, string | null>;
iconDataByProjectViewKey: Map<string, string | null>;
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({
<NewWorkspaceProjectPickerOption
{...props}
projectByOptionId={projectByOptionId}
projectIconDataByProjectKey={projectIconDataByProjectKey}
projectIconDataByProjectViewKey={projectIconDataByProjectViewKey}
selectedServerId={selectedServerId}
isPending={isPending}
supportsWorkspaceMultiplicity={supportsWorkspaceMultiplicity}
@@ -2048,7 +2048,7 @@ export function NewWorkspaceScreen({
[
isPending,
projectByOptionId,
projectIconDataByProjectKey,
projectIconDataByProjectViewKey,
selectedServerId,
supportsWorkspaceMultiplicity,
],
@@ -2093,7 +2093,7 @@ export function NewWorkspaceScreen({
options: projectPickerOptions,
triggerLabel: projectTriggerLabel,
selectedProject,
iconDataByProjectKey: projectIconDataByProjectKey,
iconDataByProjectViewKey: projectIconDataByProjectViewKey,
selectedOptionId: selectedProjectOptionId,
onSelect: handleSelectProjectOption,
onAddProject: handleAddProject,
@@ -45,7 +45,7 @@ function projectOptionId(projectId: string): string {
function computeProjectOptionData(projects: readonly HostProjectListItem[]) {
const projectByOptionId = new Map<string, HostProjectListItem>();
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,
};
@@ -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,
@@ -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);
@@ -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({
<ProjectTitleIcon
iconDataUri={projectIconDataUri}
projectName={project.projectName}
projectKey={project.projectKey}
projectViewKey={project.viewKey}
/>
<ProjectNameEditor
key={`${selectedHost.serverId}:${selectedHost.projectId}`}
@@ -959,18 +959,18 @@ function ProjectNameEditor({
function ProjectTitleIcon({
iconDataUri,
projectName,
projectKey,
projectViewKey,
}: {
iconDataUri: string | null;
projectName: string;
projectKey: string;
projectViewKey: string;
}) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
return (
<ProjectIconView
iconDataUri={iconDataUri}
initial={initial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.titleIcon}
fallbackStyle={styles.titleIconFallback}
textStyle={styles.titleIconFallbackText}
@@ -202,7 +202,7 @@ vi.mock("@/hooks/use-projects", () => ({
}));
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> = {}): 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",
}),
+14 -15
View File
@@ -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) {
<View style={settingsStyles.card}>
{projects.map((project, index) => (
<ProjectRow
key={project.projectKey}
key={project.viewKey}
project={project}
isFirst={index === 0}
isSelected={selectedProjectKey === project.projectKey}
iconDataUri={iconDataByProjectKey.get(project.projectKey) ?? null}
isSelected={selectedProjectViewKey === project.viewKey}
iconDataUri={iconDataByProjectViewKey.get(project.viewKey) ?? null}
/>
))}
</View>
@@ -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"}
>
<View style={styles.rowMain}>
@@ -135,7 +134,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
<ProjectRowIcon
iconDataUri={iconDataUri}
projectName={projectName}
projectKey={projectKey}
projectViewKey={viewKey}
/>
</View>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
@@ -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 (
<ProjectIconView
iconDataUri={iconDataUri}
initial={initial}
projectKey={projectKey}
projectViewKey={projectViewKey}
imageStyle={styles.iconImage}
fallbackStyle={styles.iconFallback}
textStyle={styles.iconFallbackText}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { createProjectViewKey } from "@/projects/workspace-structure";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
@@ -25,6 +26,10 @@ import {
const SERVER_ID = "test-server";
function equivalenceViewKey(projectKey: string): string {
return createProjectViewKey({ kind: "equivalence", projectKey });
}
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
@@ -114,10 +119,10 @@ function emptySidebarOrder(): SidebarOrderSnapshot {
};
}
function selectWorkspaceStructureProjectKeys(
function selectWorkspaceStructureProjectViewKeys(
state: Parameters<typeof selectWorkspaceStructureProjects>[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);
});
});
@@ -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 };
+14 -14
View File
@@ -7,8 +7,8 @@ interface SidebarOrderStoreState {
workspaceOrderByProject: Record<string, string[]>;
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<string, string[]> | undefined,
): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
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<SidebarOrderStoreState>()(
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) => ({
+2 -1
View File
@@ -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,
+12 -12
View File
@@ -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<string, HostGroup>;
@@ -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 };
@@ -6,6 +6,7 @@ import type {
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectIconTargets,
resolveSidebarProjectLocalPath,
} from "./sidebar-project-row-model";
@@ -14,7 +15,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): 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",
@@ -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 }] : [];
});
}
@@ -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 ?? "",
+1 -1
View File
@@ -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,
});