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
+280
View File
@@ -0,0 +1,280 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import { useProjects } from "./use-projects";
interface MockDaemonClient {
fetchWorkspaces: ReturnType<typeof vi.fn>;
}
const { hostState, clientsByServerId, runtimeStore } = vi.hoisted(() => {
const state = {
hosts: [
{ serverId: "local", label: "Local" },
{ serverId: "laptop", label: "Laptop" },
],
snapshots: new Map<string, { connectionStatus: string }>(),
};
const clients = new Map<string, { fetchWorkspaces: ReturnType<typeof vi.fn> }>();
return {
hostState: state,
clientsByServerId: clients,
runtimeStore: {
getClient: (serverId: string) => clients.get(serverId) ?? null,
getSnapshot: (serverId: string) => state.snapshots.get(serverId) ?? null,
},
};
});
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: () => runtimeStore,
useHosts: () => hostState.hosts,
}));
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
}
function renderProjectsHook() {
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return renderHook(() => useProjects(), { wrapper });
}
function makeClient(entries: WorkspaceDescriptorPayload[] | Error): MockDaemonClient {
return {
fetchWorkspaces: vi.fn(async () => {
if (entries instanceof Error) {
throw entries;
}
return {
requestId: "req-workspaces",
entries,
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
};
}),
};
}
function workspace(input: {
id: string;
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
}): WorkspaceDescriptorPayload {
return {
id: input.id,
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectRootPath: input.cwd,
workspaceDirectory: input.cwd,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: "main",
remoteUrl: input.remoteUrl,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
afterEach(() => {
clientsByServerId.clear();
hostState.hosts = [
{ serverId: "local", label: "Local" },
{ serverId: "laptop", label: "Laptop" },
];
hostState.snapshots = new Map<string, { connectionStatus: string }>();
});
describe("useProjects", () => {
it("calls all known daemon clients and returns aggregated projects sorted by display name", async () => {
hostState.snapshots.set("local", { connectionStatus: "online" });
hostState.snapshots.set("laptop", { connectionStatus: "online" });
clientsByServerId.set(
"local",
makeClient([
workspace({
id: "z-main",
projectKey: "remote:github.com/acme/zeta",
projectName: "acme/zeta",
cwd: "/repo/zeta",
remoteUrl: "https://github.com/acme/zeta.git",
}),
]),
);
clientsByServerId.set(
"laptop",
makeClient([
workspace({
id: "a-main",
projectKey: "remote:github.com/acme/alpha",
projectName: "acme/alpha",
cwd: "/repo/alpha",
remoteUrl: "https://github.com/acme/alpha.git",
}),
]),
);
const { result } = renderProjectsHook();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(clientsByServerId.get("local")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
expect(clientsByServerId.get("laptop")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
expect(result.current.projects.map((project) => project.projectName)).toEqual([
"acme/alpha",
"acme/zeta",
]);
});
it("surfaces per-host fetch failures without dropping successful hosts", async () => {
hostState.snapshots.set("local", { connectionStatus: "online" });
hostState.snapshots.set("laptop", { connectionStatus: "online" });
clientsByServerId.set(
"local",
makeClient([
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
]),
);
clientsByServerId.set("laptop", makeClient(new Error("laptop unavailable")));
const { result } = renderProjectsHook();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
]);
expect(result.current.hostErrors).toEqual([
{
serverId: "laptop",
serverName: "Laptop",
message: "laptop unavailable",
},
]);
});
it("skips disconnected hosts silently without surfacing them as failures", async () => {
hostState.snapshots.set("local", { connectionStatus: "online" });
hostState.snapshots.set("laptop", { connectionStatus: "offline" });
clientsByServerId.set(
"local",
makeClient([
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
]),
);
const { result } = renderProjectsHook();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hostErrors).toEqual([]);
expect(result.current.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
]);
});
it("returns only the stable public project and host entry shapes", async () => {
hostState.snapshots.set("local", { connectionStatus: "online" });
clientsByServerId.set(
"local",
makeClient([
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
]),
);
const { result } = renderProjectsHook();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
"githubUrl",
"hiddenUnsupportedRemoteCount",
"hostCount",
"hosts",
"onlineHostCount",
"projectKey",
"projectName",
"totalWorkspaceCount",
]);
expect(Object.keys(result.current.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
"gitRuntime",
"githubRuntime",
"isOnline",
"repoRoot",
"serverId",
"serverName",
"workspaceCount",
"workspaces",
]);
expect(Object.keys(result.current.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
"currentBranch",
"id",
"name",
"status",
"workspaceKind",
]);
});
});