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