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:
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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."),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user