diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md new file mode 100644 index 000000000..dd6ec8554 --- /dev/null +++ b/docs/DESIGN-SYSTEM.md @@ -0,0 +1,244 @@ +# DESIGN-SYSTEM.md + +Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`. + +--- + +## 1. Character + +Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar. + +The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_. + +Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong. + +--- + +## 2. Component reuse + +A semantic element used in three or more places is a primitive. One of a kind is a screen. + +Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`. + +A pressable styled to look like a button is wrong; the button is ` + + ); +} + +function BackToProjectsButton() { + return ( + + ); +} + +interface ProjectSettingsBodyProps { + project: ProjectSummary; + hosts: ProjectHostEntry[]; + selectedHost: ProjectHostEntry; + onSelectHost: (serverId: string) => void; + client: DaemonClient; +} + +function ProjectSettingsBody({ + project, + hosts, + selectedHost, + onSelectHost, + client, +}: ProjectSettingsBodyProps) { + const queryKey = useMemo( + () => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const, + [selectedHost.serverId, selectedHost.repoRoot], + ); + + const readQuery = useQuery({ + queryKey, + queryFn: () => client.readProjectConfig(selectedHost.repoRoot), + retry: false, + }); + + const data = readQuery.data; + const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; + const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; + const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; + + const handleReload = useCallback(() => { + void readQuery.refetch(); + }, [readQuery]); + + const hasMultipleHosts = hosts.length > 1; + + return ( + + + + + + + {project.projectName} + + + + + {renderContent({ + readQuery, + loadedConfig, + loadedRevision, + readError, + selectedHost, + queryKey, + client, + onReload: handleReload, + hasMultipleHosts, + })} + + ); +} + +interface RenderContentInput { + readQuery: ReturnType>; + loadedConfig: PaseoConfigRaw | null; + loadedRevision: PaseoConfigRevision | null; + readError: ProjectConfigRpcError | null; + selectedHost: ProjectHostEntry; + queryKey: readonly [string, string, string]; + client: DaemonClient; + onReload: () => void; + hasMultipleHosts: boolean; +} + +function renderContent({ + readQuery, + loadedConfig, + loadedRevision, + readError, + selectedHost, + queryKey, + client, + onReload, + hasMultipleHosts, +}: RenderContentInput) { + if (readQuery.isLoading) { + return ( + + + + ); + } + + if (readQuery.isError) { + return ( + + ); + } + + if (readError) { + return ( + + ); + } + + if (!loadedConfig) { + return ( + + + + ); + } + + const formKey = `${selectedHost.serverId}::${selectedHost.repoRoot}::${revisionToKey(loadedRevision)}`; + return ( + + ); +} + +function revisionToKey(revision: PaseoConfigRevision | null): string { + if (!revision) return "none"; + return `${revision.mtimeMs}-${revision.size}`; +} + +interface ReadFailureCalloutProps { + kind: "transport" | ProjectConfigRpcError["code"]; + onReload: () => void; + hasMultipleHosts: boolean; +} + +function ReadFailureCallout({ kind, onReload, hasMultipleHosts }: ReadFailureCalloutProps) { + const reloadAction = useMemo( + () => [{ label: "Reload", onPress: onReload, variant: "primary" }], + [onReload], + ); + + if (kind === "invalid_project_config") { + return ( + + + + ); + } + + if (kind === "project_not_found") { + return ( + + + + ); + } + + if (kind === "transport") { + return ( + + + + ); + } + + return ( + + + + ); +} + +interface ProjectConfigFormProps { + baseConfig: PaseoConfigRaw; + revision: PaseoConfigRevision | null; + repoRoot: string; + queryKey: readonly [string, string, string]; + client: DaemonClient; + onReload: () => void; +} + +function ProjectConfigForm({ + baseConfig, + revision, + repoRoot, + queryKey, + client, + onReload, +}: ProjectConfigFormProps) { + const queryClient = useQueryClient(); + const toast = useToast(); + + const [draft, setDraft] = useState(() => configToDraft(baseConfig)); + const [writeError, setWriteError] = useState(null); + const [editingScriptId, setEditingScriptId] = useState(null); + + const saveMutation = useMutation({ + mutationFn: async (input: { + config: PaseoConfigRaw; + expectedRevision: PaseoConfigRevision | null; + }) => { + return client.writeProjectConfig({ + repoRoot, + config: input.config, + expectedRevision: input.expectedRevision, + }); + }, + onSuccess: (result) => { + if (result.ok) { + queryClient.setQueryData(queryKey, { + ok: true, + config: result.config, + revision: result.revision, + requestId: "local-cache", + repoRoot, + }); + setWriteError(null); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.show("Project saved", { variant: "success" }); + } else { + setWriteError(result.error); + } + }, + }); + + const handleSave = useCallback(() => { + if (writeError?.code === "stale_project_config") return; + const config = applyDraftToConfig({ draft, base: baseConfig }); + saveMutation.mutate({ config, expectedRevision: revision }); + }, [draft, baseConfig, revision, writeError, saveMutation]); + + const handleReload = useCallback(() => { + setWriteError(null); + onReload(); + }, [onReload]); + + const updateDraft = useCallback((updater: (draft: ProjectConfigDraft) => ProjectConfigDraft) => { + setDraft((prev) => updater(prev)); + }, []); + + const handleSetupChange = useCallback( + (text: string) => updateDraft((d) => ({ ...d, setupText: text })), + [updateDraft], + ); + const handleTeardownChange = useCallback( + (text: string) => updateDraft((d) => ({ ...d, teardownText: text })), + [updateDraft], + ); + + const handleRemoveScript = useCallback( + async (script: ProjectScriptDraft) => { + const ok = await confirmDialog({ + title: "Remove script?", + message: `Remove ${script.name || "this script"}?`, + confirmLabel: "Remove", + cancelLabel: "Cancel", + destructive: true, + }); + if (!ok) return; + updateDraft((d) => ({ + ...d, + scripts: d.scripts.filter((entry) => entry.id !== script.id), + })); + }, + [updateDraft], + ); + + const handleEditScript = useCallback((script: ProjectScriptDraft) => { + setEditingScriptId(script.id); + }, []); + + const handleAddScript = useCallback(() => { + const id = `script-draft-new-${Date.now()}`; + updateDraft((d) => ({ + ...d, + scripts: [ + ...d.scripts, + { + id, + name: "", + commandText: "", + commandOriginalKind: "missing" satisfies LifecycleOriginalKind, + type: "", + portText: "", + rawEntry: {}, + }, + ], + })); + setEditingScriptId(id); + }, [updateDraft]); + + const handleEditingDraftChange = useCallback( + (next: ProjectScriptDraft) => { + updateDraft((d) => ({ + ...d, + scripts: d.scripts.map((entry) => (entry.id === next.id ? next : entry)), + })); + }, + [updateDraft], + ); + + const handleCancelEditing = useCallback(() => { + if (!editingScriptId) { + return; + } + updateDraft((d) => { + const entry = d.scripts.find((row) => row.id === editingScriptId); + if (!entry) return d; + const isEmpty = + entry.name.trim().length === 0 && + entry.commandText.trim().length === 0 && + entry.type.trim().length === 0 && + entry.portText.trim().length === 0; + if (!isEmpty) return d; + return { ...d, scripts: d.scripts.filter((row) => row.id !== editingScriptId) }; + }); + setEditingScriptId(null); + }, [editingScriptId, updateDraft]); + + const handleSaveEditing = useCallback(() => { + setEditingScriptId(null); + }, []); + + const editingScript = draft.scripts.find((entry) => entry.id === editingScriptId); + + const hasInvalidScripts = useMemo( + () => draft.scripts.some((script) => validateScript(script).hasErrors), + [draft.scripts], + ); + + const staleActions = useMemo( + () => [{ label: "Reload", onPress: handleReload, variant: "primary" }], + [handleReload], + ); + const writeFailedActions = useMemo( + () => [ + { label: "Try again", onPress: handleSave, variant: "primary" }, + { label: "Reload", onPress: handleReload, variant: "secondary" }, + ], + [handleSave, handleReload], + ); + + const scriptsTrailing = useMemo( + () => ( + + + + ), + [handleAddScript], + ); + + const isStale = writeError?.code === "stale_project_config"; + const isWriteFailed = writeError?.code === "write_failed"; + const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts; + + return ( + + + + + + + + + + + + + + + + {draft.scripts.length === 0 ? ( + + No scripts yet. + + ) : ( + draft.scripts.map((script, index) => ( + + )) + )} + + + + {isStale ? ( + + + + ) : null} + + {isWriteFailed ? ( + + + + ) : null} + + + + + + {editingScript ? ( + + ) : null} + + ); +} + +function ResolveSpinnerColor(): string { + return styles.spinnerColor.color; +} + +function ProjectTitleIcon({ host, projectName }: { host: ProjectHostEntry; 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 ; + } + return ( + + {initial} + + ); +} + +interface HostContextProps { + hosts: ProjectHostEntry[]; + selectedHost: ProjectHostEntry; + onSelectHost: (serverId: string) => void; +} + +function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) { + if (hosts.length > 1) { + return ; + } + return ( + + + + {selectedHost.serverName} + + + ); +} + +function HostStatusDot({ serverId }: { serverId: string }) { + const { theme } = useUnistyles(); + const snapshot = useHostRuntimeSnapshot(serverId); + const status = snapshot?.connectionStatus ?? "connecting"; + let color: string; + if (status === "online") color = theme.colors.palette.green[400]; + else if (status === "connecting") color = theme.colors.palette.amber[500]; + else color = theme.colors.palette.red[500]; + const dotStyle = useMemo(() => [styles.hostStatusDot, { backgroundColor: color }], [color]); + return ; +} + +interface HostPickerProps { + hosts: ProjectHostEntry[]; + selectedHost: ProjectHostEntry; + onSelectHost: (serverId: string) => void; +} + +function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) { + return ( + + + + + {selectedHost.serverName} + + + + + {hosts.map((host) => ( + + ))} + + + ); +} + +interface HostPickerItemProps { + host: ProjectHostEntry; + isSelected: boolean; + onSelectHost: (serverId: string) => void; +} + +function HostPickerItem({ host, isSelected, onSelectHost }: HostPickerItemProps) { + const handleSelect = useCallback( + () => onSelectHost(host.serverId), + [host.serverId, onSelectHost], + ); + return ( + + {host.serverName} + + ); +} + +interface ScriptRowProps { + script: ProjectScriptDraft; + isFirst: boolean; + onEdit: (script: ProjectScriptDraft) => void; + onRemove: (script: ProjectScriptDraft) => void; +} + +function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) { + const handleEdit = useCallback(() => onEdit(script), [onEdit, script]); + const handleRemove = useCallback(() => onRemove(script), [onRemove, script]); + const rowStyle = isFirst ? styles.scriptRow : styles.scriptRowWithBorder; + + return ( + + + + {script.name || "Untitled script"} + + + {scriptHint(script)} + + + + + + + + + Edit + + + Remove + + + + + ); +} + +function scriptHint(script: ProjectScriptDraft): string { + const pieces: string[] = []; + if (script.type) pieces.push(script.type); + if (script.portText) pieces.push(`port ${script.portText}`); + if (script.commandText) pieces.push(script.commandText.split("\n")[0] ?? ""); + return pieces.join(" · "); +} + +interface ScriptValidation { + hasErrors: boolean; + nameError: string | null; + commandError: string | null; +} + +function validateScript(script: ProjectScriptDraft): ScriptValidation { + const nameError = script.name.trim().length === 0 ? "Name is required" : null; + const commandError = script.commandText.trim().length === 0 ? "Command is required" : null; + return { + hasErrors: Boolean(nameError || commandError), + nameError, + commandError, + }; +} + +interface ScriptEditModalProps { + script: ProjectScriptDraft; + onChange: (next: ProjectScriptDraft) => void; + onCancel: () => void; + onSave: () => void; +} + +interface ScriptFieldsTouched { + name: boolean; + command: boolean; +} + +const ALL_TOUCHED: ScriptFieldsTouched = { name: true, command: true }; +const NONE_TOUCHED: ScriptFieldsTouched = { name: false, command: false }; + +function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModalProps) { + const [touched, setTouched] = useState(NONE_TOUCHED); + + useEffect(() => { + setTouched(NONE_TOUCHED); + }, [script.id]); + + const markTouched = useCallback((field: keyof ScriptFieldsTouched) => { + setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true })); + }, []); + + const handleNameChange = useCallback( + (text: string) => onChange({ ...script, name: text }), + [onChange, script], + ); + const handleCommandChange = useCallback( + (text: string) => onChange({ ...script, commandText: text }), + [onChange, script], + ); + const handleServiceToggle = useCallback( + (next: boolean) => onChange({ ...script, type: next ? SCRIPT_SERVICE_TYPE : "" }), + [onChange, script], + ); + + const handleNameBlur = useCallback(() => markTouched("name"), [markTouched]); + const handleCommandBlur = useCallback(() => markTouched("command"), [markTouched]); + + const validation = validateScript(script); + + const handleSavePress = useCallback(() => { + if (validation.hasErrors) { + setTouched(ALL_TOUCHED); + return; + } + onSave(); + }, [validation.hasErrors, onSave]); + + const showNameError = touched.name && validation.nameError; + const showCommandError = touched.command && validation.commandError; + const isService = script.type === SCRIPT_SERVICE_TYPE; + + return ( + + + Name + + {showNameError ? ( + + {validation.nameError} + + ) : null} + + + Command + + {showCommandError ? ( + + {validation.commandError} + + ) : null} + + + + + Run as a service + + Paseo supervises the process and assigns a port via $PASEO_PORT + + + + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + noTargetContainer: { + padding: theme.spacing[4], + alignItems: "flex-start", + gap: theme.spacing[3], + }, + noTargetText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + body: { + padding: theme.spacing[4], + gap: theme.spacing[2], + }, + backButton: { + alignSelf: "flex-start", + paddingHorizontal: 0, + }, + headerBlock: { + marginTop: theme.spacing[2], + marginBottom: theme.spacing[4], + gap: theme.spacing[2], + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + projectTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.medium, + flexShrink: 1, + }, + titleIcon: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + }, + titleIconFallback: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + alignItems: "center", + justifyContent: "center", + }, + titleIconFallbackText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + iconColor: { + color: theme.colors.foregroundMuted, + }, + hostIndicator: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.lg, + alignSelf: "flex-start", + minWidth: 0, + }, + hostStatusDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + }, + hostName: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + flexShrink: 1, + minWidth: 0, + }, + centered: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[6], + }, + errorBlock: { + padding: theme.spacing[4], + gap: theme.spacing[3], + }, + lifecycleInput: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + minHeight: 96, + textAlignVertical: "top", + }, + emptyScripts: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + scriptRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + }, + scriptRowWithBorder: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + scriptRowMain: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], + }, + scriptKebab: { + padding: theme.spacing[1], + }, + calloutWrap: { + marginTop: theme.spacing[3], + }, + footer: { + marginTop: theme.spacing[4], + flexDirection: "row", + justifyContent: "flex-end", + }, + modalSection: { + gap: theme.spacing[2], + }, + modalLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + modalInput: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + }, + modalMultilineInput: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + minHeight: 100, + textAlignVertical: "top", + }, + modalFooter: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + marginTop: theme.spacing[2], + }, + fieldError: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + }, + serviceToggleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + serviceToggleText: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], + }, + serviceToggleLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + modalHint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + placeholderColor: { + color: theme.colors.foregroundMuted, + }, + chevronColor: { + color: theme.colors.foregroundMuted, + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/projects-screen.test.tsx b/packages/app/src/screens/projects-screen.test.tsx new file mode 100644 index 000000000..4b3a40afb --- /dev/null +++ b/packages/app/src/screens/projects-screen.test.tsx @@ -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) => { + const dataAttrs: Record = {}; + 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 { + return { + id: "ws-1", + name: "main", + workspaceKind: "directory", + status: "done", + currentBranch: "main", + ...overrides, + }; +} + +function hostEntry(overrides: Partial = {}): ProjectHostEntry { + return { + serverId: "host-a", + serverName: "alpha", + isOnline: true, + repoRoot: "/home/me/proj", + workspaceCount: 1, + workspaces: [workspaceSummary()], + ...overrides, + }; +} + +function project(overrides: Partial = {}): 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) { + 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(`[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(); + }); + } + + 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"); + }); +}); diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx new file mode 100644 index 000000000..bda34c562 --- /dev/null +++ b/packages/app/src/screens/projects-screen.tsx @@ -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 ( + + + + ); + } + + if (projects.length === 0) { + const message = + hiddenUnsupportedRemoteCount > 0 + ? "Non-GitHub remote projects aren't supported yet" + : "No projects yet"; + return ( + + {message} + + ); + } + + return ( + + {hostErrors.length > 0 ? : null} + + {projects.map((project, index) => ( + + ))} + + + ); +} + +function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) { + return ( + + {errors.map((error) => ( + + {`Couldn't load projects from host ${error.serverName}: ${error.message}`} + + ))} + + ); +} + +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 ( + + + + + + + {projectName} + + + + + ); +} + +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 ; + } + return ( + + {initial} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 2f6bddcb7..7f5980888 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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 ( + + + + Projects + + + ); +} + 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} /> ))} + @@ -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 ; } + if (view.kind === "projects") { + return ; + } + if (view.kind === "project") { + return ; + } 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 ( {content} @@ -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) => ({ diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 0a576be82..8ea73dbcf 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -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", () => { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 6cd5a3543..4cc08d49e 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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, }; } diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 3b925badc..02712d2f4 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 02f2f5042..e3af3a51c 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -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) { diff --git a/packages/app/src/utils/project-config-form.test.ts b/packages/app/src/utils/project-config-form.test.ts new file mode 100644 index 000000000..b43a4c210 --- /dev/null +++ b/packages/app/src/utils/project-config-form.test.ts @@ -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).customTopLevel).toBe("preserved"); + expect((next.worktree as unknown as Record).customWorktreeField).toBe("keep"); + expect((next.worktree as unknown as Record).terminals).toEqual([ + { name: "dev", command: "npm run dev" }, + ]); + const devEntry = (next.scripts ?? {}).dev as unknown as Record; + 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; + 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; + expect(buildEntry.command).toEqual(["npm", "run", "build"]); + expect(buildEntry.customBuildField).toEqual({ nested: 1 }); + + const lintEntry = scripts.lint as unknown as Record; + 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; + 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; + const tunnel = (next.scripts ?? {}).tunnel as unknown as Record; + 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"]); + }); +}); diff --git a/packages/app/src/utils/project-config-form.ts b/packages/app/src/utils/project-config-form.ts new file mode 100644 index 000000000..a8b13c8e0 --- /dev/null +++ b/packages/app/src/utils/project-config-form.ts @@ -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 = { ...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 = {}; + for (const row of input.draft.scripts) { + const trimmedName = row.name.trim(); + if (trimmedName.length === 0) { + continue; + } + const baseEntry = row.rawEntry; + const nextEntry: Record = { ...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 = { ...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; +} diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts new file mode 100644 index 000000000..7500fe04c --- /dev/null +++ b/packages/app/src/utils/projects.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts new file mode 100644 index 000000000..7cd9bde3d --- /dev/null +++ b/packages/app/src/utils/projects.ts @@ -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; +} + +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(); + 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, + }; +} diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 1f405af0f..7554877bb 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -13,6 +13,7 @@ function workspace(overrides: Partial = {}): SidebarWorks workspaceKey: "srv:ws-root", serverId: "srv", workspaceId: "ws-root", + projectKey: "project-1", workspaceDirectory: "/repo", projectKind: "git", workspaceKind: "checkout", diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 19e6d3464..cdad6e862 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -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", diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 5f80c0553..ec5a96a1f 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -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(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index d27a55cdc..90f88a93f 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -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 { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "read_project_config_request", + repoRoot, + }, + responseType: "read_project_config_response", + timeout: 10000, + }); + } + + async writeProjectConfig(input: WriteProjectConfigInput): Promise { + 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[]; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 8597f6d01..3f20388e1 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -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; }; workspaceRegistry?: { get: ReturnType }; + projectRegistry?: Partial; 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(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7682ce759..9823e9147 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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; +} + +async function resolveKnownProjectRootForConfig( + input: ResolveKnownProjectRootForConfigInput, +): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + 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, + 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 | 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), + } + : {}), }; } diff --git a/packages/server/src/shared/messages.test.ts b/packages/server/src/shared/messages.test.ts new file mode 100644 index 000000000..d9371d403 --- /dev/null +++ b/packages/server/src/shared/messages.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "vitest"; +import { SessionOutboundMessageSchema } from "./messages.js"; + +function workspaceDescriptor(overrides: Record = {}) { + 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) { + 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, + }); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 7b735eb36..19c480b4c 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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, diff --git a/packages/server/src/utils/paseo-config-file.test.ts b/packages/server/src/utils/paseo-config-file.test.ts new file mode 100644 index 000000000..c12d6f789 --- /dev/null +++ b/packages/server/src/utils/paseo-config-file.test.ts @@ -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")), + }); + }); +}); diff --git a/packages/server/src/utils/paseo-config-file.ts b/packages/server/src/utils/paseo-config-file.ts new file mode 100644 index 000000000..ac033804a --- /dev/null +++ b/packages/server/src/utils/paseo-config-file.ts @@ -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. + } +} diff --git a/packages/server/src/utils/paseo-config-schema.ts b/packages/server/src/utils/paseo-config-schema.ts new file mode 100644 index 000000000..eb8b0b3ca --- /dev/null +++ b/packages/server/src/utils/paseo-config-schema.ts @@ -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; +export type PaseoConfigRaw = z.infer; +export type PaseoConfig = z.infer; +export type PaseoConfigRevision = z.infer; +export type ProjectConfigRpcError = z.infer; diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 54c1aa7c5..f81c6becb 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -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; - 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`); }