Add a global New workspace picker (#1324)
* Add global New workspace project picker * Clean up New workspace picker state
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||
|
||||
export interface ProjectHostError {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProjectsRuntimeSnapshot {
|
||||
connectionStatus: string;
|
||||
}
|
||||
|
||||
export interface ProjectsRuntime {
|
||||
getClient(serverId: string): Pick<DaemonClient, "fetchWorkspaces"> | null;
|
||||
getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined;
|
||||
}
|
||||
|
||||
export interface ProjectsHostInput {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedProjectsInput {
|
||||
hosts: ProjectsHostInput[];
|
||||
runtime: ProjectsRuntime;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hostErrors: ProjectHostError[];
|
||||
}
|
||||
|
||||
interface HostWorkspacesResult {
|
||||
host: ProjectHost;
|
||||
error: ProjectHostError | null;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function fetchAggregatedProjects(
|
||||
input: FetchAggregatedProjectsInput,
|
||||
): Promise<FetchAggregatedProjectsResult> {
|
||||
const results = await Promise.all(
|
||||
input.hosts.map(async (host): Promise<HostWorkspacesResult> => {
|
||||
const snapshot = input.runtime.getSnapshot(host.serverId);
|
||||
const isOnline = snapshot?.connectionStatus === "online";
|
||||
const client = input.runtime.getClient(host.serverId);
|
||||
|
||||
if (!client || !isOnline) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const workspaces: WorkspaceDescriptor[] = await fetchAllWorkspaceDescriptors({
|
||||
client,
|
||||
sort: [{ key: "name", direction: "asc" }],
|
||||
});
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
message: toErrorMessage(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
|
||||
return {
|
||||
...buildProjects({ hosts: results.map((result) => result.host) }),
|
||||
hostErrors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
|
||||
export interface HostProjectListItem {
|
||||
serverId: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
workspaceKeys: string[];
|
||||
canCreateWorktree: boolean;
|
||||
}
|
||||
|
||||
export interface HostProjectRouteContext {
|
||||
serverId: string;
|
||||
projectId?: string;
|
||||
displayName?: string;
|
||||
sourceDirectory?: string;
|
||||
}
|
||||
|
||||
function trimOptional(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function canCreateWorktreeForProjectKind(
|
||||
projectKind: WorkspaceDescriptor["projectKind"],
|
||||
): boolean {
|
||||
return projectKind === "git";
|
||||
}
|
||||
|
||||
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,
|
||||
workspaceKeys: project.workspaceKeys,
|
||||
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
|
||||
}));
|
||||
}
|
||||
|
||||
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
|
||||
const projectKey = trimOptional(route.projectId);
|
||||
const iconWorkingDir = trimOptional(route.sourceDirectory);
|
||||
if (!projectKey || !iconWorkingDir) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
serverId: route.serverId,
|
||||
projectKey,
|
||||
projectName: trimOptional(route.displayName) ?? projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir,
|
||||
workspaceKeys: [],
|
||||
canCreateWorktree: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function hostProjectFromWorkspace(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
}): HostProjectListItem | null {
|
||||
if (!input.workspace) {
|
||||
return null;
|
||||
}
|
||||
const projectKey = input.workspace.projectId.trim();
|
||||
const iconWorkingDir = input.workspace.projectRootPath.trim();
|
||||
if (!projectKey || !iconWorkingDir) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
projectKey,
|
||||
projectName: input.workspace.projectDisplayName || projectKey,
|
||||
projectKind: input.workspace.projectKind,
|
||||
iconWorkingDir,
|
||||
workspaceKeys: [input.workspace.id],
|
||||
canCreateWorktree: canCreateWorktreeForProjectKind(input.workspace.projectKind),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInitialWorktreeProject(input: {
|
||||
routeProject: HostProjectListItem | null;
|
||||
lastActiveProject: HostProjectListItem | null;
|
||||
projects: readonly HostProjectListItem[];
|
||||
}): HostProjectListItem | null {
|
||||
if (input.routeProject?.canCreateWorktree) {
|
||||
return input.routeProject;
|
||||
}
|
||||
if (input.lastActiveProject?.canCreateWorktree) {
|
||||
return input.lastActiveProject;
|
||||
}
|
||||
return input.projects.find((project) => project.canCreateWorktree) ?? null;
|
||||
}
|
||||
|
||||
export function resolveSelectedHostProject(input: {
|
||||
selectedProjectKey: string | null;
|
||||
projects: readonly HostProjectListItem[];
|
||||
routeProject: HostProjectListItem | null;
|
||||
lastActiveProject: HostProjectListItem | null;
|
||||
}): HostProjectListItem | null {
|
||||
const selectedProjectKey = input.selectedProjectKey?.trim() ?? "";
|
||||
if (!selectedProjectKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
input.projects.find((project) => project.projectKey === selectedProjectKey) ??
|
||||
(input.routeProject?.projectKey === selectedProjectKey ? input.routeProject : null) ??
|
||||
(input.lastActiveProject?.projectKey === selectedProjectKey ? input.lastActiveProject : null)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
import {
|
||||
buildHostProjectList,
|
||||
canCreateWorktreeForProjectKind,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
resolveInitialWorktreeProject,
|
||||
resolveSelectedHostProject,
|
||||
type HostProjectListItem,
|
||||
} from "./host-project-model";
|
||||
|
||||
function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceStructureProject {
|
||||
return {
|
||||
projectKey: input.projectKey ?? "project-a",
|
||||
projectName: input.projectName ?? "Project A",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
|
||||
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",
|
||||
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
|
||||
canCreateWorktree: input.canCreateWorktree ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id ?? "workspace-a",
|
||||
projectId: input.projectId ?? "project-a",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project A",
|
||||
projectRootPath: input.projectRootPath ?? "/repo/a",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo/a",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const routeProject = hostProject({
|
||||
projectKey: "route-project",
|
||||
projectName: "Route Project",
|
||||
iconWorkingDir: "/repo/route",
|
||||
});
|
||||
const lastActiveProject = hostProject({
|
||||
projectKey: "last-project",
|
||||
projectName: "Last Project",
|
||||
iconWorkingDir: "/repo/last",
|
||||
});
|
||||
const firstProject = hostProject({
|
||||
projectKey: "first-project",
|
||||
projectName: "First Project",
|
||||
iconWorkingDir: "/repo/first",
|
||||
});
|
||||
|
||||
describe("host project list", () => {
|
||||
it("preserves workspace-structure order and project metadata", () => {
|
||||
expect(
|
||||
buildHostProjectList({
|
||||
serverId: "host-a",
|
||||
projects: [
|
||||
structureProject({
|
||||
projectKey: "project-b",
|
||||
projectName: "Project B",
|
||||
projectKind: "directory",
|
||||
iconWorkingDir: "/repo/b",
|
||||
workspaceKeys: ["workspace-b"],
|
||||
}),
|
||||
structureProject({
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
workspaceKeys: ["workspace-a"],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
serverId: "host-a",
|
||||
projectKey: "project-b",
|
||||
projectName: "Project B",
|
||||
projectKind: "directory",
|
||||
iconWorkingDir: "/repo/b",
|
||||
workspaceKeys: ["workspace-b"],
|
||||
canCreateWorktree: false,
|
||||
},
|
||||
{
|
||||
serverId: "host-a",
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
workspaceKeys: ["workspace-a"],
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps worktree capability separate from project listability", () => {
|
||||
expect(canCreateWorktreeForProjectKind("git")).toBe(true);
|
||||
expect(canCreateWorktreeForProjectKind("directory")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses route project before last active project when it can create worktrees", () => {
|
||||
expect(
|
||||
resolveInitialWorktreeProject({
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
projects: [firstProject],
|
||||
}),
|
||||
).toEqual(routeProject);
|
||||
});
|
||||
|
||||
it("skips non-worktree route and last-active projects", () => {
|
||||
expect(
|
||||
resolveInitialWorktreeProject({
|
||||
routeProject: { ...routeProject, projectKind: "directory", canCreateWorktree: false },
|
||||
lastActiveProject: {
|
||||
...lastActiveProject,
|
||||
projectKind: "directory",
|
||||
canCreateWorktree: false,
|
||||
},
|
||||
projects: [
|
||||
{ ...firstProject, projectKind: "directory", canCreateWorktree: false },
|
||||
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
|
||||
],
|
||||
}),
|
||||
).toMatchObject({ projectKey: "git-project" });
|
||||
});
|
||||
|
||||
it("leaves the project empty when no worktree-capable project is available", () => {
|
||||
expect(
|
||||
resolveInitialWorktreeProject({
|
||||
routeProject: null,
|
||||
lastActiveProject: null,
|
||||
projects: [{ ...firstProject, projectKind: "directory", canCreateWorktree: false }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a selected route project available before project hydration", () => {
|
||||
expect(
|
||||
resolveSelectedHostProject({
|
||||
selectedProjectKey: routeProject.projectKey,
|
||||
projects: [],
|
||||
routeProject,
|
||||
lastActiveProject: null,
|
||||
}),
|
||||
).toEqual(routeProject);
|
||||
});
|
||||
|
||||
it("converts route project only when it has a key and source directory", () => {
|
||||
expect(
|
||||
hostProjectFromRoute({
|
||||
serverId: "host-a",
|
||||
projectId: "project-a",
|
||||
displayName: "Project A",
|
||||
sourceDirectory: "/repo/a",
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "host-a",
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
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,
|
||||
});
|
||||
|
||||
expect(
|
||||
hostProjectFromWorkspace({
|
||||
serverId: "host-a",
|
||||
workspace: workspace({ projectKind: "directory" }),
|
||||
}),
|
||||
).toMatchObject({ projectKind: "directory", canCreateWorktree: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
|
||||
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
|
||||
|
||||
export {
|
||||
buildHostProjectList,
|
||||
canCreateWorktreeForProjectKind,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
resolveInitialWorktreeProject,
|
||||
resolveSelectedHostProject,
|
||||
type HostProjectListItem,
|
||||
type HostProjectRouteContext,
|
||||
} from "@/projects/host-project-model";
|
||||
|
||||
export function useHostProjects(serverId: string | null): HostProjectListItem[] {
|
||||
const workspaceStructure = useWorkspaceStructure(serverId);
|
||||
return useMemo(() => {
|
||||
if (!serverId || workspaceStructure.projects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return buildHostProjectList({ serverId, projects: workspaceStructure.projects });
|
||||
}, [serverId, workspaceStructure.projects]);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
|
||||
|
||||
export interface ProjectIconRequestTarget {
|
||||
serverId?: string | null;
|
||||
projectKey: string;
|
||||
iconWorkingDir: string;
|
||||
}
|
||||
|
||||
function useStableProjectIconData(
|
||||
data: (string | null)[],
|
||||
signature: string,
|
||||
): readonly (string | null)[] {
|
||||
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
|
||||
if (stableRef.current?.signature !== signature) {
|
||||
stableRef.current = { signature, data };
|
||||
}
|
||||
return stableRef.current.data;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}, [input.projects, input.serverId]);
|
||||
|
||||
const projectIconQueries = useQueries({
|
||||
queries: projectIconRequests.map((request) => ({
|
||||
queryKey: projectIconQueryKey(request.serverId, request.cwd),
|
||||
queryFn: async () => {
|
||||
const client = getHostRuntimeStore().getClient(request.serverId);
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const result = await client.requestProjectIcon(request.cwd);
|
||||
return result.icon;
|
||||
},
|
||||
select: projectIconToDataUri,
|
||||
enabled: Boolean(
|
||||
getHostRuntimeStore().getClient(request.serverId) &&
|
||||
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
|
||||
request.cwd,
|
||||
),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 60,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})),
|
||||
});
|
||||
|
||||
const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000");
|
||||
const projectIconData = useStableProjectIconData(
|
||||
projectIconQueries.map((query) => query.data ?? null),
|
||||
projectIconSignature,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const iconByServerAndCwd = new Map<string, string | null>();
|
||||
for (let index = 0; index < projectIconRequests.length; index += 1) {
|
||||
const request = projectIconRequests[index];
|
||||
if (!request) {
|
||||
continue;
|
||||
}
|
||||
iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null);
|
||||
}
|
||||
|
||||
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) {
|
||||
byProject.set(project.projectKey, null);
|
||||
continue;
|
||||
}
|
||||
byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null);
|
||||
}
|
||||
|
||||
return byProject;
|
||||
}, [input.projects, input.serverId, projectIconData, projectIconRequests]);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">;
|
||||
export type FetchWorkspacesSort = NonNullable<
|
||||
Parameters<DaemonClient["fetchWorkspaces"]>[0]
|
||||
>["sort"];
|
||||
|
||||
export async function fetchAllWorkspaceDescriptors(input: {
|
||||
client: FetchWorkspacesClient;
|
||||
sort: FetchWorkspacesSort;
|
||||
}): Promise<WorkspaceDescriptor[]> {
|
||||
const entries: WorkspaceDescriptor[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
while (true) {
|
||||
const payload = await input.client.fetchWorkspaces({
|
||||
sort: input.sort,
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = payload.pageInfo.nextCursor;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
|
||||
export interface WorkspaceStructureProject {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
workspaceKeys: string[];
|
||||
}
|
||||
|
||||
export interface WorkspaceStructure {
|
||||
projects: WorkspaceStructureProject[];
|
||||
}
|
||||
|
||||
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
|
||||
|
||||
function compareWorkspaceStructureItems(
|
||||
left: { workspaceId: string; workspaceName: string },
|
||||
right: { workspaceId: string; workspaceName: string },
|
||||
): number {
|
||||
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (nameDelta !== 0) {
|
||||
return nameDelta;
|
||||
}
|
||||
|
||||
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
function compareWorkspaceStructureProjects(
|
||||
left: WorkspaceStructureProject,
|
||||
right: WorkspaceStructureProject,
|
||||
): number {
|
||||
return left.projectName.localeCompare(right.projectName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
export function buildWorkspaceStructureProjects(input: {
|
||||
serverId: string;
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
}): WorkspaceStructureProject[] {
|
||||
const workspaceList = Array.from(input.workspaces);
|
||||
if (workspaceList.length === 0) {
|
||||
return EMPTY_WORKSPACE_STRUCTURE.projects;
|
||||
}
|
||||
|
||||
const byProject = new Map<
|
||||
string,
|
||||
WorkspaceStructureProject & {
|
||||
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const workspace of workspaceList) {
|
||||
const project =
|
||||
byProject.get(workspace.projectId) ??
|
||||
({
|
||||
projectKey: workspace.projectId,
|
||||
projectName:
|
||||
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
||||
projectKind: workspace.projectKind,
|
||||
iconWorkingDir: workspace.projectRootPath,
|
||||
workspaceKeys: [],
|
||||
workspaces: [],
|
||||
} satisfies WorkspaceStructureProject & {
|
||||
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
||||
});
|
||||
|
||||
project.workspaces.push({
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||
});
|
||||
byProject.set(workspace.projectId, project);
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
projects.sort(compareWorkspaceStructureProjects);
|
||||
return projects;
|
||||
}
|
||||
Reference in New Issue
Block a user