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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user