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
+119
View File
@@ -0,0 +1,119 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"];
interface ProjectsSettingsProject {
name: string;
path: string;
}
interface ProjectsSettingsFixtures {
editableProject: ProjectsSettingsProject;
}
const initialPaseoConfig = {
worktree: {
setup: ["echo initial setup"],
teardown: "echo cleanup",
customWorktreeField: "preserved",
},
scripts: {
dev: {
command: "npm run dev",
type: "server",
port: 3000,
customScriptField: "preserved",
},
},
customTopLevelField: "preserved",
};
const test = base.extend<ProjectsSettingsFixtures>({
editableProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-", {
paseoConfig: initialPaseoConfig,
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
});
await client.close();
await repo.cleanup();
},
});
async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(/\/projects$/);
}
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `${projectName} project details` }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup" })).toBeVisible({
timeout: 30_000,
});
}
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page.getByRole("textbox", { name: "Worktree setup" }).fill(setupCommands.join("\n"));
}
async function saveProjectConfig(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
await expect
.poll(
async () => {
const contents = await readProjectConfigFile(project);
return JSON.parse(contents) as unknown;
},
{
timeout: 30_000,
},
)
.toMatchObject({
worktree: {
setup: updatedSetup,
teardown: initialPaseoConfig.worktree.teardown,
customWorktreeField: initialPaseoConfig.worktree.customWorktreeField,
},
scripts: {
dev: {
command: initialPaseoConfig.scripts.dev.command,
type: initialPaseoConfig.scripts.dev.type,
port: initialPaseoConfig.scripts.dev.port,
customScriptField: initialPaseoConfig.scripts.dev.customScriptField,
},
},
customTopLevelField: initialPaseoConfig.customTopLevelField,
});
const savedConfig = await readProjectConfigFile(project);
expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`);
}
async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<string> {
return readFile(path.join(project.path, "paseo.json"), "utf8");
}
test.describe("Projects settings", () => {
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await editWorktreeSetup(page, updatedSetup);
await saveProjectConfig(page);
await expectProjectConfigSaved(editableProject);
});
});
+2
View File
@@ -830,6 +830,8 @@ function RootStack() {
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
{/*
@@ -0,0 +1,12 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;
}
@@ -0,0 +1,7 @@
import SettingsScreen from "@/screens/settings-screen";
const PROJECTS_VIEW = { kind: "projects" as const };
export default function SettingsProjectsIndexRoute() {
return <SettingsScreen view={PROJECTS_VIEW} />;
}
@@ -39,6 +39,7 @@ import {
FolderGit2,
GitPullRequest,
Globe,
Settings,
SquareTerminal,
Monitor,
MoreVertical,
@@ -53,6 +54,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import {
buildHostNewWorkspaceRoute,
buildProjectSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
@@ -558,6 +560,10 @@ function ProjectKebabMenu({
() => <Trash2 size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const settingsLeadingIcon = useMemo(
() => <Settings size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const renderTriggerIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<MoreVertical
@@ -567,6 +573,11 @@ function ProjectKebabMenu({
),
[theme.colors.foreground, theme.colors.foregroundMuted],
);
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -579,6 +590,15 @@ function ProjectKebabMenu({
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{canOpenProjectSettings ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-settings-${projectKey}`}
leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings}
>
Open project settings
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={removeProjectLeadingIcon}
+32 -12
View File
@@ -6,7 +6,7 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { Pressable, Text, View } from "react-native";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import type {
PressableProps,
PressableStateCallbackType,
@@ -104,6 +104,7 @@ export function Button({
style,
textStyle,
disabled,
loading = false,
accessibilityRole,
...props
}: PropsWithChildren<
@@ -113,10 +114,12 @@ export function Button({
leftIcon?: LeftIcon;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
loading?: boolean;
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const isDisabled = disabled || loading;
let variantStyle: ViewStyle;
if (variant === "default") {
@@ -150,10 +153,10 @@ export function Button({
sizeStyle,
variantStyle,
pressed ? styles.pressed : null,
disabled ? styles.disabled : null,
isDisabled ? styles.disabled : null,
style,
],
[sizeStyle, variantStyle, disabled, style],
[sizeStyle, variantStyle, isDisabled, style],
);
const resolvedTextStyle = useMemo(
@@ -168,7 +171,30 @@ export function Button({
[variant, textStyle, isGhostHovered],
);
const accessibilityState = useMemo(
() => ({ disabled: isDisabled, busy: loading }),
[isDisabled, loading],
);
function resolveIconColor(): string {
if (variant === "default") {
return theme.colors.accentForeground;
}
if (variant === "ghost") {
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
}
return theme.colors.foreground;
}
function renderIcon() {
if (loading) {
return (
<View>
<ActivityIndicator size="small" color={resolveIconColor()} />
</View>
);
}
if (!leftIcon) return null;
// Pre-rendered element — pass through
@@ -176,14 +202,7 @@ export function Button({
return <View>{leftIcon}</View>;
}
let color: string;
if (variant === "default") {
color = theme.colors.accentForeground;
} else if (variant === "ghost") {
color = isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
} else {
color = theme.colors.foreground;
}
const color = resolveIconColor();
const iconSize = ICON_SIZE[size];
// Render function
@@ -208,7 +227,8 @@ export function Button({
<Pressable
{...props}
accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled}
accessibilityState={accessibilityState}
disabled={isDisabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableStyle}
+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,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects";
import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects";
const { theme, projectsState, navigate } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 20 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" as const, medium: "500" as const },
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
opacity: { 50: 0.5 },
colors: {
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surfaceSidebarHover: "#1a1a1a",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#444",
accent: "#0a84ff",
palette: { red: { 300: "#ff6b6b" } },
},
},
projectsState: {
current: {
projects: [],
hostErrors: [],
hiddenUnsupportedRemoteCount: 0,
isLoading: false,
isFetching: false,
refetch: vi.fn(),
} as UseProjectsResult,
},
navigate: vi.fn(),
}));
vi.mock("react-native", () => {
const passthrough = ({
children,
testID,
accessibilityLabel,
accessibilityRole,
onPress,
onHoverIn,
onHoverOut,
...rest
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
accessibilityRole?: string;
onPress?: (event: { stopPropagation: () => void }) => void;
onHoverIn?: () => void;
onHoverOut?: () => void;
} & Record<string, unknown>) => {
const dataAttrs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rest)) {
if (key.startsWith("data-")) {
dataAttrs[key] = value;
}
}
return React.createElement(
"div",
{
role: accessibilityRole,
"aria-label": accessibilityLabel,
"data-testid": testID,
onClick: onPress
? (event: React.MouseEvent) => {
onPress({ stopPropagation: () => event.stopPropagation() });
}
: undefined,
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
...dataAttrs,
},
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
);
};
return {
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children),
Text: ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children),
Pressable: passthrough,
Image: ({ source }: { source?: { uri?: string } }) =>
React.createElement("img", { src: source?.uri ?? "" }),
Platform: { OS: "web" },
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
ChevronRight: icon("ChevronRight"),
MoreVertical: icon("MoreVertical"),
ExternalLink: icon("ExternalLink"),
Pencil: icon("Pencil"),
FolderGit2: icon("FolderGit2"),
};
});
vi.mock("expo-router", () => ({
router: { navigate },
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: ({ size }: { size?: string | number }) =>
React.createElement("span", {
"data-testid": "projects-loading-spinner",
"data-size": size,
}),
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "dropdown-menu" }, children),
DropdownMenuTrigger: ({
children,
accessibilityLabel,
testID,
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
accessibilityLabel?: string;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"aria-label": accessibilityLabel,
"data-testid": testID,
onClick: (event: React.MouseEvent) => event.stopPropagation(),
},
typeof children === "function"
? children({ pressed: false, hovered: false, open: false })
: children,
),
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "dropdown-menu-content" }, children),
DropdownMenuItem: ({
children,
onSelect,
testID,
}: {
children?: React.ReactNode;
onSelect?: () => void;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"data-testid": testID,
onClick: (event: React.MouseEvent) => {
event.stopPropagation();
onSelect?.();
},
},
children,
),
}));
vi.mock("@/hooks/use-projects", () => ({
useProjects: () => projectsState.current,
}));
vi.mock("@/hooks/use-project-icon-query", () => ({
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
}));
import ProjectsScreen from "./projects-screen";
function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceSummary {
return {
id: "ws-1",
name: "main",
workspaceKind: "directory",
status: "done",
currentBranch: "main",
...overrides,
};
}
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",
workspaceCount: 1,
workspaces: [workspaceSummary()],
...overrides,
};
}
function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
const hosts = overrides.hosts ?? [hostEntry()];
const totalWorkspaceCount =
overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length;
return {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
hosts,
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
githubUrl: "https://github.com/acme/app",
hiddenUnsupportedRemoteCount: 0,
...overrides,
};
}
function setProjectsState(overrides: Partial<UseProjectsResult>) {
projectsState.current = {
projects: [],
hostErrors: [],
hiddenUnsupportedRemoteCount: 0,
isLoading: false,
isFetching: false,
refetch: vi.fn(),
...overrides,
};
}
function findRow(container: HTMLElement, projectKey: string): HTMLElement {
const row = container.querySelector<HTMLElement>(`[data-testid="project-row-${projectKey}"]`);
if (!row) throw new Error(`Expected row for ${projectKey}`);
return row;
}
describe("ProjectsScreen", () => {
let container: HTMLElement | null = null;
let root: Root | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
setProjectsState({});
navigate.mockReset();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
act(() => {
root?.render(<ProjectsScreen view={view} />);
});
}
it("renders a row whose visible content is the project name only", () => {
setProjectsState({
projects: [
project({
projectName: "acme/app",
hosts: [hostEntry({ serverName: "alpha", workspaceCount: 5 })],
}),
],
});
render({ kind: "projects" });
const rows = container?.querySelectorAll('[data-testid^="project-row-"]') ?? [];
expect(rows.length).toBe(1);
expect(container?.textContent).toContain("acme/app");
expect(container?.textContent).not.toContain("workspace");
expect(container?.textContent).not.toContain("offline");
expect(container?.textContent).not.toContain("github.com");
});
it("navigates to the project detail route when the row is pressed", () => {
setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
const row = findRow(container!, "remote:github.com/acme/app");
act(() => {
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
});
it("does not render a kebab menu on the row", () => {
setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
expect(
container?.querySelector('[data-testid="project-row-menu-remote:github.com/acme/app"]'),
).toBeNull();
});
it("renders a centered loading spinner before the first response", () => {
setProjectsState({ isLoading: true, projects: [] });
render({ kind: "projects" });
expect(container?.querySelector('[data-testid="projects-loading-spinner"]')).not.toBeNull();
});
it("renders the empty state when there are no projects", () => {
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
render({ kind: "projects" });
expect(container?.textContent).toContain("No projects yet");
});
it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => {
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 });
render({ kind: "projects" });
expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet");
});
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
const hostErrors: ProjectHostError[] = [
{ serverId: "a", serverName: "alpha", message: "timed out" },
{ serverId: "b", serverName: "beta", message: "unreachable" },
];
setProjectsState({
projects: [project()],
hostErrors,
});
render({ kind: "projects" });
const banner = container?.querySelector('[data-testid="projects-host-errors"]');
expect(banner).not.toBeNull();
expect(banner?.textContent).toContain("alpha");
expect(banner?.textContent).toContain("beta");
expect(container?.querySelector('[data-testid^="project-row-"]')).not.toBeNull();
});
it("highlights the selected row when the active view targets a project", () => {
setProjectsState({
projects: [
project({ projectKey: "remote:github.com/acme/app" }),
project({
projectKey: "remote:github.com/acme/other",
projectName: "acme/other",
githubUrl: "https://github.com/acme/other",
}),
],
});
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
const selected = findRow(container!, "remote:github.com/acme/app");
const other = findRow(container!, "remote:github.com/acme/other");
expect(selected.getAttribute("data-selected")).toBe("true");
expect(other.getAttribute("data-selected")).toBe("false");
});
it("does not include the word 'checkout' anywhere in the rendered tree", () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", serverName: "alpha", workspaceCount: 3 }),
hostEntry({ serverId: "b", serverName: "beta", workspaceCount: 2 }),
],
}),
],
hostErrors: [{ serverId: "x", serverName: "x", message: "down" }],
});
render({ kind: "projects" });
const html = container?.innerHTML.toLowerCase() ?? "";
expect(html).not.toContain("checkout");
});
});
@@ -0,0 +1,213 @@
import { useCallback, useMemo } from "react";
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
interface ProjectsScreenProps {
view: { kind: "projects" } | { kind: "project"; projectKey: string };
}
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
if (isLoading && projects.length === 0) {
return (
<View style={styles.centered} testID="projects-list">
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
</View>
);
}
if (projects.length === 0) {
const message =
hiddenUnsupportedRemoteCount > 0
? "Non-GitHub remote projects aren't supported yet"
: "No projects yet";
return (
<View style={styles.centered} testID="projects-list">
<Text style={styles.emptyText}>{message}</Text>
</View>
);
}
return (
<View testID="projects-list">
{hostErrors.length > 0 ? <HostErrorsBanner errors={hostErrors} /> : null}
<View style={settingsStyles.card}>
{projects.map((project, index) => (
<ProjectRow
key={project.projectKey}
project={project}
isFirst={index === 0}
isSelected={selectedProjectKey === project.projectKey}
/>
))}
</View>
</View>
);
}
function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
return (
<View style={styles.errorsBanner} testID="projects-host-errors">
{errors.map((error) => (
<Text key={error.serverId} style={styles.errorsBannerText}>
{`Couldn't load projects from host ${error.serverName}: ${error.message}`}
</Text>
))}
</View>
);
}
interface ProjectRowProps {
project: ProjectSummary;
isFirst: boolean;
isSelected: boolean;
}
function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
const { theme } = useUnistyles();
const { hosts, projectKey, projectName } = project;
const leadingHost = hosts[0];
const handleNavigate = useCallback(() => {
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
!isFirst && settingsStyles.rowBorder,
styles.row,
isSelected && styles.rowSelected,
hovered && !isSelected && styles.rowHovered,
pressed && styles.rowPressed,
],
[isFirst, isSelected],
);
return (
<Pressable
style={rowStyle}
onPress={handleNavigate}
accessibilityRole="button"
accessibilityLabel={`Edit ${projectName}`}
testID={`project-row-${projectKey}`}
data-selected={isSelected ? "true" : "false"}
>
<View style={styles.rowMain}>
<View style={styles.leading}>
<ProjectRowIcon host={leadingHost} projectName={projectName} />
</View>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{projectName}
</Text>
</View>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}
function ProjectRowIcon({
host,
projectName,
}: {
host: ProjectHostEntry | undefined;
projectName: string;
}) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
const { icon } = useProjectIconQuery({
serverId: host?.serverId ?? "",
cwd: host?.repoRoot ?? "",
});
const iconDataUri =
icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null;
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
if (iconDataUri) {
return <Image source={imageSource} style={styles.iconImage} />;
}
return (
<View style={styles.iconFallback}>
<Text style={styles.iconFallbackText}>{initial}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[6],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorsBanner: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[3],
gap: theme.spacing[1],
},
errorsBannerText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
row: {
gap: theme.spacing[3],
},
rowMain: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
rowPressed: {
backgroundColor: theme.colors.surface3,
},
rowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
leading: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
iconImage: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
},
iconFallback: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
iconFallbackText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
spinnerColor: {
color: theme.colors.foregroundMuted,
},
}));
+84 -15
View File
@@ -27,6 +27,7 @@ import {
Shield,
Puzzle,
Plus,
FolderGit2,
} from "lucide-react-native";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
@@ -70,11 +71,14 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { HostPage, HostRenameButton } from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
buildHostOpenProjectRoute,
buildHostWorkspaceRoute,
buildProjectsSettingsRoute,
buildSettingsHostRoute,
buildSettingsSectionRoute,
type SettingsSectionSlug,
@@ -88,7 +92,9 @@ import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-ac
export type SettingsView =
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string };
| { kind: "host"; serverId: string }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
interface SidebarSectionItem {
id: SettingsSectionSlug;
@@ -544,6 +550,37 @@ function SidebarSectionButton({
);
}
interface SidebarProjectsButtonProps {
isSelected: boolean;
onSelect: () => void;
}
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
const { theme } = useUnistyles();
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onSelect}
testID="settings-projects"
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<FolderGit2
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
Projects
</Text>
</Pressable>
);
}
interface SidebarHostItemProps {
serverId: string;
label: string;
@@ -590,6 +627,7 @@ interface SettingsSidebarProps {
view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHost: (serverId: string) => void;
onSelectProjects: () => void;
onAddHost: () => void;
onBackToWorkspace: () => void;
layout: "desktop" | "mobile";
@@ -599,6 +637,7 @@ function SettingsSidebar({
view,
onSelectSection,
onSelectHost,
onSelectProjects,
onAddHost,
onBackToWorkspace,
layout,
@@ -626,6 +665,7 @@ function SettingsSidebar({
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
return (
@@ -655,6 +695,7 @@ function SettingsSidebar({
onSelect={onSelectSection}
/>
))}
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>
@@ -818,6 +859,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[isCompactLayout, router],
);
const handleSelectProjects = useCallback(() => {
const target = buildProjectsSettingsRoute();
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
}, [isCompactLayout, router]);
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
@@ -844,17 +894,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}, [router]);
const handleBackToWorkspace = useCallback(() => {
if (!isCompactLayout) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return;
}
}
if (router.canGoBack()) {
router.back();
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return;
}
if (anyOnlineServerId) {
@@ -862,7 +906,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return;
}
router.replace("/");
}, [anyOnlineServerId, isCompactLayout, router]);
}, [anyOnlineServerId, router]);
const detailHeader = ((): {
title: string;
@@ -883,6 +927,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
if (!item) return null;
return { title: item.label, Icon: item.icon };
}
if (view.kind === "project" || view.kind === "projects") {
return { title: "Projects", Icon: FolderGit2 };
}
return null;
})();
@@ -890,6 +937,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
if (view.kind === "host") {
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
}
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
}
if (view.kind === "project") {
return <ProjectSettingsScreen projectKey={view.projectKey} />;
}
if (view.kind === "section") {
switch (view.section) {
case "general":
@@ -964,6 +1017,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
view={view}
onSelectSection={handleSelectSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
layout="mobile"
@@ -974,14 +1028,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
);
}
// Mobile detail: full-screen content with a back header that returns to the list.
// Mobile detail: full-screen content with a back header. Project detail uses
// an app-level back (out of settings, to the workspace) since the in-body
// "Back to projects" ghost button handles list-level back; other detail views
// step back to the settings root.
const detailBackHandler = view.kind === "project" ? handleBackToWorkspace : handleBackToRoot;
if (isCompactLayout) {
return (
<View style={styles.container}>
<BackHeader
title={detailHeader?.title}
titleAccessory={detailHeader?.titleAccessory}
onBack={handleBackToRoot}
onBack={detailBackHandler}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
@@ -1001,6 +1059,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
view={view}
onSelectSection={handleSelectSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
layout="desktop"
@@ -1094,6 +1153,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
placeholder: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[8],
},
placeholderText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
const desktopStyles = StyleSheet.create((theme) => ({
@@ -115,6 +115,50 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.scripts).toEqual([]);
});
it("preserves project placement from workspace descriptor payloads", () => {
const workspace = normalizeWorkspaceDescriptor({
id: "1",
projectId: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
workspaceDirectory: "/repo/app",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
project: {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
});
expect(workspace.project).toEqual({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
});
});
describe("mergeWorkspaces", () => {
+6
View File
@@ -119,6 +119,9 @@ export interface WorkspaceDescriptor {
status: WorkspaceDescriptorPayload["status"];
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptorPayload["scripts"];
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
project?: ProjectPlacementPayload;
}
export function normalizeWorkspaceDescriptor(
@@ -136,6 +139,9 @@ export function normalizeWorkspaceDescriptor(
status: payload.status,
diffStat: payload.diffStat ?? null,
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
gitRuntime: payload.gitRuntime,
githubRuntime: payload.githubRuntime,
project: payload.project,
};
}
@@ -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);
});
});
+12
View File
@@ -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;
}
+465
View File
@@ -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);
});
});
+193
View File
@@ -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",
@@ -1171,6 +1171,125 @@ test("requests branch suggestions via RPC", async () => {
});
});
test("reads project config via correlated RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.readProjectConfig("/repo/app", "read-project-config-1");
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: { type: "read_project_config_request"; requestId: string; repoRoot: string };
};
expect(request.message).toEqual({
type: "read_project_config_request",
requestId: "read-project-config-1",
repoRoot: "/repo/app",
});
mock.triggerMessage(
wrapSessionMessage({
type: "read_project_config_response",
payload: {
requestId: "read-project-config-1",
repoRoot: "/repo/app",
ok: true,
config: { worktree: { setup: "npm install" } },
revision: { mtimeMs: 10, size: 20 },
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "read-project-config-1",
repoRoot: "/repo/app",
ok: true,
config: { worktree: { setup: "npm install" } },
revision: { mtimeMs: 10, size: 20 },
});
});
test("writes project config via correlated RPC and returns inline failures", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.writeProjectConfig({
requestId: "write-project-config-1",
repoRoot: "/repo/app",
config: { worktree: { setup: ["npm install"] } },
expectedRevision: { mtimeMs: 10, size: 20 },
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "write_project_config_request";
requestId: string;
repoRoot: string;
config: unknown;
expectedRevision: unknown;
};
};
expect(request.message).toEqual({
type: "write_project_config_request",
requestId: "write-project-config-1",
repoRoot: "/repo/app",
config: { worktree: { setup: ["npm install"] } },
expectedRevision: { mtimeMs: 10, size: 20 },
});
mock.triggerMessage(
wrapSessionMessage({
type: "write_project_config_response",
payload: {
requestId: "write-project-config-1",
repoRoot: "/repo/app",
ok: false,
error: {
code: "stale_project_config",
currentRevision: { mtimeMs: 11, size: 21 },
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "write-project-config-1",
repoRoot: "/repo/app",
ok: false,
error: {
code: "stale_project_config",
currentRevision: { mtimeMs: 11, size: 21 },
},
});
});
test("requests directory suggestions via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -68,6 +68,8 @@ import type {
SessionOutboundMessage,
SendAgentMessageRequest,
EditorTargetId,
PaseoConfigRaw,
PaseoConfigRevision,
} from "../shared/messages.js";
import type {
AgentPermissionRequest,
@@ -274,11 +276,25 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type ReadProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "read_project_config_response" }
>["payload"];
type WriteProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "write_project_config_response" }
>["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
>;
export interface WriteProjectConfigInput {
repoRoot: string;
config: PaseoConfigRaw;
expectedRevision: PaseoConfigRevision | null;
requestId?: string;
}
interface ListCommandsOptions {
requestId?: string;
draftConfig?: ListCommandsDraftConfig;
@@ -2940,6 +2956,32 @@ export class DaemonClient {
});
}
async readProjectConfig(repoRoot: string, requestId?: string): Promise<ReadProjectConfigPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "read_project_config_request",
repoRoot,
},
responseType: "read_project_config_response",
timeout: 10000,
});
}
async writeProjectConfig(input: WriteProjectConfigInput): Promise<WriteProjectConfigPayload> {
return this.sendCorrelatedSessionRequest({
requestId: input.requestId,
message: {
type: "write_project_config_request",
repoRoot: input.repoRoot,
config: input.config,
expectedRevision: input.expectedRevision,
},
responseType: "write_project_config_response",
timeout: 10000,
});
}
async refreshProvidersSnapshot(options?: {
cwd?: string;
providers?: AgentProvider[];
+391 -3
View File
@@ -1,5 +1,5 @@
import { execSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
import { homedir, tmpdir } from "os";
import { join } from "path";
import pino from "pino";
@@ -200,6 +200,7 @@ function createSessionForTest(options?: {
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
};
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
terminalManager?: unknown;
scriptRouteStore?: unknown;
scriptRuntimeStore?: unknown;
@@ -238,10 +239,21 @@ function createSessionForTest(options?: {
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
paseoHome: "/tmp/paseo-home",
agentManager: {
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
} as unknown as SessionOptions["agentManager"],
agentStorage: {} as unknown as SessionOptions["agentStorage"],
projectRegistry: {} as unknown as SessionOptions["projectRegistry"],
agentStorage: {
list: vi.fn().mockResolvedValue([]),
} as unknown as SessionOptions["agentStorage"],
projectRegistry: (options?.projectRegistry ?? {
list: vi.fn().mockResolvedValue([]),
get: vi.fn(),
upsert: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
initialize: vi.fn(),
existsOnDisk: vi.fn(),
}) as unknown as SessionOptions["projectRegistry"],
workspaceRegistry: (options?.workspaceRegistry ?? {
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
@@ -270,6 +282,242 @@ function createSessionForTest(options?: {
});
}
function createProjectRecord(rootPath: string, archivedAt: string | null = null) {
return {
projectId: `project:${rootPath}`,
rootPath,
kind: "git" as const,
displayName: "Project",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt,
};
}
describe("project config RPC authorization", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeRoot(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "project-config-session-test-")));
tempDirs.push(root);
return root;
}
test("read_project_config_request accepts the same root with a trailing slash", async () => {
const repoRoot = makeRoot();
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "read-trailing-slash-1",
repoRoot: `${repoRoot}/`,
});
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-trailing-slash-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
]);
});
test("read_project_config_request accepts a symlink to an active project root", async () => {
const repoRoot = makeRoot();
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
const linkRoot = join(makeRoot(), "link");
symlinkSync(repoRoot, linkRoot, "dir");
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "read-symlink-1",
repoRoot: linkRoot,
});
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-symlink-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
]);
});
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
const archivedRoot = makeRoot();
const unknownRoot = makeRoot();
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi
.fn()
.mockResolvedValue([createProjectRecord(archivedRoot, "2026-01-02T00:00:00.000Z")]),
},
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "archived-1",
repoRoot: archivedRoot,
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "unknown-1",
repoRoot: unknownRoot,
});
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "archived-1",
repoRoot: archivedRoot,
ok: false,
error: { code: "project_not_found" },
},
},
{
type: "read_project_config_response",
payload: {
requestId: "unknown-1",
repoRoot: unknownRoot,
ok: false,
error: { code: "project_not_found" },
},
},
]);
});
test("read_project_config_request emits raw lifecycle forms for a known project root", async () => {
const repoRoot = makeRoot();
writeFileSync(
join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: "npm install", teardown: ["npm run clean"] } }),
);
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "read-1",
repoRoot,
});
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm install", teardown: ["npm run clean"] } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
]);
});
test("write_project_config_request emits stale and write-failed inline domain failures", async () => {
const staleRoot = makeRoot();
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const writeFailedRoot = join(makeRoot(), "not-a-directory");
writeFileSync(writeFailedRoot, "file");
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi
.fn()
.mockResolvedValue([
createProjectRecord(staleRoot),
createProjectRecord(writeFailedRoot),
]),
},
});
await session.handleMessage({
type: "write_project_config_request",
requestId: "stale-1",
repoRoot: staleRoot,
config: { worktree: { setup: "new" } },
expectedRevision: { mtimeMs: 1, size: 1 },
});
await session.handleMessage({
type: "write_project_config_request",
requestId: "write-failed-1",
repoRoot: writeFailedRoot,
config: { worktree: { setup: "new" } },
expectedRevision: null,
});
expect(messages).toEqual([
{
type: "write_project_config_response",
payload: {
requestId: "stale-1",
repoRoot: staleRoot,
ok: false,
error: {
code: "stale_project_config",
currentRevision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
},
{
type: "write_project_config_response",
payload: {
requestId: "write-failed-1",
repoRoot: writeFailedRoot,
ok: false,
error: { code: "write_failed" },
},
},
]);
});
});
function createWorkspaceGitSnapshot(
cwd: string,
overrides?: {
@@ -1131,6 +1379,146 @@ describe("session checkout status handling", () => {
});
describe("session workspace descriptors", () => {
test("fetch_workspaces_request includes project placement for a GitHub-backed workspace", async () => {
const messages: unknown[] = [];
const workspace = {
workspaceId: "ws-gh",
projectId: "remote:github.com/acme/app",
cwd: "/repo/app",
kind: "local_checkout" as const,
displayName: "app",
archivedAt: null,
};
const project = {
projectId: "remote:github.com/acme/app",
rootPath: "/repo/app",
kind: "git" as const,
displayName: "acme/app",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
};
const session = createSessionForTest({
messages,
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
workspaceGitService: {
getSnapshot: vi.fn(),
peekSnapshot: vi.fn(() =>
createWorkspaceGitSnapshot("/repo/app", {
git: {
remoteUrl: "https://github.com/acme/app.git",
currentBranch: "main",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
}),
),
registerWorkspace: vi.fn(() => () => {}),
},
});
await session.handleMessage({
type: "fetch_workspaces_request",
requestId: "fetch-workspaces-gh",
});
expect(messages).toContainEqual({
type: "fetch_workspaces_response",
payload: expect.objectContaining({
requestId: "fetch-workspaces-gh",
entries: [
expect.objectContaining({
id: "ws-gh",
project: {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "app",
remoteUrl: null,
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
],
}),
});
});
test("fetch_workspaces_request includes repo-root fallback placement for a workspace without remote", async () => {
const messages: unknown[] = [];
const workspace = {
workspaceId: "ws-local",
projectId: "/repo/local",
cwd: "/repo/local",
kind: "local_checkout" as const,
displayName: "local",
archivedAt: null,
};
const project = {
projectId: "/repo/local",
rootPath: "/repo/local",
kind: "git" as const,
displayName: "local",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
};
const session = createSessionForTest({
messages,
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
workspaceGitService: {
getSnapshot: vi.fn(),
peekSnapshot: vi.fn(() =>
createWorkspaceGitSnapshot("/repo/local", {
git: {
remoteUrl: null,
currentBranch: "main",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
}),
),
registerWorkspace: vi.fn(() => () => {}),
},
});
await session.handleMessage({
type: "fetch_workspaces_request",
requestId: "fetch-workspaces-local",
});
expect(messages).toContainEqual({
type: "fetch_workspaces_response",
payload: expect.objectContaining({
requestId: "fetch-workspaces-local",
entries: [
expect.objectContaining({
id: "ws-local",
project: {
projectKey: "/repo/local",
projectName: "local",
checkout: {
cwd: "/repo/local",
isGit: true,
currentBranch: "local",
remoteUrl: null,
worktreeRoot: "/repo/local",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
],
}),
});
});
test("reads descriptor diff stat from the workspace git service snapshot", async () => {
const workspaceGitService = {
getSnapshot: vi.fn(),
+174
View File
@@ -2,6 +2,7 @@ import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { TTLCache } from "@isaacs/ttlcache";
import pMemoize from "p-memoize";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { resolve, sep } from "path";
import { homedir } from "node:os";
@@ -162,6 +163,11 @@ import {
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import { type WorktreeConfig } from "../utils/worktree.js";
import {
readPaseoConfigForEdit,
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../utils/paseo-config-file.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
@@ -216,6 +222,46 @@ import { toWorktreeWireError } from "./worktree-errors.js";
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
interface ResolveKnownProjectRootForConfigInput {
repoRoot: string;
projectRegistry: Pick<ProjectRegistry, "list">;
}
async function resolveKnownProjectRootForConfig(
input: ResolveKnownProjectRootForConfigInput,
): Promise<string | null> {
const requestedRoot = canonicalizeConfigRoot(input.repoRoot);
const projects = await input.projectRegistry.list();
for (const project of projects) {
if (project.archivedAt !== null) {
continue;
}
const projectRoot = canonicalizeConfigRoot(project.rootPath);
if (requestedRoot === projectRoot) {
return projectRoot;
}
}
return null;
}
function canonicalizeConfigRoot(repoRoot: string): string {
const resolved = resolve(repoRoot);
try {
return stripTrailingPathSeparators(realpathSync(resolved));
} catch {
return stripTrailingPathSeparators(resolved);
}
}
function stripTrailingPathSeparators(path: string): string {
let normalized = path;
while (normalized.length > 1 && normalized.endsWith(sep)) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
type GitMutationRefreshReason =
| "commit-changes"
| "pull"
@@ -1846,11 +1892,134 @@ export class Session {
},
});
return undefined;
case "read_project_config_request":
return this.handleReadProjectConfigRequest(msg);
case "write_project_config_request":
return this.handleWriteProjectConfigRequest(msg);
default:
return undefined;
}
}
private async handleReadProjectConfigRequest(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
): Promise<void> {
const repoRoot = await resolveKnownProjectRootForConfig({
repoRoot: msg.repoRoot,
projectRegistry: this.projectRegistry,
});
if (!repoRoot) {
this.emitProjectConfigReadFailure(msg, { code: "project_not_found" });
return;
}
const result = readPaseoConfigForEdit(repoRoot);
if (!result.ok) {
this.sessionLogger.warn(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
"Failed to read project config",
);
this.emitProjectConfigReadFailure(msg, result.error, repoRoot);
return;
}
if (result.config === null) {
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "missing_project_config" },
"Project config missing",
);
}
this.emit({
type: "read_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: true,
config: result.config,
revision: result.revision,
},
});
}
private async handleWriteProjectConfigRequest(
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
): Promise<void> {
const repoRoot = await resolveKnownProjectRootForConfig({
repoRoot: msg.repoRoot,
projectRegistry: this.projectRegistry,
});
if (!repoRoot) {
this.emitProjectConfigWriteFailure(msg, { code: "project_not_found" });
return;
}
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "write_attempt" },
"Writing project config",
);
const result = writePaseoConfigForEdit({
repoRoot,
config: msg.config,
expectedRevision: msg.expectedRevision,
});
if (!result.ok) {
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
"Project config write did not complete",
);
this.emitProjectConfigWriteFailure(msg, result.error, repoRoot);
return;
}
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "written" },
"Project config written",
);
this.emit({
type: "write_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: true,
config: result.config,
revision: result.revision,
},
});
}
private emitProjectConfigReadFailure(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
error: ProjectConfigRpcError,
repoRoot = msg.repoRoot,
): void {
this.emit({
type: "read_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: false,
error,
},
});
}
private emitProjectConfigWriteFailure(
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
error: ProjectConfigRpcError,
repoRoot = msg.repoRoot,
): void {
this.emit({
type: "write_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: false,
error,
},
});
}
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "checkout_status_request":
@@ -5908,6 +6077,11 @@ export class Session {
resolveHealth: this.resolveScriptHealth ?? undefined,
})
: [],
...(resolvedProjectRecord
? {
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
}
: {}),
};
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, test } from "vitest";
import { SessionOutboundMessageSchema } from "./messages.js";
function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
return {
id: "ws-1",
projectId: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
workspaceDirectory: "/repo/app",
projectKind: "git",
workspaceKind: "local_checkout",
name: "app",
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
...overrides,
};
}
function fetchWorkspacesResponse(workspace: Record<string, unknown>) {
return {
type: "fetch_workspaces_response",
payload: {
requestId: "req-1",
entries: [workspace],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
};
}
describe("workspace descriptor message compatibility", () => {
test("old-shaped fetch_workspaces_response without project still parses", () => {
const parsed = SessionOutboundMessageSchema.parse(
fetchWorkspacesResponse(workspaceDescriptor()),
);
expect(parsed.type).toBe("fetch_workspaces_response");
if (parsed.type !== "fetch_workspaces_response") {
throw new Error("Expected fetch_workspaces_response");
}
expect(parsed.payload.entries[0]?.project).toBeUndefined();
});
test("new-shaped fetch_workspaces_response with project placement parses", () => {
const parsed = SessionOutboundMessageSchema.parse(
fetchWorkspacesResponse(
workspaceDescriptor({
project: {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
),
);
expect(parsed.type).toBe("fetch_workspaces_response");
if (parsed.type !== "fetch_workspaces_response") {
throw new Error("Expected fetch_workspaces_response");
}
expect(parsed.payload.entries[0]?.project).toEqual({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
});
test("adding project does not narrow existing descriptor fields", () => {
const parsed = SessionOutboundMessageSchema.parse(
fetchWorkspacesResponse(
workspaceDescriptor({
workspaceDirectory: undefined,
projectKind: "non_git",
workspaceKind: "directory",
gitRuntime: null,
githubRuntime: null,
project: {
projectKey: "/repo/local",
projectName: "local",
checkout: {
cwd: "/repo/local",
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
),
);
expect(parsed.type).toBe("fetch_workspaces_response");
if (parsed.type !== "fetch_workspaces_response") {
throw new Error("Expected fetch_workspaces_response");
}
expect(parsed.payload.entries[0]).toMatchObject({
projectKind: "non_git",
workspaceKind: "directory",
workspaceDirectory: "/repo/app",
gitRuntime: null,
githubRuntime: null,
});
});
});
+79
View File
@@ -47,6 +47,28 @@ import {
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "../server/loop/rpc-schemas.js";
import {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
PaseoConfigRevisionSchema,
ProjectConfigRpcErrorSchema,
type PaseoConfigRaw,
type PaseoConfigRevision,
type PaseoScriptEntryRaw,
type ProjectConfigRpcError,
} from "../utils/paseo-config-schema.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
type PaseoConfigRaw,
type PaseoConfigRevision,
type PaseoScriptEntryRaw,
type ProjectConfigRpcError,
};
// ---------------------------------------------------------------------------
// Mutable daemon config schemas (shared between server store and client)
// ---------------------------------------------------------------------------
@@ -872,6 +894,20 @@ export const SetDaemonConfigRequestMessageSchema = z.object({
config: MutableDaemonConfigPatchSchema,
});
export const ReadProjectConfigRequestMessageSchema = z.object({
type: z.literal("read_project_config_request"),
requestId: z.string(),
repoRoot: z.string(),
});
export const WriteProjectConfigRequestMessageSchema = z.object({
type: z.literal("write_project_config_request"),
requestId: z.string(),
repoRoot: z.string(),
config: PaseoConfigRawSchema,
expectedRevision: PaseoConfigRevisionSchema.nullable(),
});
// ============================================================================
// Dictation Streaming (lossless, resumable)
// ============================================================================
@@ -1601,6 +1637,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WaitForFinishRequestSchema,
GetDaemonConfigRequestMessageSchema,
SetDaemonConfigRequestMessageSchema,
ReadProjectConfigRequestMessageSchema,
WriteProjectConfigRequestMessageSchema,
DictationStreamStartMessageSchema,
DictationStreamChunkMessageSchema,
DictationStreamFinishMessageSchema,
@@ -2110,6 +2148,7 @@ export const WorkspaceDescriptorPayloadSchema = z
scripts: z.array(WorkspaceScriptPayloadSchema).default([]),
gitRuntime: WorkspaceGitRuntimePayloadSchema,
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
project: ProjectPlacementPayloadSchema.optional(),
})
.transform((workspace) => ({
...workspace,
@@ -2407,6 +2446,44 @@ export const SetDaemonConfigResponseMessageSchema = z.object({
.passthrough(),
});
export const ReadProjectConfigResponseMessageSchema = z.object({
type: z.literal("read_project_config_response"),
payload: z.discriminatedUnion("ok", [
z.object({
requestId: z.string(),
repoRoot: z.string(),
ok: z.literal(true),
config: PaseoConfigRawSchema.nullable(),
revision: PaseoConfigRevisionSchema.nullable(),
}),
z.object({
requestId: z.string(),
repoRoot: z.string(),
ok: z.literal(false),
error: ProjectConfigRpcErrorSchema,
}),
]),
});
export const WriteProjectConfigResponseMessageSchema = z.object({
type: z.literal("write_project_config_response"),
payload: z.discriminatedUnion("ok", [
z.object({
requestId: z.string(),
repoRoot: z.string(),
ok: z.literal(true),
config: PaseoConfigRawSchema,
revision: PaseoConfigRevisionSchema,
}),
z.object({
requestId: z.string(),
repoRoot: z.string(),
ok: z.literal(false),
error: ProjectConfigRpcErrorSchema,
}),
]),
});
export const AgentPermissionRequestMessageSchema = z.object({
type: z.literal("agent_permission_request"),
payload: z.object({
@@ -3187,6 +3264,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetVoiceModeResponseMessageSchema,
GetDaemonConfigResponseMessageSchema,
SetDaemonConfigResponseMessageSchema,
ReadProjectConfigResponseMessageSchema,
WriteProjectConfigResponseMessageSchema,
SetAgentModeResponseMessageSchema,
SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema,
@@ -0,0 +1,186 @@
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
import {
readPaseoConfigForEdit,
statPaseoConfigPath,
writePaseoConfigForEdit,
} from "./paseo-config-file.js";
describe("paseo config file substrate", () => {
let tempDir: string;
beforeEach(() => {
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "paseo-config-file-test-")));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("returns null config and revision when paseo.json is missing", () => {
const result = readPaseoConfigForEdit(tempDir);
expect(result).toEqual({ ok: true, config: null, revision: null });
});
it("returns invalid_project_config for invalid JSON", () => {
writeFileSync(join(tempDir, "paseo.json"), "{ invalid json\n");
const result = readPaseoConfigForEdit(tempDir);
expect(result).toEqual({
ok: false,
error: { code: "invalid_project_config" },
});
});
it("preserves raw lifecycle string and array forms with a revision token", () => {
writeFileSync(
join(tempDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: "npm install",
teardown: ["npm run clean", "npm run reset"],
},
}),
);
const result = readPaseoConfigForEdit(tempDir);
expect(result).toEqual({
ok: true,
config: {
worktree: {
setup: "npm install",
teardown: ["npm run clean", "npm run reset"],
},
},
revision: statPaseoConfigPath(tempDir),
});
});
it("keeps runtime lifecycle commands normalized for execution", () => {
writeFileSync(
join(tempDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: "npm install",
teardown: ["npm run clean", "", 42, "npm run reset"],
},
}),
);
expect(getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]);
expect(getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]);
});
it("writes pretty JSON with a trailing newline when revision matches", () => {
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const expectedRevision = statPaseoConfigPath(tempDir);
const result = writePaseoConfigForEdit({
repoRoot: tempDir,
config: { worktree: { setup: "npm install" } },
expectedRevision,
});
expect(result).toEqual({
ok: true,
config: { worktree: { setup: "npm install" } },
revision: statPaseoConfigPath(tempDir),
});
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
'{\n "worktree": {\n "setup": "npm install"\n }\n}\n',
);
});
it("rejects stale writes when the current revision changed before rename", () => {
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const expectedRevision = statPaseoConfigPath(tempDir);
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
const currentRevision = statPaseoConfigPath(tempDir);
const result = writePaseoConfigForEdit({
repoRoot: tempDir,
config: { worktree: { setup: "from editor" } },
expectedRevision,
});
expect(result).toEqual({
ok: false,
error: { code: "stale_project_config", currentRevision },
});
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
JSON.stringify({ worktree: { setup: "new" } }),
);
});
it("round-trips unknown top-level, worktree, and script-entry fields", () => {
const config = {
extraTop: { keep: true },
worktree: {
setup: ["npm install"],
customWorktreeField: "preserve me",
},
scripts: {
dev: {
command: "npm run dev",
type: "service",
customScriptField: 123,
},
},
};
const result = writePaseoConfigForEdit({
repoRoot: tempDir,
config,
expectedRevision: null,
});
expect(result).toEqual({
ok: true,
config,
revision: statPaseoConfigPath(tempDir),
});
expect(readPaseoConfigForEdit(tempDir)).toEqual({
ok: true,
config,
revision: statPaseoConfigPath(tempDir),
});
});
it("returns write_failed for filesystem write exceptions", () => {
const fileRoot = join(tempDir, "not-a-directory");
writeFileSync(fileRoot, "file");
const result = writePaseoConfigForEdit({
repoRoot: fileRoot,
config: { worktree: { setup: "npm install" } },
expectedRevision: null,
});
expect(result).toEqual({
ok: false,
error: { code: "write_failed" },
});
});
it("creates paseo.json when the file is still missing and expected revision is null", () => {
mkdirSync(join(tempDir, "nested"));
const result = writePaseoConfigForEdit({
repoRoot: join(tempDir, "nested"),
config: { scripts: { dev: { command: "npm run dev" } } },
expectedRevision: null,
});
expect(result).toEqual({
ok: true,
config: { scripts: { dev: { command: "npm run dev" } } },
revision: statPaseoConfigPath(join(tempDir, "nested")),
});
});
});
@@ -0,0 +1,129 @@
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import {
PaseoConfigRawSchema,
type PaseoConfigRaw,
type PaseoConfigRevision,
type ProjectConfigRpcError,
} from "./paseo-config-schema.js";
export {
PaseoConfigRevisionSchema,
ProjectConfigRpcErrorSchema,
type PaseoConfigRevision,
type ProjectConfigRpcError,
} from "./paseo-config-schema.js";
export const PASEO_CONFIG_FILE_NAME = "paseo.json";
export type ReadPaseoConfigForEditResult =
| { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null }
| { ok: false; error: ProjectConfigRpcError };
export type WritePaseoConfigForEditResult =
| { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision }
| { ok: false; error: ProjectConfigRpcError };
export interface WritePaseoConfigForEditInput {
repoRoot: string;
config: PaseoConfigRaw;
expectedRevision: PaseoConfigRevision | null;
}
export function resolvePaseoConfigPath(repoRoot: string): string {
return join(repoRoot, PASEO_CONFIG_FILE_NAME);
}
export function statPaseoConfigPath(repoRoot: string): PaseoConfigRevision | null {
const configPath = resolvePaseoConfigPath(repoRoot);
if (!existsSync(configPath)) {
return null;
}
const stats = statSync(configPath);
return {
mtimeMs: stats.mtimeMs,
size: stats.size,
};
}
export function readPaseoConfigJson(repoRoot: string): unknown | null {
const configPath = resolvePaseoConfigPath(repoRoot);
if (!existsSync(configPath)) {
return null;
}
return JSON.parse(readFileSync(configPath, "utf8"));
}
export function readPaseoConfigForEdit(repoRoot: string): ReadPaseoConfigForEditResult {
try {
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return { ok: true, config: null, revision: null };
}
return {
ok: true,
config: PaseoConfigRawSchema.parse(json),
revision: statPaseoConfigPath(repoRoot),
};
} catch {
return {
ok: false,
error: { code: "invalid_project_config" },
};
}
}
export function writePaseoConfigForEdit(
input: WritePaseoConfigForEditInput,
): WritePaseoConfigForEditResult {
const parsed = PaseoConfigRawSchema.safeParse(input.config);
if (!parsed.success) {
return { ok: false, error: { code: "invalid_project_config" } };
}
const configPath = resolvePaseoConfigPath(input.repoRoot);
const tempPath = join(
input.repoRoot,
`.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`,
);
try {
writeFileSync(tempPath, `${JSON.stringify(parsed.data, null, 2)}\n`);
const currentRevision = statPaseoConfigPath(input.repoRoot);
if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) {
removeTempPaseoConfig(tempPath);
return {
ok: false,
error: { code: "stale_project_config", currentRevision },
};
}
renameSync(tempPath, configPath);
const revision = statPaseoConfigPath(input.repoRoot);
if (!revision) {
return { ok: false, error: { code: "write_failed" } };
}
return { ok: true, config: parsed.data, revision };
} catch {
removeTempPaseoConfig(tempPath);
return { ok: false, error: { code: "write_failed" } };
}
}
function paseoConfigRevisionsEqual(
left: PaseoConfigRevision | null,
right: PaseoConfigRevision | null,
): boolean {
if (left === null || right === null) {
return left === right;
}
return left.mtimeMs === right.mtimeMs && left.size === right.size;
}
function removeTempPaseoConfig(tempPath: string): void {
try {
rmSync(tempPath, { force: true });
} catch {
// Best-effort cleanup only; callers need the original write outcome.
}
}
@@ -0,0 +1,75 @@
import { z } from "zod";
export function normalizeLifecycleCommands(commands: unknown): string[] {
if (typeof commands === "string") {
return commands.trim().length > 0 ? [commands] : [];
}
if (!Array.isArray(commands)) {
return [];
}
return commands.filter((command): command is string => {
return typeof command === "string" && command.trim().length > 0;
});
}
export const PaseoLifecycleCommandRawSchema = z.union([z.string(), z.array(z.string())]);
export const PaseoScriptEntryRawSchema = z
.object({
type: z.unknown().optional(),
command: z.unknown().optional(),
port: z.unknown().optional(),
})
.passthrough();
export const PaseoWorktreeConfigRawSchema = z
.object({
setup: PaseoLifecycleCommandRawSchema.optional(),
teardown: PaseoLifecycleCommandRawSchema.optional(),
terminals: z.unknown().optional(),
})
.passthrough();
export const PaseoConfigRawSchema = z
.object({
worktree: PaseoWorktreeConfigRawSchema.optional(),
scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(),
})
.passthrough();
export const WorktreeConfigSchema = PaseoWorktreeConfigRawSchema.extend({
setup: z.unknown().transform(normalizeLifecycleCommands),
teardown: z.unknown().transform(normalizeLifecycleCommands),
})
.passthrough()
.catch({ setup: [], teardown: [] });
export const ScriptEntrySchema = PaseoScriptEntryRawSchema.catch({});
export const PaseoConfigSchema = PaseoConfigRawSchema.extend({
worktree: WorktreeConfigSchema.optional(),
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
})
.passthrough()
.catch({});
export const PaseoConfigRevisionSchema = z.object({
mtimeMs: z.number(),
size: z.number(),
});
export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
z.object({ code: z.literal("project_not_found") }),
z.object({ code: z.literal("invalid_project_config") }),
z.object({
code: z.literal("stale_project_config"),
currentRevision: PaseoConfigRevisionSchema.nullable(),
}),
z.object({ code: z.literal("write_failed") }),
]);
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
export type ProjectConfigRpcError = z.infer<typeof ProjectConfigRpcErrorSchema>;
+18 -51
View File
@@ -1,14 +1,24 @@
import { execFile, spawn } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
import { rm, stat } from "fs/promises";
import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import * as pty from "node-pty";
import stripAnsi from "strip-ansi";
import { z } from "zod";
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import { readPaseoConfigJson } from "./paseo-config-file.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
PaseoConfigSchema,
type PaseoConfig,
type PaseoConfigRaw,
} from "./paseo-config-schema.js";
import { PaseoConfigSchema, type PaseoConfig } from "./paseo-config-schema.js";
import {
normalizeBaseRefName,
readPaseoWorktreeMetadata,
@@ -84,49 +94,6 @@ export interface WorktreeTerminalConfig {
command: string;
}
function normalizeLifecycleCommands(commands: unknown): string[] {
if (typeof commands === "string") {
return commands.trim().length > 0 ? [commands] : [];
}
if (!Array.isArray(commands)) {
return [];
}
return commands.filter((command): command is string => {
return typeof command === "string" && command.trim().length > 0;
});
}
const LifecycleCommandsSchema = z.unknown().transform(normalizeLifecycleCommands);
const WorktreeConfigSchema = z
.object({
setup: LifecycleCommandsSchema,
teardown: LifecycleCommandsSchema,
terminals: z.unknown().optional(),
})
.passthrough()
.catch({ setup: [], teardown: [] });
const ScriptEntrySchema = z
.object({
type: z.unknown(),
command: z.unknown(),
port: z.unknown(),
})
.partial()
.passthrough()
.catch({});
const PaseoConfigSchema = z
.object({
worktree: WorktreeConfigSchema.optional(),
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
})
.passthrough()
.catch({});
type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export interface PlainScriptConfig {
type?: undefined;
command: string;
@@ -229,13 +196,13 @@ export class UnknownBranchError extends Error {
}
}
function readPaseoConfig(repoRoot: string): PaseoConfig | null {
const paseoConfigPath = join(repoRoot, "paseo.json");
if (!existsSync(paseoConfigPath)) {
return null;
}
export function readPaseoConfig(repoRoot: string): PaseoConfig | null {
try {
return PaseoConfigSchema.parse(JSON.parse(readFileSync(paseoConfigPath, "utf8")));
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return null;
}
return PaseoConfigSchema.parse(json);
} catch {
throw new Error(`Failed to parse paseo.json`);
}