feat: project settings page with paseo.json editor

Adds a Projects settings flow for inspecting and editing per-project
paseo.json (worktree setup/teardown, scripts, terminals) directly from the
app. Reads/writes go through new daemon RPCs with revision-based optimistic
concurrency, and the screen always keeps its header and host picker visible
so a hung host doesn't trap the user.
This commit is contained in:
Mohamed Boudra
2026-04-26 11:37:47 +07:00
parent 18903bcecb
commit 219050a7ab
36 changed files with 6447 additions and 81 deletions
@@ -4,6 +4,8 @@ import {
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
buildProjectSettingsRoute,
buildProjectsSettingsRoute,
decodeFilePathFromPathSegment,
decodeWorkspaceIdFromPathSegment,
encodeFilePathForPathSegment,
@@ -133,3 +135,28 @@ describe("workspace route parsing", () => {
expect(decodeWorkspaceIdFromPathSegment(encoded)).toBe("team/setup:id#1");
});
});
describe("projects settings routes", () => {
it("buildProjectsSettingsRoute returns /settings/projects", () => {
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
});
it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => {
expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe(
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
);
});
it("buildProjectSettingsRoute encodes a local repo-root key", () => {
expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe(
"/settings/projects/%2FUsers%2Fme%2Fdev%2Fpaseo",
);
});
it("project keys round-trip through decodeURIComponent", () => {
const projectKey = "remote:github.com/acme/app";
const route = buildProjectSettingsRoute(projectKey);
const segment = route.slice("/settings/projects/".length);
expect(decodeURIComponent(segment)).toBe(projectKey);
});
});
+12
View File
@@ -403,6 +403,18 @@ export function buildSettingsHostRoute(serverId: string) {
return `/settings/hosts/${encodeSegment(normalized)}` as const;
}
export function buildProjectsSettingsRoute() {
return "/settings/projects" as const;
}
export function buildProjectSettingsRoute(projectKey: string) {
const normalized = trimNonEmpty(projectKey);
if (!normalized) {
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
}
return `/settings/projects/${encodeSegment(normalized)}` as const;
}
export function mapPathnameToServer(pathname: string, nextServerId: string) {
const normalized = trimNonEmpty(nextServerId);
if (!normalized) {
@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form";
function emptyDraft(): ProjectConfigDraft {
return {
setupText: "",
setupOriginalKind: "missing",
teardownText: "",
teardownOriginalKind: "missing",
scripts: [],
};
}
describe("configToDraft", () => {
it("returns an empty draft for null config", () => {
expect(configToDraft(null)).toEqual(emptyDraft());
});
it("renders a string lifecycle command as a single textarea text and remembers the kind", () => {
const draft = configToDraft({
worktree: { setup: "npm install" },
});
expect(draft.setupText).toBe("npm install");
expect(draft.setupOriginalKind).toBe("string");
expect(draft.teardownText).toBe("");
expect(draft.teardownOriginalKind).toBe("missing");
});
it("renders an array lifecycle command as newline-separated text", () => {
const draft = configToDraft({
worktree: { teardown: ["docker compose down", "rm -rf .cache"] },
});
expect(draft.teardownText).toBe("docker compose down\nrm -rf .cache");
expect(draft.teardownOriginalKind).toBe("array");
});
it("converts a scripts record into draft rows with stable local ids", () => {
const draft = configToDraft({
scripts: {
dev: { type: "long-running", command: "npm run dev", port: 3000 },
build: { command: ["npm", "run", "build"] },
},
});
expect(draft.scripts).toHaveLength(2);
const [devRow, buildRow] = draft.scripts;
expect(devRow.name).toBe("dev");
expect(devRow.commandText).toBe("npm run dev");
expect(devRow.commandOriginalKind).toBe("string");
expect(devRow.type).toBe("long-running");
expect(devRow.portText).toBe("3000");
expect(devRow.id).toBeTruthy();
expect(buildRow.name).toBe("build");
expect(buildRow.commandText).toBe("npm\nrun\nbuild");
expect(buildRow.commandOriginalKind).toBe("array");
expect(buildRow.portText).toBe("");
expect(buildRow.id).not.toBe(devRow.id);
});
});
describe("applyDraftToConfig", () => {
it("preserves the original string kind when editing an existing setup field", () => {
const base: PaseoConfigRaw = { worktree: { setup: "npm install" } };
const draft = configToDraft(base);
draft.setupText = "npm install\nnpm run prepare";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.setup).toBe("npm install\nnpm run prepare");
});
it("preserves the original array kind when editing an existing teardown field", () => {
const base: PaseoConfigRaw = {
worktree: { teardown: ["docker compose down"] },
};
const draft = configToDraft(base);
draft.teardownText = "docker compose down\nrm -rf .cache";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.teardown).toEqual(["docker compose down", "rm -rf .cache"]);
});
it("writes a string for a newly added lifecycle field with one non-empty line", () => {
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.setupText = "npm install";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.setup).toBe("npm install");
});
it("writes an array for a newly added lifecycle field with multiple non-empty lines", () => {
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.setupText = "npm install\nnpm run prepare";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.setup).toEqual(["npm install", "npm run prepare"]);
});
it("omits a lifecycle field whose draft text is empty", () => {
const base: PaseoConfigRaw = { worktree: { setup: "npm install" } };
const draft = configToDraft(base);
draft.setupText = "";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.setup).toBeUndefined();
});
it("preserves unknown top-level, worktree, and script entry fields on round-trip", () => {
const base = {
worktree: {
setup: "npm install",
terminals: [{ name: "dev", command: "npm run dev" }],
customWorktreeField: "keep",
},
scripts: {
dev: {
type: "long-running",
command: "npm run dev",
port: 3000,
customScriptField: { nested: true },
},
},
customTopLevel: "preserved",
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
const next = applyDraftToConfig({ draft, base });
expect((next as unknown as Record<string, unknown>).customTopLevel).toBe("preserved");
expect((next.worktree as unknown as Record<string, unknown>).customWorktreeField).toBe("keep");
expect((next.worktree as unknown as Record<string, unknown>).terminals).toEqual([
{ name: "dev", command: "npm run dev" },
]);
const devEntry = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
expect(devEntry.customScriptField).toEqual({ nested: true });
});
it("preserves all scripts on round-trip, including ones never edited in this session", () => {
const base = {
scripts: {
dev: { type: "long-running", command: "npm run dev", port: 3000, customDevField: "keep" },
build: { command: ["npm", "run", "build"], customBuildField: { nested: 1 } },
lint: { command: "npm run lint", type: "task" },
},
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
// Edit only "dev". Leave "build" and "lint" untouched.
const devRow = draft.scripts.find((row) => row.name === "dev");
if (!devRow) throw new Error("expected dev row in draft");
devRow.commandText = "npm run dev -- --watch";
const next = applyDraftToConfig({ draft, base });
const scripts = next.scripts ?? {};
expect(Object.keys(scripts).sort()).toEqual(["build", "dev", "lint"]);
const devEntry = scripts.dev as unknown as Record<string, unknown>;
expect(devEntry.command).toBe("npm run dev -- --watch");
expect(devEntry.type).toBe("long-running");
expect(devEntry.port).toBe(3000);
expect(devEntry.customDevField).toBe("keep");
const buildEntry = scripts.build as unknown as Record<string, unknown>;
expect(buildEntry.command).toEqual(["npm", "run", "build"]);
expect(buildEntry.customBuildField).toEqual({ nested: 1 });
const lintEntry = scripts.lint as unknown as Record<string, unknown>;
expect(lintEntry.command).toBe("npm run lint");
expect(lintEntry.type).toBe("task");
});
it("normalizes script command text into the original command kind", () => {
const base = {
scripts: {
build: { command: ["npm", "run", "build"] },
},
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
const buildRow = draft.scripts[0];
buildRow.commandText = "npm run build";
const next = applyDraftToConfig({ draft, base });
const buildEntry = (next.scripts ?? {}).build as unknown as Record<string, unknown>;
expect(buildEntry.command).toEqual(["npm run build"]);
});
it("parses script port as a number when numeric and writes string for non-numeric input", () => {
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.scripts = [
{
id: "row-1",
name: "dev",
commandText: "npm run dev",
commandOriginalKind: "missing",
type: "long-running",
portText: "3000",
rawEntry: {},
},
{
id: "row-2",
name: "tunnel",
commandText: "ngrok",
commandOriginalKind: "missing",
type: "long-running",
portText: "auto",
rawEntry: {},
},
];
const next = applyDraftToConfig({ draft, base });
const dev = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
const tunnel = (next.scripts ?? {}).tunnel as unknown as Record<string, unknown>;
expect(dev.port).toBe(3000);
expect(tunnel.port).toBe("auto");
});
it("drops scripts with an empty name and removes scripts no longer present in the draft", () => {
const base = {
scripts: {
dev: { command: "npm run dev" },
build: { command: "npm run build" },
},
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
// remove build, add a row with empty name.
draft.scripts = draft.scripts
.filter((row) => row.name !== "build")
.concat({
id: "row-empty",
name: " ",
commandText: "echo hi",
commandOriginalKind: "missing",
type: "",
portText: "",
rawEntry: {},
});
const next = applyDraftToConfig({ draft, base });
const scripts = next.scripts ?? {};
expect(Object.keys(scripts)).toEqual(["dev"]);
});
});
@@ -0,0 +1,187 @@
import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@server/shared/messages";
export type LifecycleOriginalKind = "string" | "array" | "missing";
export interface ProjectScriptDraft {
id: string;
name: string;
commandText: string;
commandOriginalKind: LifecycleOriginalKind;
type: string;
portText: string;
rawEntry: PaseoScriptEntryRaw;
}
export interface ProjectConfigDraft {
setupText: string;
setupOriginalKind: LifecycleOriginalKind;
teardownText: string;
teardownOriginalKind: LifecycleOriginalKind;
scripts: ProjectScriptDraft[];
}
interface LifecycleProjection {
text: string;
kind: LifecycleOriginalKind;
}
function projectLifecycle(value: unknown): LifecycleProjection {
if (typeof value === "string") {
return { text: value, kind: "string" };
}
if (Array.isArray(value)) {
const lines = value.filter((entry): entry is string => typeof entry === "string");
return { text: lines.join("\n"), kind: "array" };
}
return { text: "", kind: "missing" };
}
function lifecycleFromText(
text: string,
kind: LifecycleOriginalKind,
): string | string[] | undefined {
const lines = text.split("\n").filter((line) => line.trim().length > 0);
if (lines.length === 0) {
return undefined;
}
if (kind === "string") {
return lines.join("\n");
}
if (kind === "array") {
return lines;
}
return lines.length === 1 ? lines[0] : lines;
}
function projectScriptType(value: unknown): string {
return typeof value === "string" ? value : "";
}
function projectScriptPort(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
if (typeof value === "string") {
return value;
}
return "";
}
function parseScriptPort(value: string): number | string | undefined {
const trimmed = value.trim();
if (trimmed.length === 0) {
return undefined;
}
if (/^[0-9]+$/.test(trimmed)) {
const parsed = Number(trimmed);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return trimmed;
}
let scriptDraftIdCounter = 0;
function nextScriptDraftId(): string {
scriptDraftIdCounter += 1;
return `script-draft-${scriptDraftIdCounter}`;
}
export function configToDraft(config: PaseoConfigRaw | null | undefined): ProjectConfigDraft {
const worktree = config?.worktree ?? {};
const setup = projectLifecycle(worktree.setup);
const teardown = projectLifecycle(worktree.teardown);
const scripts: ProjectScriptDraft[] = [];
const scriptsRecord = config?.scripts ?? {};
for (const [name, entry] of Object.entries(scriptsRecord)) {
const command = projectLifecycle(entry.command);
scripts.push({
id: nextScriptDraftId(),
name,
commandText: command.text,
commandOriginalKind: command.kind,
type: projectScriptType(entry.type),
portText: projectScriptPort(entry.port),
rawEntry: entry,
});
}
return {
setupText: setup.text,
setupOriginalKind: setup.kind,
teardownText: teardown.text,
teardownOriginalKind: teardown.kind,
scripts,
};
}
interface ApplyDraftInput {
draft: ProjectConfigDraft;
base: PaseoConfigRaw | null | undefined;
}
export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
const baseConfig = input.base ?? {};
const baseWorktree = baseConfig.worktree ?? {};
const nextWorktree: Record<string, unknown> = { ...baseWorktree };
const nextSetup = lifecycleFromText(input.draft.setupText, input.draft.setupOriginalKind);
if (nextSetup === undefined) {
delete nextWorktree.setup;
} else {
nextWorktree.setup = nextSetup;
}
const nextTeardown = lifecycleFromText(
input.draft.teardownText,
input.draft.teardownOriginalKind,
);
if (nextTeardown === undefined) {
delete nextWorktree.teardown;
} else {
nextWorktree.teardown = nextTeardown;
}
const nextScripts: Record<string, PaseoScriptEntryRaw> = {};
for (const row of input.draft.scripts) {
const trimmedName = row.name.trim();
if (trimmedName.length === 0) {
continue;
}
const baseEntry = row.rawEntry;
const nextEntry: Record<string, unknown> = { ...baseEntry };
const nextCommand = lifecycleFromText(row.commandText, row.commandOriginalKind);
if (nextCommand === undefined) {
delete nextEntry.command;
} else {
nextEntry.command = nextCommand;
}
const trimmedType = row.type.trim();
if (trimmedType.length === 0) {
delete nextEntry.type;
} else {
nextEntry.type = trimmedType;
}
const nextPort = parseScriptPort(row.portText);
if (nextPort === undefined) {
delete nextEntry.port;
} else {
nextEntry.port = nextPort;
}
nextScripts[trimmedName] = nextEntry as PaseoScriptEntryRaw;
}
const result: Record<string, unknown> = { ...baseConfig };
if (Object.keys(nextWorktree).length === 0) {
delete result.worktree;
} else {
result.worktree = nextWorktree;
}
if (Object.keys(nextScripts).length === 0) {
delete result.scripts;
} else {
result.scripts = nextScripts;
}
return result as PaseoConfigRaw;
}
+465
View File
@@ -0,0 +1,465 @@
import { describe, expect, it } from "vitest";
import type { ProjectPlacementPayload } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects } from "./projects";
function placement(input: {
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
mainRepoRoot?: string | null;
}): ProjectPlacementPayload {
return {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: input.mainRepoRoot ?? null,
},
};
}
function workspace(input: {
id: string;
repoRoot: string;
project?: ProjectPlacementPayload;
projectId?: string;
projectName?: string;
remoteUrl?: string | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectRootPath: input.repoRoot,
workspaceDirectory: input.repoRoot,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
status: "done",
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: "main",
remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: input.project,
};
}
describe("buildProjects", () => {
it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-a",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-a",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-b",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-b",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
workspace({
id: "feature",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app/feature",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("remote:github.com/acme/app");
expect(summary?.projectName).toBe("acme/app");
expect(summary?.hostCount).toBe(2);
expect(summary?.onlineHostCount).toBe(2);
expect(summary?.totalWorkspaceCount).toBe(5);
expect(summary?.githubUrl).toBe("https://github.com/acme/app");
expect(summary?.hosts).toHaveLength(2);
const local = summary?.hosts.find((host) => host.serverId === "local");
const laptop = summary?.hosts.find((host) => host.serverId === "laptop");
expect(local?.workspaceCount).toBe(3);
expect(laptop?.workspaceCount).toBe(2);
expect(local?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature-a", "feature-b"]);
expect(laptop?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature"]);
});
it("collapses five workspaces on one host into a single host entry whose workspaceCount is five", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: Array.from({ length: 5 }, (_, index) =>
workspace({
id: `ws-${index}`,
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: `/repo/app/ws-${index}`,
remoteUrl: "https://github.com/acme/app.git",
}),
}),
),
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toHaveLength(1);
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(5);
expect(result.projects[0]?.totalWorkspaceCount).toBe(5);
expect(result.projects[0]?.hostCount).toBe(1);
});
it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/worktrees/app/main",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/worktrees/app/main",
remoteUrl: "https://github.com/acme/app.git",
mainRepoRoot: "/repo/app",
}),
}),
],
},
{
serverId: "legacy",
serverName: "Legacy",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
}),
],
},
],
});
const acme = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const legacy = result.projects.find((project) => project.projectKey === "legacy-project");
expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app");
expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy");
});
it("derives githubUrl only when projectKey matches remote:github.com/{owner}/{repo}", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
const github = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const local = result.projects.find((project) => project.projectKey === "/repo/local");
expect(github?.githubUrl).toBe("https://github.com/acme/app");
expect(local?.githubUrl).toBeUndefined();
});
it("totals hostCount across all hosts and counts only online ones in onlineHostCount", () => {
const result = buildProjects({
hosts: [
{
serverId: "online",
serverName: "Online",
isOnline: true,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
},
{
serverId: "offline",
serverName: "Offline",
isOnline: false,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hostCount).toBe(2);
expect(result.projects[0]?.onlineHostCount).toBe(1);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "online")?.isOnline).toBe(
true,
);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "offline")?.isOnline).toBe(
false,
);
});
it("does not merge fallback repo-root-keyed projects with different roots", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "one",
repoRoot: "/repo/one",
project: placement({
projectKey: "/repo/one",
projectName: "one",
cwd: "/repo/one",
remoteUrl: null,
}),
}),
workspace({
id: "two",
repoRoot: "/repo/two",
project: placement({
projectKey: "/repo/two",
projectName: "two",
cwd: "/repo/two",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.projects.map((project) => project.projectKey)).toEqual([
"/repo/one",
"/repo/two",
]);
});
it("filters non-GitHub remote projects while keeping GitHub and local projects", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/github",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/github",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
expect(result.projects.map((project) => project.projectKey)).toEqual([
"remote:github.com/acme/app",
"/repo/local",
]);
});
it("produces the unsupported empty-state signal when only non-GitHub remote projects are present", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toEqual([]);
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
});
it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => {
const result = buildProjects({
hosts: [
{
serverId: "old-daemon",
serverName: "Old daemon",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
remoteUrl: "https://gitlab.com/acme/legacy.git",
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("legacy-project");
expect(summary?.projectName).toBe("Legacy");
expect(summary?.githubUrl).toBeUndefined();
expect(summary?.hosts).toHaveLength(1);
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
expect(result.hiddenUnsupportedRemoteCount).toBe(0);
});
});
+193
View File
@@ -0,0 +1,193 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
export interface WorkspaceSummary {
id: string;
name: string;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
status: WorkspaceDescriptor["status"];
currentBranch: string | null;
}
export interface ProjectHostEntry {
serverId: string;
serverName: string;
isOnline: boolean;
repoRoot: string;
workspaceCount: number;
workspaces: WorkspaceSummary[];
gitRuntime?: WorkspaceDescriptor["gitRuntime"];
githubRuntime?: WorkspaceDescriptor["githubRuntime"];
}
export interface ProjectSummary {
projectKey: string;
projectName: string;
hosts: ProjectHostEntry[];
totalWorkspaceCount: number;
hostCount: number;
onlineHostCount: number;
githubUrl?: string;
hiddenUnsupportedRemoteCount: number;
}
export interface ProjectHost {
serverId: string;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
}
export interface BuildProjectsInput {
hosts: ProjectHost[];
}
export interface BuildProjectsResult {
projects: ProjectSummary[];
hiddenUnsupportedRemoteCount: number;
}
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
interface HostGroup {
serverId: string;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
}
interface ProjectGroup {
projectKey: string;
projectName: string;
hostsByServerId: Map<string, HostGroup>;
}
function isSupportedProjectKey(projectKey: string): boolean {
return !projectKey.startsWith("remote:") || projectKey.startsWith("remote:github.com/");
}
function deriveGithubUrl(projectKey: string): string | undefined {
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
if (!match) {
return undefined;
}
return `https://github.com/${match[1]}/${match[2]}`;
}
function resolveHostRepoRoot(workspaces: WorkspaceDescriptor[]): string {
for (const workspace of workspaces) {
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
if (mainRepoRoot) {
return mainRepoRoot;
}
}
return workspaces[0]?.projectRootPath ?? "";
}
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
return {
id: workspace.id,
name: workspace.name,
workspaceKind: workspace.workspaceKind,
status: workspace.status,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
};
}
function toHostEntry(group: HostGroup): ProjectHostEntry {
const repoRoot = resolveHostRepoRoot(group.workspaces);
const canonical =
group.workspaces.find((workspace) => workspace.projectRootPath === repoRoot) ??
group.workspaces[0];
return {
serverId: group.serverId,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
workspaceCount: group.workspaces.length,
workspaces: group.workspaces.map(toWorkspaceSummary),
gitRuntime: canonical?.gitRuntime,
githubRuntime: canonical?.githubRuntime,
};
}
function compareHosts(left: ProjectHostEntry, right: ProjectHostEntry): number {
const name = left.serverName.localeCompare(right.serverName);
if (name !== 0) {
return name;
}
return left.serverId.localeCompare(right.serverId);
}
function toProjectSummary(input: {
draft: ProjectGroup;
hiddenUnsupportedRemoteCount: number;
}): ProjectSummary {
const hosts = Array.from(input.draft.hostsByServerId.values())
.map(toHostEntry)
.sort(compareHosts);
const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = hosts.filter((host) => host.isOnline).length;
return {
projectKey: input.draft.projectKey,
projectName: input.draft.projectName,
hosts,
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
githubUrl: deriveGithubUrl(input.draft.projectKey),
hiddenUnsupportedRemoteCount: input.hiddenUnsupportedRemoteCount,
};
}
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
const groups = new Map<string, ProjectGroup>();
let hiddenUnsupportedRemoteCount = 0;
for (const host of input.hosts) {
for (const workspace of host.workspaces) {
const projectKey = workspace.projectId;
if (!isSupportedProjectKey(projectKey)) {
hiddenUnsupportedRemoteCount += 1;
continue;
}
let group = groups.get(projectKey);
if (!group) {
group = {
projectKey,
projectName: workspace.projectDisplayName,
hostsByServerId: new Map(),
};
groups.set(projectKey, group);
}
let hostGroup = group.hostsByServerId.get(host.serverId);
if (!hostGroup) {
hostGroup = {
serverId: host.serverId,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
};
group.hostsByServerId.set(host.serverId, hostGroup);
}
hostGroup.workspaces.push(workspace);
}
}
const projects = Array.from(groups.values()).map((draft) =>
toProjectSummary({ draft, hiddenUnsupportedRemoteCount }),
);
projects.sort((left, right) => {
const name = left.projectName.localeCompare(right.projectName);
if (name !== 0) {
return name;
}
return left.projectKey.localeCompare(right.projectKey);
});
return {
projects,
hiddenUnsupportedRemoteCount,
};
}
@@ -13,6 +13,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
workspaceKey: "srv:ws-root",
serverId: "srv",
workspaceId: "ws-root",
projectKey: "project-1",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
@@ -11,11 +11,13 @@ function workspace(input: {
workspaceId: string;
workspaceDirectory: string;
name: string;
projectKey?: string;
}): SidebarWorkspaceEntry {
return {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
projectKey: input.projectKey ?? "project-default",
workspaceDirectory: input.workspaceDirectory,
projectKind: "git",
workspaceKind: "checkout",