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",
]);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
export const projectsQueryKey = ["projects"] as const;
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export interface UseProjectsResult {
projects: ProjectSummary[];
hiddenUnsupportedRemoteCount: number;
hostErrors: ProjectHostError[];
isLoading: boolean;
isFetching: boolean;
refetch: () => void;
}
interface FetchAllWorkspaceDescriptorsInput {
client: Pick<DaemonClient, "fetchWorkspaces">;
}
interface HostWorkspacesResult {
host: ProjectHost;
error: ProjectHostError | null;
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function fetchAllWorkspaceDescriptors(
input: FetchAllWorkspaceDescriptorsInput,
): Promise<WorkspaceDescriptor[]> {
const entries: WorkspaceDescriptor[] = [];
let cursor: string | null = null;
while (true) {
const payload = await input.client.fetchWorkspaces({
sort: [{ key: "name", direction: "asc" }],
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;
}
export function useProjects(): UseProjectsResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const hostInputs = useMemo(
() =>
hosts.map((host) => ({
serverId: host.serverId,
serverName: host.label,
})),
[hosts],
);
const projectsQuery = useQuery({
queryKey: projectsQueryKey,
queryFn: async () => {
const results = await Promise.all(
hostInputs.map(async (host): Promise<HostWorkspacesResult> => {
const snapshot = runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = runtime.getClient(host.serverId);
if (!client || !isOnline) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: null,
};
}
try {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: await fetchAllWorkspaceDescriptors({ client }),
},
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,
};
},
});
return {
projects: projectsQuery.data?.projects ?? [],
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
hostErrors: projectsQuery.data?.hostErrors ?? [],
isLoading: projectsQuery.isLoading,
isFetching: projectsQuery.isFetching,
refetch: () => {
void projectsQuery.refetch();
},
};
}
@@ -21,6 +21,7 @@ export interface SidebarWorkspaceEntry {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectKey: string;
projectRootPath?: string;
workspaceDirectory?: string;
projectKind: WorkspaceDescriptor["projectKind"];
@@ -57,6 +58,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
projectKey: input.project.projectKey,
projectRootPath: input.project.iconWorkingDir,
workspaceDirectory: undefined,
projectKind: input.project.projectKind,
@@ -77,6 +79,7 @@ export function createSidebarWorkspaceEntry(input: {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,