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