Merge sidebar workspaces across all connected hosts (#1538)

* Merge workspaces across all hosts in the sidebar

Replace per-host sidebar sections with a single merged project list.
Projects that exist on multiple hosts (matched by projectKey) are
collapsed into one row. Host identity is surfaced via:

- A status-only footer pill showing online/offline host counts
- Hover cards with a mandatory host row on every workspace entry
- Subtitles showing the host name when a project spans >1 host
- A host filter in the grouping dropdown (not a sidebar switcher)

Order and group-mode stores migrated from per-server to global keys
with zustand persist migrations. New-workspace screen now shows a
merged project list with a host selector combobox.

Key files:
- workspace-structure.ts: merge algorithm by projectKey
- sidebar-view-store.ts / sidebar-order-store.ts: global state
- sidebar-workspace-list.tsx: context-based subtitle injection
- new-workspace-screen.tsx: host selector in footer row

* Add host filtering to the merged sidebar

The sidebar now merges workspaces from every connected host, so a single
global "active host" no longer fits. Host selection becomes explicit per
action via a host chooser, and agent history pages across all hosts at
once. Removes the active-host / active-server-id model and the
all-agents-list hook that fed it.

* Fix rebase fallout: translate archive host error, update stale workspace-key test

archiveWorkspacesOptimistically used a raw "Host is not connected" string
that the i18n completeness test forbids; route it through i18n.t with the
existing hostDisconnected key. The selectors memoization test asserted
un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed.

* Fix sidebar review fallout

* Address sidebar review followups

* Fix sidebar workspace identity migration

* Address sidebar review followups

* Handle partial host history failures

* Fix merged sidebar project removal and E2E drift

* Restore history routes and project removal shape

* Remove sidebar host context plumbing

* Tighten sidebar status and host filter state

* Keep new workspace project selection host-compatible

* Migrate sidebar view storage

* Streamline host selection flows

* Update all-host history on archive

* Fix empty project sidebar action

Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model.

* Fix Japanese shortcut translation key

Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test.

* Fix Playwright speech teardown race
This commit is contained in:
Mohamed Boudra
2026-06-26 12:16:49 +08:00
committed by GitHub
parent 1109e453bc
commit a49c658d1f
103 changed files with 6856 additions and 2857 deletions
+90 -14
View File
@@ -1,14 +1,16 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
export interface HostProjectListItem {
serverId: string;
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
canCreateWorktree: boolean;
}
export interface HostProjectRouteContext {
@@ -30,17 +32,15 @@ export function canCreateWorktreeForProjectKind(
}
export function buildHostProjectList(input: {
serverId: string;
projects: readonly WorkspaceStructureProject[];
}): HostProjectListItem[] {
return input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
}));
}
@@ -51,13 +51,18 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
return null;
}
return {
serverId: route.serverId,
projectKey,
projectName: trimOptional(route.displayName) ?? projectKey,
projectKind: "git",
iconWorkingDir,
hosts: [
{
serverId: route.serverId,
iconWorkingDir,
canCreateWorktree: true,
},
],
workspaceKeys: [],
canCreateWorktree: true,
};
}
@@ -73,29 +78,100 @@ export function hostProjectFromWorkspace(input: {
if (!projectKey || !iconWorkingDir) {
return null;
}
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
return {
serverId: input.serverId,
projectKey,
projectName: input.workspace.projectDisplayName || projectKey,
projectKind: input.workspace.projectKind,
iconWorkingDir,
workspaceKeys: [input.workspace.id],
canCreateWorktree: canCreateWorktreeForProjectKind(input.workspace.projectKind),
hosts: [
{
serverId: input.serverId,
iconWorkingDir,
canCreateWorktree: canCreate,
},
],
workspaceKeys: [`${input.serverId}:${input.workspace.id}`],
};
}
function projectCanCreateWorktree(project: HostProjectListItem): boolean {
return project.hosts.some((h) => h.canCreateWorktree);
}
export function getHostProjectSourceDirectory(
project: HostProjectListItem,
serverId: string,
): string | null {
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
}
export function canCreateWorkspaceForHostProject(input: {
project: HostProjectListItem;
serverId: string;
allowAllProjects: boolean;
}): boolean {
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
if (!host) {
return false;
}
return input.allowAllProjects || host.canCreateWorktree;
}
export function filterWorkspaceProjectsForHost(input: {
projects: readonly HostProjectListItem[];
serverId: string;
allowAllProjects: boolean;
}): HostProjectListItem[] {
return input.projects.filter((project) =>
canCreateWorkspaceForHostProject({
project,
serverId: input.serverId,
allowAllProjects: input.allowAllProjects,
}),
);
}
export function resolveInitialWorkspaceProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
serverId: string;
allowAllProjects: boolean;
}): HostProjectListItem | null {
const candidates = [input.routeProject, input.lastActiveProject];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const hydratedProject =
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
if (
canCreateWorkspaceForHostProject({
project: hydratedProject,
serverId: input.serverId,
allowAllProjects: input.allowAllProjects,
})
) {
return hydratedProject;
}
}
return input.projects[0] ?? null;
}
export function resolveInitialWorktreeProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
}): HostProjectListItem | null {
if (input.routeProject?.canCreateWorktree) {
if (input.routeProject && projectCanCreateWorktree(input.routeProject)) {
return input.routeProject;
}
if (input.lastActiveProject?.canCreateWorktree) {
if (input.lastActiveProject && projectCanCreateWorktree(input.lastActiveProject)) {
return input.lastActiveProject;
}
return input.projects.find((project) => project.canCreateWorktree) ?? null;
return input.projects.find((project) => projectCanCreateWorktree(project)) ?? null;
}
export function resolveSelectedHostProject(input: {
+124 -17
View File
@@ -3,9 +3,13 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
@@ -17,19 +21,31 @@ function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceS
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
canCreateWorktree: input.projectKind !== "directory",
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
};
}
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
return {
serverId: input.serverId ?? "host-a",
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
canCreateWorktree: input.canCreateWorktree ?? true,
};
}
@@ -71,7 +87,6 @@ describe("host project list", () => {
it("preserves workspace-structure order and project metadata", () => {
expect(
buildHostProjectList({
serverId: "host-a",
projects: [
structureProject({
projectKey: "project-b",
@@ -79,6 +94,7 @@ describe("host project list", () => {
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
}),
structureProject({
projectKey: "project-a",
@@ -86,27 +102,26 @@ describe("host project list", () => {
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
}),
],
}),
).toEqual([
{
serverId: "host-a",
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
workspaceKeys: ["workspace-b"],
canCreateWorktree: false,
},
{
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
},
]);
});
@@ -129,14 +144,24 @@ describe("host project list", () => {
it("skips non-worktree route and last-active projects", () => {
expect(
resolveInitialWorktreeProject({
routeProject: { ...routeProject, projectKind: "directory", canCreateWorktree: false },
routeProject: {
...routeProject,
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/route", canCreateWorktree: false }],
},
lastActiveProject: {
...lastActiveProject,
projectKind: "directory",
canCreateWorktree: false,
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/last", canCreateWorktree: false }],
},
projects: [
{ ...firstProject, projectKind: "directory", canCreateWorktree: false },
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
],
}),
@@ -148,11 +173,92 @@ describe("host project list", () => {
resolveInitialWorktreeProject({
routeProject: null,
lastActiveProject: null,
projects: [{ ...firstProject, projectKind: "directory", canCreateWorktree: false }],
projects: [
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
],
}),
).toBeNull();
});
it("filters new-workspace projects to the selected host", () => {
const hostAOnly = hostProject({
projectKey: "host-a-project",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
});
const hostBOnly = hostProject({
projectKey: "host-b-project",
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
});
expect(
filterWorkspaceProjectsForHost({
projects: [hostAOnly, hostBOnly],
serverId: "host-b",
allowAllProjects: false,
}).map((project) => project.projectKey),
).toEqual(["host-b-project"]);
});
it("allows directory projects only when workspace multiplicity is supported", () => {
const directoryProject = hostProject({
projectKey: "directory-project",
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/directory", canCreateWorktree: false }],
});
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: false,
}),
).toBe(false);
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: true,
}),
).toBe(true);
});
it("falls back when the route project is not available on the selected host", () => {
const selectedHostProject = hostProject({
projectKey: "selected-host-project",
hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/selected-host", canCreateWorktree: true },
],
});
expect(
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject: null,
projects: [selectedHostProject],
serverId: "host-b",
allowAllProjects: false,
}),
).toEqual(selectedHostProject);
});
it("resolves the selected host project source directory", () => {
const project = hostProject({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
});
expect(getHostProjectSourceDirectory(project, "host-b")).toBe("/repo/b");
expect(getHostProjectSourceDirectory(project, "host-c")).toBeNull();
});
it("keeps a selected route project available before project hydration", () => {
expect(
resolveSelectedHostProject({
@@ -173,26 +279,24 @@ describe("host project list", () => {
sourceDirectory: "/repo/a",
}),
).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: [],
canCreateWorktree: true,
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
});
it("converts last active workspaces with matching worktree capability", () => {
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["host-a:workspace-a"],
});
expect(
@@ -200,6 +304,9 @@ describe("host project list", () => {
serverId: "host-a",
workspace: workspace({ projectKind: "directory" }),
}),
).toMatchObject({ projectKind: "directory", canCreateWorktree: false });
).toMatchObject({
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }],
});
});
});
+9 -5
View File
@@ -4,21 +4,25 @@ import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-
export {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
type HostProjectRouteContext,
} from "@/projects/host-project-model";
export function useHostProjects(serverId: string | null): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverId);
export function useHostProjects(serverIds: string[]): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverIds);
return useMemo(() => {
if (!serverId || workspaceStructure.projects.length === 0) {
if (workspaceStructure.projects.length === 0) {
return [];
}
return buildHostProjectList({ serverId, projects: workspaceStructure.projects });
}, [serverId, workspaceStructure.projects]);
return buildHostProjectList({ projects: workspaceStructure.projects });
}, [workspaceStructure.projects]);
}
+9 -12
View File
@@ -4,7 +4,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
export interface ProjectIconRequestTarget {
serverId?: string | null;
serverId: string;
projectKey: string;
iconWorkingDir: string;
}
@@ -21,24 +21,19 @@ function useStableProjectIconData(
}
export function useProjectIconDataByProjectKey(input: {
serverId: string | null;
projects: readonly ProjectIconRequestTarget[];
}): Map<string, string | null> {
const projectIconRequests = useMemo(() => {
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const project of input.projects) {
const serverId = project.serverId || input.serverId;
if (!serverId) {
continue;
}
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
continue;
}
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
unique.set(`${project.serverId}:${cwd}`, { serverId: project.serverId, cwd });
}
return Array.from(unique.values());
}, [input.projects, input.serverId]);
}, [input.projects]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
@@ -83,15 +78,17 @@ export function useProjectIconDataByProjectKey(input: {
const byProject = new Map<string, string | null>();
for (const project of input.projects) {
const serverId = project.serverId || input.serverId;
const cwd = project.iconWorkingDir.trim();
if (!cwd || !serverId) {
if (!cwd) {
byProject.set(project.projectKey, null);
continue;
}
byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null);
byProject.set(
project.projectKey,
iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null,
);
}
return byProject;
}, [input.projects, input.serverId, projectIconData, projectIconRequests]);
}, [input.projects, projectIconData, projectIconRequests]);
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
getProjectRemoveReadiness,
removeProjectFromHosts,
type ProjectRemoveProject,
} from "./project-remove";
const project: ProjectRemoveProject = {
projectKey: "remote:github.com/acme/app",
hosts: [{ serverId: "host-a" }, { serverId: "host-b" }],
};
function createProjectRemoveClient() {
const removedProjectKeys: string[] = [];
return {
removedProjectKeys,
client: {
async removeProject(projectKey: string): Promise<{ removedWorkspaceIds: string[] }> {
removedProjectKeys.push(projectKey);
return { removedWorkspaceIds: [] };
},
},
};
}
describe("project remove policy", () => {
it("requires every host to support project removal", () => {
const readiness = getProjectRemoveReadiness({
project,
supportsProjectRemove: (serverId) => serverId === "host-a",
});
expect(readiness).toEqual({
kind: "needs_host_update",
serverIds: ["host-b"],
});
});
it("removes the project from every participating host", async () => {
const hostA = createProjectRemoveClient();
const hostB = createProjectRemoveClient();
const readiness = getProjectRemoveReadiness({
project,
supportsProjectRemove: () => true,
});
expect(readiness).toEqual({
kind: "ready",
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
});
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
getClient: (serverId) => {
if (serverId === "host-a") return hostA.client;
if (serverId === "host-b") return hostB.client;
return null;
},
});
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
expect(hostA.removedProjectKeys).toEqual([project.projectKey]);
expect(hostB.removedProjectKeys).toEqual([project.projectKey]);
});
it("reports disconnected hosts before sending any remove request", async () => {
const hostA = createProjectRemoveClient();
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
});
expect(outcome).toEqual({ kind: "host_disconnected", serverIds: ["host-b"] });
expect(hostA.removedProjectKeys).toEqual([]);
});
});
+102
View File
@@ -0,0 +1,102 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { selectHostFeature } from "@/runtime/host-features";
interface ProjectRemoveHost {
serverId: string;
}
export interface ProjectRemoveProject {
projectKey: string;
hosts: readonly ProjectRemoveHost[];
}
export interface ProjectRemoveTarget {
serverId: string;
}
export type ProjectRemoveReadiness =
| { kind: "ready"; targets: ProjectRemoveTarget[] }
| { kind: "needs_host_update"; serverIds: string[] };
export type ProjectRemoveOutcome =
| { kind: "removed"; serverIds: string[] }
| { kind: "host_disconnected"; serverIds: string[] }
| { kind: "failed"; serverIds: string[] };
type ProjectRemoveClient = Pick<DaemonClient, "removeProject">;
export function getProjectRemoveReadiness(input: {
project: ProjectRemoveProject;
supportsProjectRemove: (serverId: string) => boolean;
}): ProjectRemoveReadiness {
const unsupportedServerIds: string[] = [];
const targets: ProjectRemoveTarget[] = [];
for (const host of input.project.hosts) {
if (!input.supportsProjectRemove(host.serverId)) {
unsupportedServerIds.push(host.serverId);
continue;
}
targets.push({ serverId: host.serverId });
}
if (unsupportedServerIds.length > 0) {
return { kind: "needs_host_update", serverIds: unsupportedServerIds };
}
return { kind: "ready", targets };
}
export function getCurrentProjectRemoveReadiness(
project: ProjectRemoveProject,
): ProjectRemoveReadiness {
const sessionState = useSessionStore.getState();
return getProjectRemoveReadiness({
project,
supportsProjectRemove: (serverId) => selectHostFeature(sessionState, serverId, "projectRemove"),
});
}
export async function removeProjectFromHosts(input: {
projectKey: string;
targets: readonly ProjectRemoveTarget[];
getClient: (serverId: string) => ProjectRemoveClient | null;
}): Promise<ProjectRemoveOutcome> {
const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = [];
const disconnectedServerIds: string[] = [];
for (const target of input.targets) {
const client = input.getClient(target.serverId);
if (!client) {
disconnectedServerIds.push(target.serverId);
continue;
}
clients.push({ serverId: target.serverId, client });
}
if (disconnectedServerIds.length > 0) {
return { kind: "host_disconnected", serverIds: disconnectedServerIds };
}
const results = await Promise.allSettled(
clients.map(async ({ client }) => {
await client.removeProject(input.projectKey);
}),
);
const failedServerIds: string[] = [];
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
const failed = clients[index];
if (failed) {
failedServerIds.push(failed.serverId);
}
}
}
if (failedServerIds.length > 0) {
return { kind: "failed", serverIds: failedServerIds };
}
return { kind: "removed", serverIds: clients.map((entry) => entry.serverId) };
}
+104 -49
View File
@@ -1,11 +1,18 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
export interface WorkspaceStructureHostPlacement {
serverId: string;
iconWorkingDir: string;
canCreateWorktree: boolean;
}
export interface WorkspaceStructureProject {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
}
@@ -13,8 +20,6 @@ export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
@@ -42,68 +47,118 @@ function compareWorkspaceStructureProjects(
});
}
export function buildWorkspaceStructureProjects(input: {
function canCreateWorktreeForProjectKind(projectKind: WorkspaceDescriptor["projectKind"]): boolean {
return projectKind === "git";
}
interface WorkspaceStructureSession {
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
emptyProjects?: Iterable<EmptyProjectDescriptor>;
}): WorkspaceStructureProject[] {
const workspaceList = Array.from(input.workspaces);
const emptyProjectList = Array.from(input.emptyProjects ?? []);
if (workspaceList.length === 0 && emptyProjectList.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
}
export function buildWorkspaceStructureProjects(input: {
sessions: WorkspaceStructureSession[];
}): WorkspaceStructureProject[] {
const byProject = new Map<
string,
WorkspaceStructureProject & {
{
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: Map<string, WorkspaceStructureHostPlacement>;
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
for (const emptyProject of emptyProjectList) {
byProject.set(emptyProject.projectId, {
projectKey: emptyProject.projectId,
projectName:
emptyProject.projectDisplayName || projectDisplayNameFromProjectId(emptyProject.projectId),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
workspaceKeys: [],
workspaces: [],
});
}
for (const session of input.sessions) {
for (const emptyProject of session.emptyProjects ?? []) {
const projectKey = emptyProject.projectId;
const placement = {
serverId: session.serverId,
iconWorkingDir: emptyProject.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
};
const existing = byProject.get(projectKey);
for (const workspace of workspaceList) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
emptyProject.projectCustomName ??
emptyProject.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
hosts: new Map([[session.serverId, placement]]),
workspaces: [],
});
continue;
}
existing.hosts.set(session.serverId, placement);
}
for (const workspace of session.workspaces) {
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
const existing = byProject.get(projectKey);
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
hosts: new Map([
[
session.serverId,
{
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
},
],
]),
workspaces: [
{
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
},
],
});
continue;
}
existing.hosts.set(session.serverId, {
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
workspaceKeys: [],
workspaces: [],
} satisfies WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
});
project.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${input.serverId}:${workspace.id}`,
});
byProject.set(workspace.projectId, project);
existing.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
});
}
}
const projects = Array.from(byProject.values()).map(
({ workspaces: projectWorkspaces, ...project }) => {
const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems);
return Object.assign({}, project, {
workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId),
});
},
);
const projects: WorkspaceStructureProject[] = [];
for (const raw of byProject.values()) {
const sortedWorkspaces = [...raw.workspaces].sort(compareWorkspaceStructureItems);
projects.push({
projectKey: raw.projectKey,
projectName: raw.projectName,
projectKind: raw.projectKind,
iconWorkingDir: raw.iconWorkingDir,
hosts: Array.from(raw.hosts.values()),
workspaceKeys: sortedWorkspaces.map((w) => w.workspaceKey),
});
}
projects.sort(compareWorkspaceStructureProjects);
return projects;