Merge branch 'research/paseo-json-rpc'
This commit is contained in:
@@ -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 `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hierarchy
|
||||
|
||||
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
|
||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the callout actions (`packages/app/src/components/callout-card.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<CalloutCard>` titles (`packages/app/src/components/callout-card.tsx:175-180`).
|
||||
|
||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Buttons
|
||||
|
||||
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
|
||||
|
||||
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
|
||||
|
||||
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
|
||||
|
||||
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
|
||||
|
||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Sizes: `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Borders
|
||||
|
||||
Borders group, separate, or rarely emphasize.
|
||||
|
||||
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
|
||||
|
||||
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
|
||||
|
||||
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
|
||||
|
||||
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
|
||||
|
||||
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pickers
|
||||
|
||||
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
|
||||
|
||||
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
|
||||
|
||||
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
|
||||
|
||||
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
|
||||
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
|
||||
|
||||
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Density and rhythm
|
||||
|
||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx:1056-1062`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
|
||||
|
||||
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
|
||||
|
||||
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
---
|
||||
|
||||
## 8. Responsiveness
|
||||
|
||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||
|
||||
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
|
||||
|
||||
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
|
||||
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
|
||||
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
|
||||
## 9. Copy and voice
|
||||
|
||||
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
|
||||
|
||||
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
|
||||
|
||||
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
|
||||
|
||||
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
|
||||
|
||||
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
|
||||
|
||||
Terminology:
|
||||
|
||||
- Workspace, never "checkout".
|
||||
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
|
||||
- Project, not "repo" or "repository".
|
||||
- Provider, not "model provider".
|
||||
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
|
||||
|
||||
---
|
||||
|
||||
## 10. States
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
|
||||
Actionable errors are `<CalloutCard variant="error" actions={[...]} />` when the user can act on them from the page. The daemon-version-mismatch callout is the canonical use (`packages/app/src/components/daemon-version-mismatch-callout-source.tsx`).
|
||||
|
||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` for failures that interrupt the flow and have no place on the page.
|
||||
|
||||
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
|
||||
|
||||
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
|
||||
|
||||
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
|
||||
|
||||
---
|
||||
|
||||
## 11. List rows
|
||||
|
||||
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
||||
|
||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
|
||||
|
||||
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
|
||||
|
||||
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
|
||||
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Status pills and badges
|
||||
|
||||
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
|
||||
|
||||
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
||||
|
||||
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Forbidden
|
||||
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<CalloutCard>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
---
|
||||
|
||||
## 14. Canonical surfaces by pattern
|
||||
|
||||
| Pattern | Reference |
|
||||
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
||||
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
|
||||
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
|
||||
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
|
||||
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
|
||||
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
|
||||
| Page-level callout with action | `packages/app/src/components/callout-card.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx` |
|
||||
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
|
||||
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
|
||||
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
|
||||
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
|
||||
@@ -0,0 +1,45 @@
|
||||
# Paseo Glossary
|
||||
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add a project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:17`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:88`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2128`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:9`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:597`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` (`packages/server/src/shared/messages.ts:1886`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2063`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` (`packages/server/src/shared/messages.ts:2027`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2042`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:193`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:182`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:249`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:736`).
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:576`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
## Open question — per-host project entry (TBD)
|
||||
|
||||
A project aggregates workspaces across daemons. The projects screen and project-settings screen need a name for "one row in the project list per (project, daemon)". `ProjectPlacementPayload` is **per-workspace**, so it isn't this thing — but the noun "placement" could naturally extend (`ProjectDaemonPlacement`, or just "placements grouped by daemon"). The in-progress `ProjectCheckout` type (`packages/app/src/utils/projects.ts:4`) is also per-workspace today, even though the settings UI selector treats it per-daemon (matched on `serverId` alone) — so the code is currently inconsistent with itself.
|
||||
|
||||
Candidates: (1) extend "placement" to the (project, daemon) bundle, (2) drop the wrapper type and use `{ host, project, workspaces[] }` plus descriptive UI copy ("project · host X · 2 online"). Recommendation: option (2) — no new noun, use existing terms compositionally. Do not introduce "Checkout" / `ProjectCheckout` as the canonical name.
|
||||
|
||||
## Rename before landing (in-progress `Checkout*` references)
|
||||
|
||||
- `packages/app/src/utils/projects.ts:4,20,22,23,46,85,104,120,124,126,146,148,155` — `ProjectCheckout`, `checkouts`, `checkoutCount`, `onlineCheckoutCount`, `buildCheckoutTarget`, `compareCheckouts`.
|
||||
- `packages/app/src/screens/projects-screen.tsx:73,74,77,90` — `checkoutLabel`, `${checkoutCount} checkout(s)`, `onlineSuffix`.
|
||||
- `packages/app/src/screens/project-settings-screen.tsx:17,41,46,55,76,81,110-152,323,443-600` — `ProjectCheckout` import, `CheckoutSelection`, `CheckoutSelector`, `CheckoutOption`, `usableCheckouts`, `onlineUsableCheckouts`, `selectedCheckout`, `selectedCheckoutKey`, "no online checkout", "no checkout with a valid server", `testID="checkout-selector"`, `testID="checkout-option-*"`, a11y `"Edit X checkout"`.
|
||||
- `packages/app/src/screens/project-settings-screen.test.tsx:185,195,196,366,369,378,389,412,415,431,432,456,464,478,481,490,501,504,521` — `checkouts`, `checkoutCount`, `onlineCheckoutCount`, "no checkouts are online", "checkout selector", "online checkout".
|
||||
- `packages/app/src/utils/projects.test.ts:107,108,111,259` — `checkoutCount`, `onlineCheckoutCount`, `checkouts`.
|
||||
|
||||
(Out of scope for this rename: `ProjectCheckoutLite*Payload` and the git-`checkout` family in `packages/server/src/utils/checkout-*.ts`. Those refer to _git_ checkout state, not the rejected (project, daemon) sense.)
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/commands/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2137`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:9`).
|
||||
- In-progress `ProjectCheckout` (`packages/app/src/utils/projects.ts:4`) is per-workspace, but `project-settings-screen.tsx` selects by `serverId` alone — same daemon with multiple workspaces will produce duplicate React keys in the selector.
|
||||
@@ -0,0 +1,119 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
|
||||
interface ProjectsSettingsProject {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ProjectsSettingsFixtures {
|
||||
editableProject: ProjectsSettingsProject;
|
||||
}
|
||||
|
||||
const initialPaseoConfig = {
|
||||
worktree: {
|
||||
setup: ["echo initial setup"],
|
||||
teardown: "echo cleanup",
|
||||
customWorktreeField: "preserved",
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
command: "npm run dev",
|
||||
type: "server",
|
||||
port: 3000,
|
||||
customScriptField: "preserved",
|
||||
},
|
||||
},
|
||||
customTopLevelField: "preserved",
|
||||
};
|
||||
|
||||
const test = base.extend<ProjectsSettingsFixtures>({
|
||||
editableProject: async ({ page: _page }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("projects-settings-", {
|
||||
paseoConfig: initialPaseoConfig,
|
||||
});
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await provide({
|
||||
name: openedProject.projectDisplayName,
|
||||
path: repo.path,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Projects", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/projects$/);
|
||||
}
|
||||
|
||||
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `${projectName} project details` }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Worktree setup" }).fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
async function saveProjectConfig(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const contents = await readProjectConfigFile(project);
|
||||
return JSON.parse(contents) as unknown;
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
)
|
||||
.toMatchObject({
|
||||
worktree: {
|
||||
setup: updatedSetup,
|
||||
teardown: initialPaseoConfig.worktree.teardown,
|
||||
customWorktreeField: initialPaseoConfig.worktree.customWorktreeField,
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
command: initialPaseoConfig.scripts.dev.command,
|
||||
type: initialPaseoConfig.scripts.dev.type,
|
||||
port: initialPaseoConfig.scripts.dev.port,
|
||||
customScriptField: initialPaseoConfig.scripts.dev.customScriptField,
|
||||
},
|
||||
},
|
||||
customTopLevelField: initialPaseoConfig.customTopLevelField,
|
||||
});
|
||||
|
||||
const savedConfig = await readProjectConfigFile(project);
|
||||
expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<string> {
|
||||
return readFile(path.join(project.path, "paseo.json"), "utf8");
|
||||
}
|
||||
|
||||
test.describe("Projects settings", () => {
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
});
|
||||
});
|
||||
@@ -830,6 +830,8 @@ function RootStack() {
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings/index" />
|
||||
<Stack.Screen name="settings/[section]" />
|
||||
<Stack.Screen name="settings/projects/index" />
|
||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
{/*
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default function SettingsProjectDetailRoute() {
|
||||
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
|
||||
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
|
||||
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
|
||||
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
|
||||
|
||||
return <SettingsScreen view={view} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
const PROJECTS_VIEW = { kind: "projects" as const };
|
||||
|
||||
export default function SettingsProjectsIndexRoute() {
|
||||
return <SettingsScreen view={PROJECTS_VIEW} />;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Settings,
|
||||
SquareTerminal,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
@@ -53,6 +54,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import {
|
||||
buildHostNewWorkspaceRoute,
|
||||
buildProjectSettingsRoute,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
@@ -558,6 +560,10 @@ function ProjectKebabMenu({
|
||||
() => <Trash2 size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const settingsLeadingIcon = useMemo(
|
||||
() => <Settings size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const renderTriggerIcon = useCallback(
|
||||
({ hovered }: { hovered?: boolean }) => (
|
||||
<MoreVertical
|
||||
@@ -567,6 +573,11 @@ function ProjectKebabMenu({
|
||||
),
|
||||
[theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
const handleOpenProjectSettings = useCallback(() => {
|
||||
if (projectKey.trim().length === 0) return;
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
const canOpenProjectSettings = projectKey.trim().length > 0;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -579,6 +590,15 @@ function ProjectKebabMenu({
|
||||
{renderTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
{canOpenProjectSettings ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-open-settings-${projectKey}`}
|
||||
leading={settingsLeadingIcon}
|
||||
onSelect={handleOpenProjectSettings}
|
||||
>
|
||||
Open project settings
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={removeProjectLeadingIcon}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import type {
|
||||
PressableProps,
|
||||
PressableStateCallbackType,
|
||||
@@ -104,6 +104,7 @@ export function Button({
|
||||
style,
|
||||
textStyle,
|
||||
disabled,
|
||||
loading = false,
|
||||
accessibilityRole,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
@@ -113,10 +114,12 @@ export function Button({
|
||||
leftIcon?: LeftIcon;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
loading?: boolean;
|
||||
}
|
||||
>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { theme } = useUnistyles();
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
let variantStyle: ViewStyle;
|
||||
if (variant === "default") {
|
||||
@@ -150,10 +153,10 @@ export function Button({
|
||||
sizeStyle,
|
||||
variantStyle,
|
||||
pressed ? styles.pressed : null,
|
||||
disabled ? styles.disabled : null,
|
||||
isDisabled ? styles.disabled : null,
|
||||
style,
|
||||
],
|
||||
[sizeStyle, variantStyle, disabled, style],
|
||||
[sizeStyle, variantStyle, isDisabled, style],
|
||||
);
|
||||
|
||||
const resolvedTextStyle = useMemo(
|
||||
@@ -168,7 +171,30 @@ export function Button({
|
||||
[variant, textStyle, isGhostHovered],
|
||||
);
|
||||
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ disabled: isDisabled, busy: loading }),
|
||||
[isDisabled, loading],
|
||||
);
|
||||
|
||||
function resolveIconColor(): string {
|
||||
if (variant === "default") {
|
||||
return theme.colors.accentForeground;
|
||||
}
|
||||
if (variant === "ghost") {
|
||||
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
}
|
||||
return theme.colors.foreground;
|
||||
}
|
||||
|
||||
function renderIcon() {
|
||||
if (loading) {
|
||||
return (
|
||||
<View>
|
||||
<ActivityIndicator size="small" color={resolveIconColor()} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!leftIcon) return null;
|
||||
|
||||
// Pre-rendered element — pass through
|
||||
@@ -176,14 +202,7 @@ export function Button({
|
||||
return <View>{leftIcon}</View>;
|
||||
}
|
||||
|
||||
let color: string;
|
||||
if (variant === "default") {
|
||||
color = theme.colors.accentForeground;
|
||||
} else if (variant === "ghost") {
|
||||
color = isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
} else {
|
||||
color = theme.colors.foreground;
|
||||
}
|
||||
const color = resolveIconColor();
|
||||
const iconSize = ICON_SIZE[size];
|
||||
|
||||
// Render function
|
||||
@@ -208,7 +227,8 @@ export function Button({
|
||||
<Pressable
|
||||
{...props}
|
||||
accessibilityRole={accessibilityRole ?? "button"}
|
||||
disabled={disabled}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={isDisabled}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={pressableStyle}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import { useProjects } from "./use-projects";
|
||||
|
||||
interface MockDaemonClient {
|
||||
fetchWorkspaces: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const { hostState, clientsByServerId, runtimeStore } = vi.hoisted(() => {
|
||||
const state = {
|
||||
hosts: [
|
||||
{ serverId: "local", label: "Local" },
|
||||
{ serverId: "laptop", label: "Laptop" },
|
||||
],
|
||||
snapshots: new Map<string, { connectionStatus: string }>(),
|
||||
};
|
||||
const clients = new Map<string, { fetchWorkspaces: ReturnType<typeof vi.fn> }>();
|
||||
return {
|
||||
hostState: state,
|
||||
clientsByServerId: clients,
|
||||
runtimeStore: {
|
||||
getClient: (serverId: string) => clients.get(serverId) ?? null,
|
||||
getSnapshot: (serverId: string) => state.snapshots.get(serverId) ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => runtimeStore,
|
||||
useHosts: () => hostState.hosts,
|
||||
}));
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderProjectsHook() {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(() => useProjects(), { wrapper });
|
||||
}
|
||||
|
||||
function makeClient(entries: WorkspaceDescriptorPayload[] | Error): MockDaemonClient {
|
||||
return {
|
||||
fetchWorkspaces: vi.fn(async () => {
|
||||
if (entries instanceof Error) {
|
||||
throw entries;
|
||||
}
|
||||
return {
|
||||
requestId: "req-workspaces",
|
||||
entries,
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
}): WorkspaceDescriptorPayload {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectKey,
|
||||
projectDisplayName: input.projectName,
|
||||
projectRootPath: input.cwd,
|
||||
workspaceDirectory: input.cwd,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: {
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
},
|
||||
githubRuntime: null,
|
||||
project: {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName,
|
||||
checkout: {
|
||||
cwd: input.cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
worktreeRoot: input.cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clientsByServerId.clear();
|
||||
hostState.hosts = [
|
||||
{ serverId: "local", label: "Local" },
|
||||
{ serverId: "laptop", label: "Laptop" },
|
||||
];
|
||||
hostState.snapshots = new Map<string, { connectionStatus: string }>();
|
||||
});
|
||||
|
||||
describe("useProjects", () => {
|
||||
it("calls all known daemon clients and returns aggregated projects sorted by display name", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "z-main",
|
||||
projectKey: "remote:github.com/acme/zeta",
|
||||
projectName: "acme/zeta",
|
||||
cwd: "/repo/zeta",
|
||||
remoteUrl: "https://github.com/acme/zeta.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
clientsByServerId.set(
|
||||
"laptop",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "a-main",
|
||||
projectKey: "remote:github.com/acme/alpha",
|
||||
projectName: "acme/alpha",
|
||||
cwd: "/repo/alpha",
|
||||
remoteUrl: "https://github.com/acme/alpha.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(clientsByServerId.get("local")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(clientsByServerId.get("laptop")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.projects.map((project) => project.projectName)).toEqual([
|
||||
"acme/alpha",
|
||||
"acme/zeta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces per-host fetch failures without dropping successful hosts", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
clientsByServerId.set("laptop", makeClient(new Error("laptop unavailable")));
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(result.current.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
expect(result.current.hostErrors).toEqual([
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
message: "laptop unavailable",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips disconnected hosts silently without surfacing them as failures", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "offline" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(result.current.hostErrors).toEqual([]);
|
||||
expect(result.current.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only the stable public project and host entry shapes", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
"hiddenUnsupportedRemoteCount",
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
"projectKey",
|
||||
"projectName",
|
||||
"totalWorkspaceCount",
|
||||
]);
|
||||
expect(Object.keys(result.current.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
|
||||
"gitRuntime",
|
||||
"githubRuntime",
|
||||
"isOnline",
|
||||
"repoRoot",
|
||||
"serverId",
|
||||
"serverName",
|
||||
"workspaceCount",
|
||||
"workspaces",
|
||||
]);
|
||||
expect(Object.keys(result.current.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
|
||||
"currentBranch",
|
||||
"id",
|
||||
"name",
|
||||
"status",
|
||||
"workspaceKind",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||
|
||||
export const projectsQueryKey = ["projects"] as const;
|
||||
|
||||
export interface ProjectHostError {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UseProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
hostErrors: ProjectHostError[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface FetchAllWorkspaceDescriptorsInput {
|
||||
client: Pick<DaemonClient, "fetchWorkspaces">;
|
||||
}
|
||||
|
||||
interface HostWorkspacesResult {
|
||||
host: ProjectHost;
|
||||
error: ProjectHostError | null;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function fetchAllWorkspaceDescriptors(
|
||||
input: FetchAllWorkspaceDescriptorsInput,
|
||||
): Promise<WorkspaceDescriptor[]> {
|
||||
const entries: WorkspaceDescriptor[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
while (true) {
|
||||
const payload = await input.client.fetchWorkspaces({
|
||||
sort: [{ key: "name", direction: "asc" }],
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = payload.pageInfo.nextCursor;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function useProjects(): UseProjectsResult {
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const hostInputs = useMemo(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
})),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryFn: async () => {
|
||||
const results = await Promise.all(
|
||||
hostInputs.map(async (host): Promise<HostWorkspacesResult> => {
|
||||
const snapshot = runtime.getSnapshot(host.serverId);
|
||||
const isOnline = snapshot?.connectionStatus === "online";
|
||||
const client = runtime.getClient(host.serverId);
|
||||
|
||||
if (!client || !isOnline) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: await fetchAllWorkspaceDescriptors({ client }),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
message: toErrorMessage(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
|
||||
return {
|
||||
...buildProjects({ hosts: results.map((result) => result.host) }),
|
||||
hostErrors,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
projects: projectsQuery.data?.projects ?? [],
|
||||
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
|
||||
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
||||
isLoading: projectsQuery.isLoading,
|
||||
isFetching: projectsQuery.isFetching,
|
||||
refetch: () => {
|
||||
void projectsQuery.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectRootPath?: string;
|
||||
workspaceDirectory?: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
@@ -57,6 +58,7 @@ function createStructuralWorkspaceEntry(input: {
|
||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
projectKey: input.project.projectKey,
|
||||
projectRootPath: input.project.iconWorkingDir,
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: input.project.projectKind,
|
||||
@@ -77,6 +79,7 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
|
||||
projectRootPath: input.workspace.projectRootPath,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
projectKind: input.workspace.projectKind,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects";
|
||||
import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects";
|
||||
|
||||
const { theme, projectsState, navigate } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||
iconSize: { sm: 14, md: 20 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400" as const, medium: "500" as const },
|
||||
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
surface3: "#333",
|
||||
surfaceSidebarHover: "#1a1a1a",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#444",
|
||||
accent: "#0a84ff",
|
||||
palette: { red: { 300: "#ff6b6b" } },
|
||||
},
|
||||
},
|
||||
projectsState: {
|
||||
current: {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as UseProjectsResult,
|
||||
},
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => {
|
||||
const passthrough = ({
|
||||
children,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
accessibilityRole,
|
||||
onPress,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
...rest
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
accessibilityRole?: string;
|
||||
onPress?: (event: { stopPropagation: () => void }) => void;
|
||||
onHoverIn?: () => void;
|
||||
onHoverOut?: () => void;
|
||||
} & Record<string, unknown>) => {
|
||||
const dataAttrs: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (key.startsWith("data-")) {
|
||||
dataAttrs[key] = value;
|
||||
}
|
||||
}
|
||||
return React.createElement(
|
||||
"div",
|
||||
{
|
||||
role: accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
onClick: onPress
|
||||
? (event: React.MouseEvent) => {
|
||||
onPress({ stopPropagation: () => event.stopPropagation() });
|
||||
}
|
||||
: undefined,
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
...dataAttrs,
|
||||
},
|
||||
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||
React.createElement("div", { "data-testid": testID }, children),
|
||||
Text: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("span", null, children),
|
||||
Pressable: passthrough,
|
||||
Image: ({ source }: { source?: { uri?: string } }) =>
|
||||
React.createElement("img", { src: source?.uri ?? "" }),
|
||||
Platform: { OS: "web" },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => {
|
||||
const Icon = () => React.createElement("span", { "data-icon": name });
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
ChevronRight: icon("ChevronRight"),
|
||||
MoreVertical: icon("MoreVertical"),
|
||||
ExternalLink: icon("ExternalLink"),
|
||||
Pencil: icon("Pencil"),
|
||||
FolderGit2: icon("FolderGit2"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: { navigate },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: ({ size }: { size?: string | number }) =>
|
||||
React.createElement("span", {
|
||||
"data-testid": "projects-loading-spinner",
|
||||
"data-size": size,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "dropdown-menu" }, children),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
||||
},
|
||||
typeof children === "function"
|
||||
? children({ pressed: false, hovered: false, open: false })
|
||||
: children,
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "dropdown-menu-content" }, children),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": testID,
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
},
|
||||
},
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-projects", () => ({
|
||||
useProjects: () => projectsState.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-project-icon-query", () => ({
|
||||
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
|
||||
}));
|
||||
|
||||
import ProjectsScreen from "./projects-screen";
|
||||
|
||||
function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceSummary {
|
||||
return {
|
||||
id: "ws-1",
|
||||
name: "main",
|
||||
workspaceKind: "directory",
|
||||
status: "done",
|
||||
currentBranch: "main",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
|
||||
return {
|
||||
serverId: "host-a",
|
||||
serverName: "alpha",
|
||||
isOnline: true,
|
||||
repoRoot: "/home/me/proj",
|
||||
workspaceCount: 1,
|
||||
workspaces: [workspaceSummary()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
||||
const hosts = overrides.hosts ?? [hostEntry()];
|
||||
const totalWorkspaceCount =
|
||||
overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
|
||||
const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length;
|
||||
return {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
hosts,
|
||||
totalWorkspaceCount,
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: "https://github.com/acme/app",
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
||||
projectsState.current = {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function findRow(container: HTMLElement, projectKey: string): HTMLElement {
|
||||
const row = container.querySelector<HTMLElement>(`[data-testid="project-row-${projectKey}"]`);
|
||||
if (!row) throw new Error(`Expected row for ${projectKey}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("ProjectsScreen", () => {
|
||||
let container: HTMLElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
setProjectsState({});
|
||||
navigate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
|
||||
act(() => {
|
||||
root?.render(<ProjectsScreen view={view} />);
|
||||
});
|
||||
}
|
||||
|
||||
it("renders a row whose visible content is the project name only", () => {
|
||||
setProjectsState({
|
||||
projects: [
|
||||
project({
|
||||
projectName: "acme/app",
|
||||
hosts: [hostEntry({ serverName: "alpha", workspaceCount: 5 })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const rows = container?.querySelectorAll('[data-testid^="project-row-"]') ?? [];
|
||||
expect(rows.length).toBe(1);
|
||||
expect(container?.textContent).toContain("acme/app");
|
||||
expect(container?.textContent).not.toContain("workspace");
|
||||
expect(container?.textContent).not.toContain("offline");
|
||||
expect(container?.textContent).not.toContain("github.com");
|
||||
});
|
||||
|
||||
it("navigates to the project detail route when the row is pressed", () => {
|
||||
setProjectsState({
|
||||
projects: [project({ projectKey: "remote:github.com/acme/app" })],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const row = findRow(container!, "remote:github.com/acme/app");
|
||||
act(() => {
|
||||
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
|
||||
});
|
||||
|
||||
it("does not render a kebab menu on the row", () => {
|
||||
setProjectsState({
|
||||
projects: [project({ projectKey: "remote:github.com/acme/app" })],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(
|
||||
container?.querySelector('[data-testid="project-row-menu-remote:github.com/acme/app"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a centered loading spinner before the first response", () => {
|
||||
setProjectsState({ isLoading: true, projects: [] });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.querySelector('[data-testid="projects-loading-spinner"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no projects", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("No projects yet");
|
||||
});
|
||||
|
||||
it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet");
|
||||
});
|
||||
|
||||
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
|
||||
const hostErrors: ProjectHostError[] = [
|
||||
{ serverId: "a", serverName: "alpha", message: "timed out" },
|
||||
{ serverId: "b", serverName: "beta", message: "unreachable" },
|
||||
];
|
||||
setProjectsState({
|
||||
projects: [project()],
|
||||
hostErrors,
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const banner = container?.querySelector('[data-testid="projects-host-errors"]');
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner?.textContent).toContain("alpha");
|
||||
expect(banner?.textContent).toContain("beta");
|
||||
expect(container?.querySelector('[data-testid^="project-row-"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("highlights the selected row when the active view targets a project", () => {
|
||||
setProjectsState({
|
||||
projects: [
|
||||
project({ projectKey: "remote:github.com/acme/app" }),
|
||||
project({
|
||||
projectKey: "remote:github.com/acme/other",
|
||||
projectName: "acme/other",
|
||||
githubUrl: "https://github.com/acme/other",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
|
||||
|
||||
const selected = findRow(container!, "remote:github.com/acme/app");
|
||||
const other = findRow(container!, "remote:github.com/acme/other");
|
||||
expect(selected.getAttribute("data-selected")).toBe("true");
|
||||
expect(other.getAttribute("data-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("does not include the word 'checkout' anywhere in the rendered tree", () => {
|
||||
setProjectsState({
|
||||
projects: [
|
||||
project({
|
||||
hosts: [
|
||||
hostEntry({ serverId: "a", serverName: "alpha", workspaceCount: 3 }),
|
||||
hostEntry({ serverId: "b", serverName: "beta", workspaceCount: 2 }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
hostErrors: [{ serverId: "x", serverName: "x", message: "down" }],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const html = container?.innerHTML.toLowerCase() ?? "";
|
||||
expect(html).not.toContain("checkout");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
|
||||
|
||||
interface ProjectsScreenProps {
|
||||
view: { kind: "projects" } | { kind: "project"; projectKey: string };
|
||||
}
|
||||
|
||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
|
||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||
|
||||
if (isLoading && projects.length === 0) {
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
const message =
|
||||
hiddenUnsupportedRemoteCount > 0
|
||||
? "Non-GitHub remote projects aren't supported yet"
|
||||
: "No projects yet";
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<Text style={styles.emptyText}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="projects-list">
|
||||
{hostErrors.length > 0 ? <HostErrorsBanner errors={hostErrors} /> : null}
|
||||
<View style={settingsStyles.card}>
|
||||
{projects.map((project, index) => (
|
||||
<ProjectRow
|
||||
key={project.projectKey}
|
||||
project={project}
|
||||
isFirst={index === 0}
|
||||
isSelected={selectedProjectKey === project.projectKey}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
|
||||
return (
|
||||
<View style={styles.errorsBanner} testID="projects-host-errors">
|
||||
{errors.map((error) => (
|
||||
<Text key={error.serverId} style={styles.errorsBannerText}>
|
||||
{`Couldn't load projects from host ${error.serverName}: ${error.message}`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectRowProps {
|
||||
project: ProjectSummary;
|
||||
isFirst: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { hosts, projectKey, projectName } = project;
|
||||
const leadingHost = hosts[0];
|
||||
|
||||
const handleNavigate = useCallback(() => {
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
!isFirst && settingsStyles.rowBorder,
|
||||
styles.row,
|
||||
isSelected && styles.rowSelected,
|
||||
hovered && !isSelected && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[isFirst, isSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={rowStyle}
|
||||
onPress={handleNavigate}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit ${projectName}`}
|
||||
testID={`project-row-${projectKey}`}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.leading}>
|
||||
<ProjectRowIcon host={leadingHost} projectName={projectName} />
|
||||
</View>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{projectName}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRowIcon({
|
||||
host,
|
||||
projectName,
|
||||
}: {
|
||||
host: ProjectHostEntry | undefined;
|
||||
projectName: string;
|
||||
}) {
|
||||
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
||||
const { icon } = useProjectIconQuery({
|
||||
serverId: host?.serverId ?? "",
|
||||
cwd: host?.repoRoot ?? "",
|
||||
});
|
||||
const iconDataUri =
|
||||
icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null;
|
||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||
|
||||
if (iconDataUri) {
|
||||
return <Image source={imageSource} style={styles.iconImage} />;
|
||||
}
|
||||
return (
|
||||
<View style={styles.iconFallback}>
|
||||
<Text style={styles.iconFallbackText}>{initial}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
errorsBanner: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[3],
|
||||
marginBottom: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
errorsBannerText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
row: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
rowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
leading: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconImage: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
iconFallback: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconFallbackText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
spinnerColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Shield,
|
||||
Puzzle,
|
||||
Plus,
|
||||
FolderGit2,
|
||||
} from "lucide-react-native";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
@@ -70,11 +71,14 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { HostPage, HostRenameButton } from "@/screens/settings/host-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostRoute,
|
||||
buildSettingsSectionRoute,
|
||||
type SettingsSectionSlug,
|
||||
@@ -88,7 +92,9 @@ import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-ac
|
||||
export type SettingsView =
|
||||
| { kind: "root" }
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string };
|
||||
| { kind: "host"; serverId: string }
|
||||
| { kind: "projects" }
|
||||
| { kind: "project"; projectKey: string };
|
||||
|
||||
interface SidebarSectionItem {
|
||||
id: SettingsSectionSlug;
|
||||
@@ -544,6 +550,37 @@ function SidebarSectionButton({
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProjectsButtonProps {
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
const labelStyle = useMemo(
|
||||
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
||||
[isSelected, theme.colors.foreground],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={onSelect}
|
||||
testID="settings-projects"
|
||||
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
||||
>
|
||||
<FolderGit2
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
Projects
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarHostItemProps {
|
||||
serverId: string;
|
||||
label: string;
|
||||
@@ -590,6 +627,7 @@ interface SettingsSidebarProps {
|
||||
view: SettingsView;
|
||||
onSelectSection: (section: SettingsSectionSlug) => void;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
onSelectProjects: () => void;
|
||||
onAddHost: () => void;
|
||||
onBackToWorkspace: () => void;
|
||||
layout: "desktop" | "mobile";
|
||||
@@ -599,6 +637,7 @@ function SettingsSidebar({
|
||||
view,
|
||||
onSelectSection,
|
||||
onSelectHost,
|
||||
onSelectProjects,
|
||||
onAddHost,
|
||||
onBackToWorkspace,
|
||||
layout,
|
||||
@@ -626,6 +665,7 @@ function SettingsSidebar({
|
||||
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
|
||||
return (
|
||||
@@ -655,6 +695,7 @@ function SettingsSidebar({
|
||||
onSelect={onSelectSection}
|
||||
/>
|
||||
))}
|
||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
<View style={sidebarStyles.list}>
|
||||
@@ -818,6 +859,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[isCompactLayout, router],
|
||||
);
|
||||
|
||||
const handleSelectProjects = useCallback(() => {
|
||||
const target = buildProjectsSettingsRoute();
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
router.replace(target);
|
||||
}
|
||||
}, [isCompactLayout, router]);
|
||||
|
||||
const handleScanQr = useCallback(() => {
|
||||
closeAddConnectionFlow();
|
||||
router.push({
|
||||
@@ -844,17 +894,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
}, [router]);
|
||||
|
||||
const handleBackToWorkspace = useCallback(() => {
|
||||
if (!isCompactLayout) {
|
||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
||||
if (lastWorkspaceRoute) {
|
||||
router.replace(
|
||||
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
||||
if (lastWorkspaceRoute) {
|
||||
router.replace(
|
||||
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (anyOnlineServerId) {
|
||||
@@ -862,7 +906,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
return;
|
||||
}
|
||||
router.replace("/");
|
||||
}, [anyOnlineServerId, isCompactLayout, router]);
|
||||
}, [anyOnlineServerId, router]);
|
||||
|
||||
const detailHeader = ((): {
|
||||
title: string;
|
||||
@@ -883,6 +927,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (!item) return null;
|
||||
return { title: item.label, Icon: item.icon };
|
||||
}
|
||||
if (view.kind === "project" || view.kind === "projects") {
|
||||
return { title: "Projects", Icon: FolderGit2 };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
@@ -890,6 +937,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (view.kind === "host") {
|
||||
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||
}
|
||||
if (view.kind === "projects") {
|
||||
return <ProjectsScreen view={view} />;
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return <ProjectSettingsScreen projectKey={view.projectKey} />;
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
switch (view.section) {
|
||||
case "general":
|
||||
@@ -964,6 +1017,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
layout="mobile"
|
||||
@@ -974,14 +1028,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile detail: full-screen content with a back header that returns to the list.
|
||||
// Mobile detail: full-screen content with a back header. Project detail uses
|
||||
// an app-level back (out of settings, to the workspace) since the in-body
|
||||
// "Back to projects" ghost button handles list-level back; other detail views
|
||||
// step back to the settings root.
|
||||
const detailBackHandler = view.kind === "project" ? handleBackToWorkspace : handleBackToRoot;
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader
|
||||
title={detailHeader?.title}
|
||||
titleAccessory={detailHeader?.titleAccessory}
|
||||
onBack={handleBackToRoot}
|
||||
onBack={detailBackHandler}
|
||||
/>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
@@ -1001,6 +1059,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
layout="desktop"
|
||||
@@ -1094,6 +1153,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
placeholderText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
const desktopStyles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -115,6 +115,50 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
|
||||
expect(workspace.scripts).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves project placement from workspace descriptor payloads", () => {
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
id: "1",
|
||||
projectId: "remote:github.com/acme/app",
|
||||
projectDisplayName: "acme/app",
|
||||
projectRootPath: "/repo/app",
|
||||
workspaceDirectory: "/repo/app",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
project: {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(workspace.project).toEqual({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorkspaces", () => {
|
||||
|
||||
@@ -119,6 +119,9 @@ export interface WorkspaceDescriptor {
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
scripts: WorkspaceDescriptorPayload["scripts"];
|
||||
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
|
||||
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
|
||||
project?: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDescriptor(
|
||||
@@ -136,6 +139,9 @@ export function normalizeWorkspaceDescriptor(
|
||||
status: payload.status,
|
||||
diffStat: payload.diffStat ?? null,
|
||||
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
|
||||
gitRuntime: payload.gitRuntime,
|
||||
githubRuntime: payload.githubRuntime,
|
||||
project: payload.project,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildProjectSettingsRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
encodeFilePathForPathSegment,
|
||||
@@ -133,3 +135,28 @@ describe("workspace route parsing", () => {
|
||||
expect(decodeWorkspaceIdFromPathSegment(encoded)).toBe("team/setup:id#1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projects settings routes", () => {
|
||||
it("buildProjectsSettingsRoute returns /settings/projects", () => {
|
||||
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
|
||||
});
|
||||
|
||||
it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => {
|
||||
expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe(
|
||||
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildProjectSettingsRoute encodes a local repo-root key", () => {
|
||||
expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe(
|
||||
"/settings/projects/%2FUsers%2Fme%2Fdev%2Fpaseo",
|
||||
);
|
||||
});
|
||||
|
||||
it("project keys round-trip through decodeURIComponent", () => {
|
||||
const projectKey = "remote:github.com/acme/app";
|
||||
const route = buildProjectSettingsRoute(projectKey);
|
||||
const segment = route.slice("/settings/projects/".length);
|
||||
expect(decodeURIComponent(segment)).toBe(projectKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,6 +403,18 @@ export function buildSettingsHostRoute(serverId: string) {
|
||||
return `/settings/hosts/${encodeSegment(normalized)}` as const;
|
||||
}
|
||||
|
||||
export function buildProjectsSettingsRoute() {
|
||||
return "/settings/projects" as const;
|
||||
}
|
||||
|
||||
export function buildProjectSettingsRoute(projectKey: string) {
|
||||
const normalized = trimNonEmpty(projectKey);
|
||||
if (!normalized) {
|
||||
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
|
||||
}
|
||||
return `/settings/projects/${encodeSegment(normalized)}` as const;
|
||||
}
|
||||
|
||||
export function mapPathnameToServer(pathname: string, nextServerId: string) {
|
||||
const normalized = trimNonEmpty(nextServerId);
|
||||
if (!normalized) {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PaseoConfigRaw } from "@server/shared/messages";
|
||||
import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form";
|
||||
|
||||
function emptyDraft(): ProjectConfigDraft {
|
||||
return {
|
||||
setupText: "",
|
||||
setupOriginalKind: "missing",
|
||||
teardownText: "",
|
||||
teardownOriginalKind: "missing",
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("configToDraft", () => {
|
||||
it("returns an empty draft for null config", () => {
|
||||
expect(configToDraft(null)).toEqual(emptyDraft());
|
||||
});
|
||||
|
||||
it("renders a string lifecycle command as a single textarea text and remembers the kind", () => {
|
||||
const draft = configToDraft({
|
||||
worktree: { setup: "npm install" },
|
||||
});
|
||||
expect(draft.setupText).toBe("npm install");
|
||||
expect(draft.setupOriginalKind).toBe("string");
|
||||
expect(draft.teardownText).toBe("");
|
||||
expect(draft.teardownOriginalKind).toBe("missing");
|
||||
});
|
||||
|
||||
it("renders an array lifecycle command as newline-separated text", () => {
|
||||
const draft = configToDraft({
|
||||
worktree: { teardown: ["docker compose down", "rm -rf .cache"] },
|
||||
});
|
||||
expect(draft.teardownText).toBe("docker compose down\nrm -rf .cache");
|
||||
expect(draft.teardownOriginalKind).toBe("array");
|
||||
});
|
||||
|
||||
it("converts a scripts record into draft rows with stable local ids", () => {
|
||||
const draft = configToDraft({
|
||||
scripts: {
|
||||
dev: { type: "long-running", command: "npm run dev", port: 3000 },
|
||||
build: { command: ["npm", "run", "build"] },
|
||||
},
|
||||
});
|
||||
expect(draft.scripts).toHaveLength(2);
|
||||
const [devRow, buildRow] = draft.scripts;
|
||||
expect(devRow.name).toBe("dev");
|
||||
expect(devRow.commandText).toBe("npm run dev");
|
||||
expect(devRow.commandOriginalKind).toBe("string");
|
||||
expect(devRow.type).toBe("long-running");
|
||||
expect(devRow.portText).toBe("3000");
|
||||
expect(devRow.id).toBeTruthy();
|
||||
expect(buildRow.name).toBe("build");
|
||||
expect(buildRow.commandText).toBe("npm\nrun\nbuild");
|
||||
expect(buildRow.commandOriginalKind).toBe("array");
|
||||
expect(buildRow.portText).toBe("");
|
||||
expect(buildRow.id).not.toBe(devRow.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDraftToConfig", () => {
|
||||
it("preserves the original string kind when editing an existing setup field", () => {
|
||||
const base: PaseoConfigRaw = { worktree: { setup: "npm install" } };
|
||||
const draft = configToDraft(base);
|
||||
draft.setupText = "npm install\nnpm run prepare";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.setup).toBe("npm install\nnpm run prepare");
|
||||
});
|
||||
|
||||
it("preserves the original array kind when editing an existing teardown field", () => {
|
||||
const base: PaseoConfigRaw = {
|
||||
worktree: { teardown: ["docker compose down"] },
|
||||
};
|
||||
const draft = configToDraft(base);
|
||||
draft.teardownText = "docker compose down\nrm -rf .cache";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.teardown).toEqual(["docker compose down", "rm -rf .cache"]);
|
||||
});
|
||||
|
||||
it("writes a string for a newly added lifecycle field with one non-empty line", () => {
|
||||
const base: PaseoConfigRaw = {};
|
||||
const draft = configToDraft(base);
|
||||
draft.setupText = "npm install";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.setup).toBe("npm install");
|
||||
});
|
||||
|
||||
it("writes an array for a newly added lifecycle field with multiple non-empty lines", () => {
|
||||
const base: PaseoConfigRaw = {};
|
||||
const draft = configToDraft(base);
|
||||
draft.setupText = "npm install\nnpm run prepare";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.setup).toEqual(["npm install", "npm run prepare"]);
|
||||
});
|
||||
|
||||
it("omits a lifecycle field whose draft text is empty", () => {
|
||||
const base: PaseoConfigRaw = { worktree: { setup: "npm install" } };
|
||||
const draft = configToDraft(base);
|
||||
draft.setupText = "";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
expect(next.worktree?.setup).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves unknown top-level, worktree, and script entry fields on round-trip", () => {
|
||||
const base = {
|
||||
worktree: {
|
||||
setup: "npm install",
|
||||
terminals: [{ name: "dev", command: "npm run dev" }],
|
||||
customWorktreeField: "keep",
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
type: "long-running",
|
||||
command: "npm run dev",
|
||||
port: 3000,
|
||||
customScriptField: { nested: true },
|
||||
},
|
||||
},
|
||||
customTopLevel: "preserved",
|
||||
} as unknown as PaseoConfigRaw;
|
||||
|
||||
const draft = configToDraft(base);
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
|
||||
expect((next as unknown as Record<string, unknown>).customTopLevel).toBe("preserved");
|
||||
expect((next.worktree as unknown as Record<string, unknown>).customWorktreeField).toBe("keep");
|
||||
expect((next.worktree as unknown as Record<string, unknown>).terminals).toEqual([
|
||||
{ name: "dev", command: "npm run dev" },
|
||||
]);
|
||||
const devEntry = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
|
||||
expect(devEntry.customScriptField).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it("preserves all scripts on round-trip, including ones never edited in this session", () => {
|
||||
const base = {
|
||||
scripts: {
|
||||
dev: { type: "long-running", command: "npm run dev", port: 3000, customDevField: "keep" },
|
||||
build: { command: ["npm", "run", "build"], customBuildField: { nested: 1 } },
|
||||
lint: { command: "npm run lint", type: "task" },
|
||||
},
|
||||
} as unknown as PaseoConfigRaw;
|
||||
|
||||
const draft = configToDraft(base);
|
||||
// Edit only "dev". Leave "build" and "lint" untouched.
|
||||
const devRow = draft.scripts.find((row) => row.name === "dev");
|
||||
if (!devRow) throw new Error("expected dev row in draft");
|
||||
devRow.commandText = "npm run dev -- --watch";
|
||||
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const scripts = next.scripts ?? {};
|
||||
expect(Object.keys(scripts).sort()).toEqual(["build", "dev", "lint"]);
|
||||
|
||||
const devEntry = scripts.dev as unknown as Record<string, unknown>;
|
||||
expect(devEntry.command).toBe("npm run dev -- --watch");
|
||||
expect(devEntry.type).toBe("long-running");
|
||||
expect(devEntry.port).toBe(3000);
|
||||
expect(devEntry.customDevField).toBe("keep");
|
||||
|
||||
const buildEntry = scripts.build as unknown as Record<string, unknown>;
|
||||
expect(buildEntry.command).toEqual(["npm", "run", "build"]);
|
||||
expect(buildEntry.customBuildField).toEqual({ nested: 1 });
|
||||
|
||||
const lintEntry = scripts.lint as unknown as Record<string, unknown>;
|
||||
expect(lintEntry.command).toBe("npm run lint");
|
||||
expect(lintEntry.type).toBe("task");
|
||||
});
|
||||
|
||||
it("normalizes script command text into the original command kind", () => {
|
||||
const base = {
|
||||
scripts: {
|
||||
build: { command: ["npm", "run", "build"] },
|
||||
},
|
||||
} as unknown as PaseoConfigRaw;
|
||||
const draft = configToDraft(base);
|
||||
const buildRow = draft.scripts[0];
|
||||
buildRow.commandText = "npm run build";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const buildEntry = (next.scripts ?? {}).build as unknown as Record<string, unknown>;
|
||||
expect(buildEntry.command).toEqual(["npm run build"]);
|
||||
});
|
||||
|
||||
it("parses script port as a number when numeric and writes string for non-numeric input", () => {
|
||||
const base: PaseoConfigRaw = {};
|
||||
const draft = configToDraft(base);
|
||||
draft.scripts = [
|
||||
{
|
||||
id: "row-1",
|
||||
name: "dev",
|
||||
commandText: "npm run dev",
|
||||
commandOriginalKind: "missing",
|
||||
type: "long-running",
|
||||
portText: "3000",
|
||||
rawEntry: {},
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
name: "tunnel",
|
||||
commandText: "ngrok",
|
||||
commandOriginalKind: "missing",
|
||||
type: "long-running",
|
||||
portText: "auto",
|
||||
rawEntry: {},
|
||||
},
|
||||
];
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const dev = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
|
||||
const tunnel = (next.scripts ?? {}).tunnel as unknown as Record<string, unknown>;
|
||||
expect(dev.port).toBe(3000);
|
||||
expect(tunnel.port).toBe("auto");
|
||||
});
|
||||
|
||||
it("drops scripts with an empty name and removes scripts no longer present in the draft", () => {
|
||||
const base = {
|
||||
scripts: {
|
||||
dev: { command: "npm run dev" },
|
||||
build: { command: "npm run build" },
|
||||
},
|
||||
} as unknown as PaseoConfigRaw;
|
||||
const draft = configToDraft(base);
|
||||
// remove build, add a row with empty name.
|
||||
draft.scripts = draft.scripts
|
||||
.filter((row) => row.name !== "build")
|
||||
.concat({
|
||||
id: "row-empty",
|
||||
name: " ",
|
||||
commandText: "echo hi",
|
||||
commandOriginalKind: "missing",
|
||||
type: "",
|
||||
portText: "",
|
||||
rawEntry: {},
|
||||
});
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const scripts = next.scripts ?? {};
|
||||
expect(Object.keys(scripts)).toEqual(["dev"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@server/shared/messages";
|
||||
|
||||
export type LifecycleOriginalKind = "string" | "array" | "missing";
|
||||
|
||||
export interface ProjectScriptDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
commandText: string;
|
||||
commandOriginalKind: LifecycleOriginalKind;
|
||||
type: string;
|
||||
portText: string;
|
||||
rawEntry: PaseoScriptEntryRaw;
|
||||
}
|
||||
|
||||
export interface ProjectConfigDraft {
|
||||
setupText: string;
|
||||
setupOriginalKind: LifecycleOriginalKind;
|
||||
teardownText: string;
|
||||
teardownOriginalKind: LifecycleOriginalKind;
|
||||
scripts: ProjectScriptDraft[];
|
||||
}
|
||||
|
||||
interface LifecycleProjection {
|
||||
text: string;
|
||||
kind: LifecycleOriginalKind;
|
||||
}
|
||||
|
||||
function projectLifecycle(value: unknown): LifecycleProjection {
|
||||
if (typeof value === "string") {
|
||||
return { text: value, kind: "string" };
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const lines = value.filter((entry): entry is string => typeof entry === "string");
|
||||
return { text: lines.join("\n"), kind: "array" };
|
||||
}
|
||||
return { text: "", kind: "missing" };
|
||||
}
|
||||
|
||||
function lifecycleFromText(
|
||||
text: string,
|
||||
kind: LifecycleOriginalKind,
|
||||
): string | string[] | undefined {
|
||||
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
||||
if (lines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (kind === "string") {
|
||||
return lines.join("\n");
|
||||
}
|
||||
if (kind === "array") {
|
||||
return lines;
|
||||
}
|
||||
return lines.length === 1 ? lines[0] : lines;
|
||||
}
|
||||
|
||||
function projectScriptType(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function projectScriptPort(value: unknown): string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseScriptPort(value: string): number | string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^[0-9]+$/.test(trimmed)) {
|
||||
const parsed = Number(trimmed);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
let scriptDraftIdCounter = 0;
|
||||
|
||||
function nextScriptDraftId(): string {
|
||||
scriptDraftIdCounter += 1;
|
||||
return `script-draft-${scriptDraftIdCounter}`;
|
||||
}
|
||||
|
||||
export function configToDraft(config: PaseoConfigRaw | null | undefined): ProjectConfigDraft {
|
||||
const worktree = config?.worktree ?? {};
|
||||
const setup = projectLifecycle(worktree.setup);
|
||||
const teardown = projectLifecycle(worktree.teardown);
|
||||
const scripts: ProjectScriptDraft[] = [];
|
||||
|
||||
const scriptsRecord = config?.scripts ?? {};
|
||||
for (const [name, entry] of Object.entries(scriptsRecord)) {
|
||||
const command = projectLifecycle(entry.command);
|
||||
scripts.push({
|
||||
id: nextScriptDraftId(),
|
||||
name,
|
||||
commandText: command.text,
|
||||
commandOriginalKind: command.kind,
|
||||
type: projectScriptType(entry.type),
|
||||
portText: projectScriptPort(entry.port),
|
||||
rawEntry: entry,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
setupText: setup.text,
|
||||
setupOriginalKind: setup.kind,
|
||||
teardownText: teardown.text,
|
||||
teardownOriginalKind: teardown.kind,
|
||||
scripts,
|
||||
};
|
||||
}
|
||||
|
||||
interface ApplyDraftInput {
|
||||
draft: ProjectConfigDraft;
|
||||
base: PaseoConfigRaw | null | undefined;
|
||||
}
|
||||
|
||||
export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
|
||||
const baseConfig = input.base ?? {};
|
||||
const baseWorktree = baseConfig.worktree ?? {};
|
||||
|
||||
const nextWorktree: Record<string, unknown> = { ...baseWorktree };
|
||||
const nextSetup = lifecycleFromText(input.draft.setupText, input.draft.setupOriginalKind);
|
||||
if (nextSetup === undefined) {
|
||||
delete nextWorktree.setup;
|
||||
} else {
|
||||
nextWorktree.setup = nextSetup;
|
||||
}
|
||||
const nextTeardown = lifecycleFromText(
|
||||
input.draft.teardownText,
|
||||
input.draft.teardownOriginalKind,
|
||||
);
|
||||
if (nextTeardown === undefined) {
|
||||
delete nextWorktree.teardown;
|
||||
} else {
|
||||
nextWorktree.teardown = nextTeardown;
|
||||
}
|
||||
|
||||
const nextScripts: Record<string, PaseoScriptEntryRaw> = {};
|
||||
for (const row of input.draft.scripts) {
|
||||
const trimmedName = row.name.trim();
|
||||
if (trimmedName.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const baseEntry = row.rawEntry;
|
||||
const nextEntry: Record<string, unknown> = { ...baseEntry };
|
||||
const nextCommand = lifecycleFromText(row.commandText, row.commandOriginalKind);
|
||||
if (nextCommand === undefined) {
|
||||
delete nextEntry.command;
|
||||
} else {
|
||||
nextEntry.command = nextCommand;
|
||||
}
|
||||
const trimmedType = row.type.trim();
|
||||
if (trimmedType.length === 0) {
|
||||
delete nextEntry.type;
|
||||
} else {
|
||||
nextEntry.type = trimmedType;
|
||||
}
|
||||
const nextPort = parseScriptPort(row.portText);
|
||||
if (nextPort === undefined) {
|
||||
delete nextEntry.port;
|
||||
} else {
|
||||
nextEntry.port = nextPort;
|
||||
}
|
||||
nextScripts[trimmedName] = nextEntry as PaseoScriptEntryRaw;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = { ...baseConfig };
|
||||
if (Object.keys(nextWorktree).length === 0) {
|
||||
delete result.worktree;
|
||||
} else {
|
||||
result.worktree = nextWorktree;
|
||||
}
|
||||
if (Object.keys(nextScripts).length === 0) {
|
||||
delete result.scripts;
|
||||
} else {
|
||||
result.scripts = nextScripts;
|
||||
}
|
||||
return result as PaseoConfigRaw;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
status: WorkspaceDescriptor["status"];
|
||||
currentBranch: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectHostEntry {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
repoRoot: string;
|
||||
workspaceCount: number;
|
||||
workspaces: WorkspaceSummary[];
|
||||
gitRuntime?: WorkspaceDescriptor["gitRuntime"];
|
||||
githubRuntime?: WorkspaceDescriptor["githubRuntime"];
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
hosts: ProjectHostEntry[];
|
||||
totalWorkspaceCount: number;
|
||||
hostCount: number;
|
||||
onlineHostCount: number;
|
||||
githubUrl?: string;
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}
|
||||
|
||||
export interface ProjectHost {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
}
|
||||
|
||||
export interface BuildProjectsInput {
|
||||
hosts: ProjectHost[];
|
||||
}
|
||||
|
||||
export interface BuildProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}
|
||||
|
||||
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
|
||||
|
||||
interface HostGroup {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
}
|
||||
|
||||
interface ProjectGroup {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
hostsByServerId: Map<string, HostGroup>;
|
||||
}
|
||||
|
||||
function isSupportedProjectKey(projectKey: string): boolean {
|
||||
return !projectKey.startsWith("remote:") || projectKey.startsWith("remote:github.com/");
|
||||
}
|
||||
|
||||
function deriveGithubUrl(projectKey: string): string | undefined {
|
||||
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return `https://github.com/${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
function resolveHostRepoRoot(workspaces: WorkspaceDescriptor[]): string {
|
||||
for (const workspace of workspaces) {
|
||||
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
|
||||
if (mainRepoRoot) {
|
||||
return mainRepoRoot;
|
||||
}
|
||||
}
|
||||
return workspaces[0]?.projectRootPath ?? "";
|
||||
}
|
||||
|
||||
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
status: workspace.status,
|
||||
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toHostEntry(group: HostGroup): ProjectHostEntry {
|
||||
const repoRoot = resolveHostRepoRoot(group.workspaces);
|
||||
const canonical =
|
||||
group.workspaces.find((workspace) => workspace.projectRootPath === repoRoot) ??
|
||||
group.workspaces[0];
|
||||
return {
|
||||
serverId: group.serverId,
|
||||
serverName: group.serverName,
|
||||
isOnline: group.isOnline,
|
||||
repoRoot,
|
||||
workspaceCount: group.workspaces.length,
|
||||
workspaces: group.workspaces.map(toWorkspaceSummary),
|
||||
gitRuntime: canonical?.gitRuntime,
|
||||
githubRuntime: canonical?.githubRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
function compareHosts(left: ProjectHostEntry, right: ProjectHostEntry): number {
|
||||
const name = left.serverName.localeCompare(right.serverName);
|
||||
if (name !== 0) {
|
||||
return name;
|
||||
}
|
||||
return left.serverId.localeCompare(right.serverId);
|
||||
}
|
||||
|
||||
function toProjectSummary(input: {
|
||||
draft: ProjectGroup;
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}): ProjectSummary {
|
||||
const hosts = Array.from(input.draft.hostsByServerId.values())
|
||||
.map(toHostEntry)
|
||||
.sort(compareHosts);
|
||||
const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
|
||||
const onlineHostCount = hosts.filter((host) => host.isOnline).length;
|
||||
return {
|
||||
projectKey: input.draft.projectKey,
|
||||
projectName: input.draft.projectName,
|
||||
hosts,
|
||||
totalWorkspaceCount,
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: deriveGithubUrl(input.draft.projectKey),
|
||||
hiddenUnsupportedRemoteCount: input.hiddenUnsupportedRemoteCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
const groups = new Map<string, ProjectGroup>();
|
||||
let hiddenUnsupportedRemoteCount = 0;
|
||||
|
||||
for (const host of input.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
const projectKey = workspace.projectId;
|
||||
if (!isSupportedProjectKey(projectKey)) {
|
||||
hiddenUnsupportedRemoteCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let group = groups.get(projectKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
projectKey,
|
||||
projectName: workspace.projectDisplayName,
|
||||
hostsByServerId: new Map(),
|
||||
};
|
||||
groups.set(projectKey, group);
|
||||
}
|
||||
|
||||
let hostGroup = group.hostsByServerId.get(host.serverId);
|
||||
if (!hostGroup) {
|
||||
hostGroup = {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline: host.isOnline,
|
||||
workspaces: [],
|
||||
};
|
||||
group.hostsByServerId.set(host.serverId, hostGroup);
|
||||
}
|
||||
hostGroup.workspaces.push(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
const projects = Array.from(groups.values()).map((draft) =>
|
||||
toProjectSummary({ draft, hiddenUnsupportedRemoteCount }),
|
||||
);
|
||||
projects.sort((left, right) => {
|
||||
const name = left.projectName.localeCompare(right.projectName);
|
||||
if (name !== 0) {
|
||||
return name;
|
||||
}
|
||||
return left.projectKey.localeCompare(right.projectKey);
|
||||
});
|
||||
|
||||
return {
|
||||
projects,
|
||||
hiddenUnsupportedRemoteCount,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
workspaceKey: "srv:ws-root",
|
||||
serverId: "srv",
|
||||
workspaceId: "ws-root",
|
||||
projectKey: "project-1",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
|
||||
@@ -11,11 +11,13 @@ function workspace(input: {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
name: string;
|
||||
projectKey?: string;
|
||||
}): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
projectKey: input.projectKey ?? "project-default",
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
|
||||
@@ -1171,6 +1171,125 @@ test("requests branch suggestions via RPC", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("reads project config via correlated RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.readProjectConfig("/repo/app", "read-project-config-1");
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: { type: "read_project_config_request"; requestId: string; repoRoot: string };
|
||||
};
|
||||
expect(request.message).toEqual({
|
||||
type: "read_project_config_request",
|
||||
requestId: "read-project-config-1",
|
||||
repoRoot: "/repo/app",
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "read-project-config-1",
|
||||
repoRoot: "/repo/app",
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm install" } },
|
||||
revision: { mtimeMs: 10, size: 20 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: "read-project-config-1",
|
||||
repoRoot: "/repo/app",
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm install" } },
|
||||
revision: { mtimeMs: 10, size: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
test("writes project config via correlated RPC and returns inline failures", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.writeProjectConfig({
|
||||
requestId: "write-project-config-1",
|
||||
repoRoot: "/repo/app",
|
||||
config: { worktree: { setup: ["npm install"] } },
|
||||
expectedRevision: { mtimeMs: 10, size: 20 },
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "write_project_config_request";
|
||||
requestId: string;
|
||||
repoRoot: string;
|
||||
config: unknown;
|
||||
expectedRevision: unknown;
|
||||
};
|
||||
};
|
||||
expect(request.message).toEqual({
|
||||
type: "write_project_config_request",
|
||||
requestId: "write-project-config-1",
|
||||
repoRoot: "/repo/app",
|
||||
config: { worktree: { setup: ["npm install"] } },
|
||||
expectedRevision: { mtimeMs: 10, size: 20 },
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "write_project_config_response",
|
||||
payload: {
|
||||
requestId: "write-project-config-1",
|
||||
repoRoot: "/repo/app",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "stale_project_config",
|
||||
currentRevision: { mtimeMs: 11, size: 21 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: "write-project-config-1",
|
||||
repoRoot: "/repo/app",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "stale_project_config",
|
||||
currentRevision: { mtimeMs: 11, size: 21 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("requests directory suggestions via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -68,6 +68,8 @@ import type {
|
||||
SessionOutboundMessage,
|
||||
SendAgentMessageRequest,
|
||||
EditorTargetId,
|
||||
PaseoConfigRaw,
|
||||
PaseoConfigRevision,
|
||||
} from "../shared/messages.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -274,11 +276,25 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
||||
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||
type ReadProjectConfigPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "read_project_config_response" }
|
||||
>["payload"];
|
||||
type WriteProjectConfigPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "write_project_config_response" }
|
||||
>["payload"];
|
||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||
type ListCommandsDraftConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
|
||||
>;
|
||||
export interface WriteProjectConfigInput {
|
||||
repoRoot: string;
|
||||
config: PaseoConfigRaw;
|
||||
expectedRevision: PaseoConfigRevision | null;
|
||||
requestId?: string;
|
||||
}
|
||||
interface ListCommandsOptions {
|
||||
requestId?: string;
|
||||
draftConfig?: ListCommandsDraftConfig;
|
||||
@@ -2940,6 +2956,32 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async readProjectConfig(repoRoot: string, requestId?: string): Promise<ReadProjectConfigPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "read_project_config_request",
|
||||
repoRoot,
|
||||
},
|
||||
responseType: "read_project_config_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async writeProjectConfig(input: WriteProjectConfigInput): Promise<WriteProjectConfigPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "write_project_config_request",
|
||||
repoRoot: input.repoRoot,
|
||||
config: input.config,
|
||||
expectedRevision: input.expectedRevision,
|
||||
},
|
||||
responseType: "write_project_config_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async refreshProvidersSnapshot(options?: {
|
||||
cwd?: string;
|
||||
providers?: AgentProvider[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { homedir, tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import pino from "pino";
|
||||
@@ -200,6 +200,7 @@ function createSessionForTest(options?: {
|
||||
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
||||
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
||||
terminalManager?: unknown;
|
||||
scriptRouteStore?: unknown;
|
||||
scriptRuntimeStore?: unknown;
|
||||
@@ -238,10 +239,21 @@ function createSessionForTest(options?: {
|
||||
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
agentManager: {
|
||||
listAgents: vi.fn(() => []),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
} as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: {} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: {} as unknown as SessionOptions["projectRegistry"],
|
||||
agentStorage: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: (options?.projectRegistry ?? {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
existsOnDisk: vi.fn(),
|
||||
}) as unknown as SessionOptions["projectRegistry"],
|
||||
workspaceRegistry: (options?.workspaceRegistry ?? {
|
||||
get: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
@@ -270,6 +282,242 @@ function createSessionForTest(options?: {
|
||||
});
|
||||
}
|
||||
|
||||
function createProjectRecord(rootPath: string, archivedAt: string | null = null) {
|
||||
return {
|
||||
projectId: `project:${rootPath}`,
|
||||
rootPath,
|
||||
kind: "git" as const,
|
||||
displayName: "Project",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe("project config RPC authorization", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeRoot(): string {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "project-config-session-test-")));
|
||||
tempDirs.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("read_project_config_request accepts the same root with a trailing slash", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "read_project_config_request",
|
||||
requestId: "read-trailing-slash-1",
|
||||
repoRoot: `${repoRoot}/`,
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "read-trailing-slash-1",
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
revision: expect.objectContaining({
|
||||
mtimeMs: expect.any(Number),
|
||||
size: expect.any(Number),
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("read_project_config_request accepts a symlink to an active project root", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||
const linkRoot = join(makeRoot(), "link");
|
||||
symlinkSync(repoRoot, linkRoot, "dir");
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "read_project_config_request",
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot: linkRoot,
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
revision: expect.objectContaining({
|
||||
mtimeMs: expect.any(Number),
|
||||
size: expect.any(Number),
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
|
||||
const archivedRoot = makeRoot();
|
||||
const unknownRoot = makeRoot();
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi
|
||||
.fn()
|
||||
.mockResolvedValue([createProjectRecord(archivedRoot, "2026-01-02T00:00:00.000Z")]),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "read_project_config_request",
|
||||
requestId: "archived-1",
|
||||
repoRoot: archivedRoot,
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "read_project_config_request",
|
||||
requestId: "unknown-1",
|
||||
repoRoot: unknownRoot,
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "archived-1",
|
||||
repoRoot: archivedRoot,
|
||||
ok: false,
|
||||
error: { code: "project_not_found" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "unknown-1",
|
||||
repoRoot: unknownRoot,
|
||||
ok: false,
|
||||
error: { code: "project_not_found" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("read_project_config_request emits raw lifecycle forms for a known project root", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
writeFileSync(
|
||||
join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({ worktree: { setup: "npm install", teardown: ["npm run clean"] } }),
|
||||
);
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "read_project_config_request",
|
||||
requestId: "read-1",
|
||||
repoRoot,
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "read-1",
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm install", teardown: ["npm run clean"] } },
|
||||
revision: expect.objectContaining({
|
||||
mtimeMs: expect.any(Number),
|
||||
size: expect.any(Number),
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("write_project_config_request emits stale and write-failed inline domain failures", async () => {
|
||||
const staleRoot = makeRoot();
|
||||
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
||||
const writeFailedRoot = join(makeRoot(), "not-a-directory");
|
||||
writeFileSync(writeFailedRoot, "file");
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
createProjectRecord(staleRoot),
|
||||
createProjectRecord(writeFailedRoot),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "write_project_config_request",
|
||||
requestId: "stale-1",
|
||||
repoRoot: staleRoot,
|
||||
config: { worktree: { setup: "new" } },
|
||||
expectedRevision: { mtimeMs: 1, size: 1 },
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "write_project_config_request",
|
||||
requestId: "write-failed-1",
|
||||
repoRoot: writeFailedRoot,
|
||||
config: { worktree: { setup: "new" } },
|
||||
expectedRevision: null,
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "write_project_config_response",
|
||||
payload: {
|
||||
requestId: "stale-1",
|
||||
repoRoot: staleRoot,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "stale_project_config",
|
||||
currentRevision: expect.objectContaining({
|
||||
mtimeMs: expect.any(Number),
|
||||
size: expect.any(Number),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "write_project_config_response",
|
||||
payload: {
|
||||
requestId: "write-failed-1",
|
||||
repoRoot: writeFailedRoot,
|
||||
ok: false,
|
||||
error: { code: "write_failed" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function createWorkspaceGitSnapshot(
|
||||
cwd: string,
|
||||
overrides?: {
|
||||
@@ -1131,6 +1379,146 @@ describe("session checkout status handling", () => {
|
||||
});
|
||||
|
||||
describe("session workspace descriptors", () => {
|
||||
test("fetch_workspaces_request includes project placement for a GitHub-backed workspace", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspace = {
|
||||
workspaceId: "ws-gh",
|
||||
projectId: "remote:github.com/acme/app",
|
||||
cwd: "/repo/app",
|
||||
kind: "local_checkout" as const,
|
||||
displayName: "app",
|
||||
archivedAt: null,
|
||||
};
|
||||
const project = {
|
||||
projectId: "remote:github.com/acme/app",
|
||||
rootPath: "/repo/app",
|
||||
kind: "git" as const,
|
||||
displayName: "acme/app",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
};
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(() =>
|
||||
createWorkspaceGitSnapshot("/repo/app", {
|
||||
git: {
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
currentBranch: "main",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
registerWorkspace: vi.fn(() => () => {}),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "fetch-workspaces-gh",
|
||||
});
|
||||
|
||||
expect(messages).toContainEqual({
|
||||
type: "fetch_workspaces_response",
|
||||
payload: expect.objectContaining({
|
||||
requestId: "fetch-workspaces-gh",
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
id: "ws-gh",
|
||||
project: {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "app",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("fetch_workspaces_request includes repo-root fallback placement for a workspace without remote", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspace = {
|
||||
workspaceId: "ws-local",
|
||||
projectId: "/repo/local",
|
||||
cwd: "/repo/local",
|
||||
kind: "local_checkout" as const,
|
||||
displayName: "local",
|
||||
archivedAt: null,
|
||||
};
|
||||
const project = {
|
||||
projectId: "/repo/local",
|
||||
rootPath: "/repo/local",
|
||||
kind: "git" as const,
|
||||
displayName: "local",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
};
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(() =>
|
||||
createWorkspaceGitSnapshot("/repo/local", {
|
||||
git: {
|
||||
remoteUrl: null,
|
||||
currentBranch: "main",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
registerWorkspace: vi.fn(() => () => {}),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "fetch-workspaces-local",
|
||||
});
|
||||
|
||||
expect(messages).toContainEqual({
|
||||
type: "fetch_workspaces_response",
|
||||
payload: expect.objectContaining({
|
||||
requestId: "fetch-workspaces-local",
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
id: "ws-local",
|
||||
project: {
|
||||
projectKey: "/repo/local",
|
||||
projectName: "local",
|
||||
checkout: {
|
||||
cwd: "/repo/local",
|
||||
isGit: true,
|
||||
currentBranch: "local",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: "/repo/local",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("reads descriptor diff stat from the workspace git service snapshot", async () => {
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import equal from "fast-deep-equal";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
import pMemoize from "p-memoize";
|
||||
import { realpathSync } from "node:fs";
|
||||
import type { FSWatcher } from "node:fs";
|
||||
import { resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -162,6 +163,11 @@ import {
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { type WorktreeConfig } from "../utils/worktree.js";
|
||||
import {
|
||||
readPaseoConfigForEdit,
|
||||
writePaseoConfigForEdit,
|
||||
type ProjectConfigRpcError,
|
||||
} from "../utils/paseo-config-file.js";
|
||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
@@ -216,6 +222,46 @@ import { toWorktreeWireError } from "./worktree-errors.js";
|
||||
|
||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
||||
|
||||
interface ResolveKnownProjectRootForConfigInput {
|
||||
repoRoot: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "list">;
|
||||
}
|
||||
|
||||
async function resolveKnownProjectRootForConfig(
|
||||
input: ResolveKnownProjectRootForConfigInput,
|
||||
): Promise<string | null> {
|
||||
const requestedRoot = canonicalizeConfigRoot(input.repoRoot);
|
||||
const projects = await input.projectRegistry.list();
|
||||
for (const project of projects) {
|
||||
if (project.archivedAt !== null) {
|
||||
continue;
|
||||
}
|
||||
const projectRoot = canonicalizeConfigRoot(project.rootPath);
|
||||
if (requestedRoot === projectRoot) {
|
||||
return projectRoot;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canonicalizeConfigRoot(repoRoot: string): string {
|
||||
const resolved = resolve(repoRoot);
|
||||
try {
|
||||
return stripTrailingPathSeparators(realpathSync(resolved));
|
||||
} catch {
|
||||
return stripTrailingPathSeparators(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
function stripTrailingPathSeparators(path: string): string {
|
||||
let normalized = path;
|
||||
while (normalized.length > 1 && normalized.endsWith(sep)) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
type GitMutationRefreshReason =
|
||||
| "commit-changes"
|
||||
| "pull"
|
||||
@@ -1846,11 +1892,134 @@ export class Session {
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
case "read_project_config_request":
|
||||
return this.handleReadProjectConfigRequest(msg);
|
||||
case "write_project_config_request":
|
||||
return this.handleWriteProjectConfigRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleReadProjectConfigRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
|
||||
): Promise<void> {
|
||||
const repoRoot = await resolveKnownProjectRootForConfig({
|
||||
repoRoot: msg.repoRoot,
|
||||
projectRegistry: this.projectRegistry,
|
||||
});
|
||||
if (!repoRoot) {
|
||||
this.emitProjectConfigReadFailure(msg, { code: "project_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = readPaseoConfigForEdit(repoRoot);
|
||||
if (!result.ok) {
|
||||
this.sessionLogger.warn(
|
||||
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
|
||||
"Failed to read project config",
|
||||
);
|
||||
this.emitProjectConfigReadFailure(msg, result.error, repoRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.config === null) {
|
||||
this.sessionLogger.debug(
|
||||
{ repoRoot, requestId: msg.requestId, outcome: "missing_project_config" },
|
||||
"Project config missing",
|
||||
);
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: result.config,
|
||||
revision: result.revision,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleWriteProjectConfigRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
|
||||
): Promise<void> {
|
||||
const repoRoot = await resolveKnownProjectRootForConfig({
|
||||
repoRoot: msg.repoRoot,
|
||||
projectRegistry: this.projectRegistry,
|
||||
});
|
||||
if (!repoRoot) {
|
||||
this.emitProjectConfigWriteFailure(msg, { code: "project_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{ repoRoot, requestId: msg.requestId, outcome: "write_attempt" },
|
||||
"Writing project config",
|
||||
);
|
||||
const result = writePaseoConfigForEdit({
|
||||
repoRoot,
|
||||
config: msg.config,
|
||||
expectedRevision: msg.expectedRevision,
|
||||
});
|
||||
if (!result.ok) {
|
||||
this.sessionLogger.debug(
|
||||
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
|
||||
"Project config write did not complete",
|
||||
);
|
||||
this.emitProjectConfigWriteFailure(msg, result.error, repoRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{ repoRoot, requestId: msg.requestId, outcome: "written" },
|
||||
"Project config written",
|
||||
);
|
||||
this.emit({
|
||||
type: "write_project_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: result.config,
|
||||
revision: result.revision,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private emitProjectConfigReadFailure(
|
||||
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
|
||||
error: ProjectConfigRpcError,
|
||||
repoRoot = msg.repoRoot,
|
||||
): void {
|
||||
this.emit({
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
repoRoot,
|
||||
ok: false,
|
||||
error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private emitProjectConfigWriteFailure(
|
||||
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
|
||||
error: ProjectConfigRpcError,
|
||||
repoRoot = msg.repoRoot,
|
||||
): void {
|
||||
this.emit({
|
||||
type: "write_project_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
repoRoot,
|
||||
ok: false,
|
||||
error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "checkout_status_request":
|
||||
@@ -5908,6 +6077,11 @@ export class Session {
|
||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
||||
})
|
||||
: [],
|
||||
...(resolvedProjectRecord
|
||||
? {
|
||||
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { SessionOutboundMessageSchema } from "./messages.js";
|
||||
|
||||
function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "ws-1",
|
||||
projectId: "remote:github.com/acme/app",
|
||||
projectDisplayName: "acme/app",
|
||||
projectRootPath: "/repo/app",
|
||||
workspaceDirectory: "/repo/app",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "app",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fetchWorkspacesResponse(workspace: Record<string, unknown>) {
|
||||
return {
|
||||
type: "fetch_workspaces_response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
entries: [workspace],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace descriptor message compatibility", () => {
|
||||
test("old-shaped fetch_workspaces_response without project still parses", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse(
|
||||
fetchWorkspacesResponse(workspaceDescriptor()),
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("fetch_workspaces_response");
|
||||
if (parsed.type !== "fetch_workspaces_response") {
|
||||
throw new Error("Expected fetch_workspaces_response");
|
||||
}
|
||||
expect(parsed.payload.entries[0]?.project).toBeUndefined();
|
||||
});
|
||||
|
||||
test("new-shaped fetch_workspaces_response with project placement parses", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse(
|
||||
fetchWorkspacesResponse(
|
||||
workspaceDescriptor({
|
||||
project: {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("fetch_workspaces_response");
|
||||
if (parsed.type !== "fetch_workspaces_response") {
|
||||
throw new Error("Expected fetch_workspaces_response");
|
||||
}
|
||||
expect(parsed.payload.entries[0]?.project).toEqual({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("adding project does not narrow existing descriptor fields", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse(
|
||||
fetchWorkspacesResponse(
|
||||
workspaceDescriptor({
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
project: {
|
||||
projectKey: "/repo/local",
|
||||
projectName: "local",
|
||||
checkout: {
|
||||
cwd: "/repo/local",
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("fetch_workspaces_response");
|
||||
if (parsed.type !== "fetch_workspaces_response") {
|
||||
throw new Error("Expected fetch_workspaces_response");
|
||||
}
|
||||
expect(parsed.payload.entries[0]).toMatchObject({
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
workspaceDirectory: "/repo/app",
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,28 @@ import {
|
||||
LoopLogsResponseSchema,
|
||||
LoopStopResponseSchema,
|
||||
} from "../server/loop/rpc-schemas.js";
|
||||
import {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoLifecycleCommandRawSchema,
|
||||
PaseoScriptEntryRawSchema,
|
||||
PaseoWorktreeConfigRawSchema,
|
||||
PaseoConfigRevisionSchema,
|
||||
ProjectConfigRpcErrorSchema,
|
||||
type PaseoConfigRaw,
|
||||
type PaseoConfigRevision,
|
||||
type PaseoScriptEntryRaw,
|
||||
type ProjectConfigRpcError,
|
||||
} from "../utils/paseo-config-schema.js";
|
||||
export {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoLifecycleCommandRawSchema,
|
||||
PaseoScriptEntryRawSchema,
|
||||
PaseoWorktreeConfigRawSchema,
|
||||
type PaseoConfigRaw,
|
||||
type PaseoConfigRevision,
|
||||
type PaseoScriptEntryRaw,
|
||||
type ProjectConfigRpcError,
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutable daemon config schemas (shared between server store and client)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -872,6 +894,20 @@ export const SetDaemonConfigRequestMessageSchema = z.object({
|
||||
config: MutableDaemonConfigPatchSchema,
|
||||
});
|
||||
|
||||
export const ReadProjectConfigRequestMessageSchema = z.object({
|
||||
type: z.literal("read_project_config_request"),
|
||||
requestId: z.string(),
|
||||
repoRoot: z.string(),
|
||||
});
|
||||
|
||||
export const WriteProjectConfigRequestMessageSchema = z.object({
|
||||
type: z.literal("write_project_config_request"),
|
||||
requestId: z.string(),
|
||||
repoRoot: z.string(),
|
||||
config: PaseoConfigRawSchema,
|
||||
expectedRevision: PaseoConfigRevisionSchema.nullable(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Dictation Streaming (lossless, resumable)
|
||||
// ============================================================================
|
||||
@@ -1601,6 +1637,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
WaitForFinishRequestSchema,
|
||||
GetDaemonConfigRequestMessageSchema,
|
||||
SetDaemonConfigRequestMessageSchema,
|
||||
ReadProjectConfigRequestMessageSchema,
|
||||
WriteProjectConfigRequestMessageSchema,
|
||||
DictationStreamStartMessageSchema,
|
||||
DictationStreamChunkMessageSchema,
|
||||
DictationStreamFinishMessageSchema,
|
||||
@@ -2110,6 +2148,7 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
scripts: z.array(WorkspaceScriptPayloadSchema).default([]),
|
||||
gitRuntime: WorkspaceGitRuntimePayloadSchema,
|
||||
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
|
||||
project: ProjectPlacementPayloadSchema.optional(),
|
||||
})
|
||||
.transform((workspace) => ({
|
||||
...workspace,
|
||||
@@ -2407,6 +2446,44 @@ export const SetDaemonConfigResponseMessageSchema = z.object({
|
||||
.passthrough(),
|
||||
});
|
||||
|
||||
export const ReadProjectConfigResponseMessageSchema = z.object({
|
||||
type: z.literal("read_project_config_response"),
|
||||
payload: z.discriminatedUnion("ok", [
|
||||
z.object({
|
||||
requestId: z.string(),
|
||||
repoRoot: z.string(),
|
||||
ok: z.literal(true),
|
||||
config: PaseoConfigRawSchema.nullable(),
|
||||
revision: PaseoConfigRevisionSchema.nullable(),
|
||||
}),
|
||||
z.object({
|
||||
requestId: z.string(),
|
||||
repoRoot: z.string(),
|
||||
ok: z.literal(false),
|
||||
error: ProjectConfigRpcErrorSchema,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export const WriteProjectConfigResponseMessageSchema = z.object({
|
||||
type: z.literal("write_project_config_response"),
|
||||
payload: z.discriminatedUnion("ok", [
|
||||
z.object({
|
||||
requestId: z.string(),
|
||||
repoRoot: z.string(),
|
||||
ok: z.literal(true),
|
||||
config: PaseoConfigRawSchema,
|
||||
revision: PaseoConfigRevisionSchema,
|
||||
}),
|
||||
z.object({
|
||||
requestId: z.string(),
|
||||
repoRoot: z.string(),
|
||||
ok: z.literal(false),
|
||||
error: ProjectConfigRpcErrorSchema,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export const AgentPermissionRequestMessageSchema = z.object({
|
||||
type: z.literal("agent_permission_request"),
|
||||
payload: z.object({
|
||||
@@ -3187,6 +3264,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetVoiceModeResponseMessageSchema,
|
||||
GetDaemonConfigResponseMessageSchema,
|
||||
SetDaemonConfigResponseMessageSchema,
|
||||
ReadProjectConfigResponseMessageSchema,
|
||||
WriteProjectConfigResponseMessageSchema,
|
||||
SetAgentModeResponseMessageSchema,
|
||||
SetAgentModelResponseMessageSchema,
|
||||
SetAgentThinkingResponseMessageSchema,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
|
||||
import {
|
||||
readPaseoConfigForEdit,
|
||||
statPaseoConfigPath,
|
||||
writePaseoConfigForEdit,
|
||||
} from "./paseo-config-file.js";
|
||||
|
||||
describe("paseo config file substrate", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "paseo-config-file-test-")));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns null config and revision when paseo.json is missing", () => {
|
||||
const result = readPaseoConfigForEdit(tempDir);
|
||||
|
||||
expect(result).toEqual({ ok: true, config: null, revision: null });
|
||||
});
|
||||
|
||||
it("returns invalid_project_config for invalid JSON", () => {
|
||||
writeFileSync(join(tempDir, "paseo.json"), "{ invalid json\n");
|
||||
|
||||
const result = readPaseoConfigForEdit(tempDir);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: { code: "invalid_project_config" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves raw lifecycle string and array forms with a revision token", () => {
|
||||
writeFileSync(
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: "npm install",
|
||||
teardown: ["npm run clean", "npm run reset"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = readPaseoConfigForEdit(tempDir);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
worktree: {
|
||||
setup: "npm install",
|
||||
teardown: ["npm run clean", "npm run reset"],
|
||||
},
|
||||
},
|
||||
revision: statPaseoConfigPath(tempDir),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps runtime lifecycle commands normalized for execution", () => {
|
||||
writeFileSync(
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: "npm install",
|
||||
teardown: ["npm run clean", "", 42, "npm run reset"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]);
|
||||
expect(getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]);
|
||||
});
|
||||
|
||||
it("writes pretty JSON with a trailing newline when revision matches", () => {
|
||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
||||
const expectedRevision = statPaseoConfigPath(tempDir);
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
repoRoot: tempDir,
|
||||
config: { worktree: { setup: "npm install" } },
|
||||
expectedRevision,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm install" } },
|
||||
revision: statPaseoConfigPath(tempDir),
|
||||
});
|
||||
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
|
||||
'{\n "worktree": {\n "setup": "npm install"\n }\n}\n',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects stale writes when the current revision changed before rename", () => {
|
||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
||||
const expectedRevision = statPaseoConfigPath(tempDir);
|
||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
|
||||
const currentRevision = statPaseoConfigPath(tempDir);
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
repoRoot: tempDir,
|
||||
config: { worktree: { setup: "from editor" } },
|
||||
expectedRevision,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: { code: "stale_project_config", currentRevision },
|
||||
});
|
||||
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
|
||||
JSON.stringify({ worktree: { setup: "new" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips unknown top-level, worktree, and script-entry fields", () => {
|
||||
const config = {
|
||||
extraTop: { keep: true },
|
||||
worktree: {
|
||||
setup: ["npm install"],
|
||||
customWorktreeField: "preserve me",
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
command: "npm run dev",
|
||||
type: "service",
|
||||
customScriptField: 123,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
repoRoot: tempDir,
|
||||
config,
|
||||
expectedRevision: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config,
|
||||
revision: statPaseoConfigPath(tempDir),
|
||||
});
|
||||
expect(readPaseoConfigForEdit(tempDir)).toEqual({
|
||||
ok: true,
|
||||
config,
|
||||
revision: statPaseoConfigPath(tempDir),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns write_failed for filesystem write exceptions", () => {
|
||||
const fileRoot = join(tempDir, "not-a-directory");
|
||||
writeFileSync(fileRoot, "file");
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
repoRoot: fileRoot,
|
||||
config: { worktree: { setup: "npm install" } },
|
||||
expectedRevision: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: { code: "write_failed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates paseo.json when the file is still missing and expected revision is null", () => {
|
||||
mkdirSync(join(tempDir, "nested"));
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
repoRoot: join(tempDir, "nested"),
|
||||
config: { scripts: { dev: { command: "npm run dev" } } },
|
||||
expectedRevision: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: { scripts: { dev: { command: "npm run dev" } } },
|
||||
revision: statPaseoConfigPath(join(tempDir, "nested")),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
PaseoConfigRawSchema,
|
||||
type PaseoConfigRaw,
|
||||
type PaseoConfigRevision,
|
||||
type ProjectConfigRpcError,
|
||||
} from "./paseo-config-schema.js";
|
||||
export {
|
||||
PaseoConfigRevisionSchema,
|
||||
ProjectConfigRpcErrorSchema,
|
||||
type PaseoConfigRevision,
|
||||
type ProjectConfigRpcError,
|
||||
} from "./paseo-config-schema.js";
|
||||
|
||||
export const PASEO_CONFIG_FILE_NAME = "paseo.json";
|
||||
|
||||
export type ReadPaseoConfigForEditResult =
|
||||
| { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null }
|
||||
| { ok: false; error: ProjectConfigRpcError };
|
||||
|
||||
export type WritePaseoConfigForEditResult =
|
||||
| { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision }
|
||||
| { ok: false; error: ProjectConfigRpcError };
|
||||
|
||||
export interface WritePaseoConfigForEditInput {
|
||||
repoRoot: string;
|
||||
config: PaseoConfigRaw;
|
||||
expectedRevision: PaseoConfigRevision | null;
|
||||
}
|
||||
|
||||
export function resolvePaseoConfigPath(repoRoot: string): string {
|
||||
return join(repoRoot, PASEO_CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
export function statPaseoConfigPath(repoRoot: string): PaseoConfigRevision | null {
|
||||
const configPath = resolvePaseoConfigPath(repoRoot);
|
||||
if (!existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
const stats = statSync(configPath);
|
||||
return {
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
};
|
||||
}
|
||||
|
||||
export function readPaseoConfigJson(repoRoot: string): unknown | null {
|
||||
const configPath = resolvePaseoConfigPath(repoRoot);
|
||||
if (!existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(readFileSync(configPath, "utf8"));
|
||||
}
|
||||
|
||||
export function readPaseoConfigForEdit(repoRoot: string): ReadPaseoConfigForEditResult {
|
||||
try {
|
||||
const json = readPaseoConfigJson(repoRoot);
|
||||
if (json === null) {
|
||||
return { ok: true, config: null, revision: null };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
config: PaseoConfigRawSchema.parse(json),
|
||||
revision: statPaseoConfigPath(repoRoot),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: "invalid_project_config" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function writePaseoConfigForEdit(
|
||||
input: WritePaseoConfigForEditInput,
|
||||
): WritePaseoConfigForEditResult {
|
||||
const parsed = PaseoConfigRawSchema.safeParse(input.config);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: { code: "invalid_project_config" } };
|
||||
}
|
||||
|
||||
const configPath = resolvePaseoConfigPath(input.repoRoot);
|
||||
const tempPath = join(
|
||||
input.repoRoot,
|
||||
`.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`,
|
||||
);
|
||||
|
||||
try {
|
||||
writeFileSync(tempPath, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
||||
const currentRevision = statPaseoConfigPath(input.repoRoot);
|
||||
if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) {
|
||||
removeTempPaseoConfig(tempPath);
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: "stale_project_config", currentRevision },
|
||||
};
|
||||
}
|
||||
|
||||
renameSync(tempPath, configPath);
|
||||
const revision = statPaseoConfigPath(input.repoRoot);
|
||||
if (!revision) {
|
||||
return { ok: false, error: { code: "write_failed" } };
|
||||
}
|
||||
return { ok: true, config: parsed.data, revision };
|
||||
} catch {
|
||||
removeTempPaseoConfig(tempPath);
|
||||
return { ok: false, error: { code: "write_failed" } };
|
||||
}
|
||||
}
|
||||
|
||||
function paseoConfigRevisionsEqual(
|
||||
left: PaseoConfigRevision | null,
|
||||
right: PaseoConfigRevision | null,
|
||||
): boolean {
|
||||
if (left === null || right === null) {
|
||||
return left === right;
|
||||
}
|
||||
return left.mtimeMs === right.mtimeMs && left.size === right.size;
|
||||
}
|
||||
|
||||
function removeTempPaseoConfig(tempPath: string): void {
|
||||
try {
|
||||
rmSync(tempPath, { force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup only; callers need the original write outcome.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export function normalizeLifecycleCommands(commands: unknown): string[] {
|
||||
if (typeof commands === "string") {
|
||||
return commands.trim().length > 0 ? [commands] : [];
|
||||
}
|
||||
if (!Array.isArray(commands)) {
|
||||
return [];
|
||||
}
|
||||
return commands.filter((command): command is string => {
|
||||
return typeof command === "string" && command.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export const PaseoLifecycleCommandRawSchema = z.union([z.string(), z.array(z.string())]);
|
||||
|
||||
export const PaseoScriptEntryRawSchema = z
|
||||
.object({
|
||||
type: z.unknown().optional(),
|
||||
command: z.unknown().optional(),
|
||||
port: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoWorktreeConfigRawSchema = z
|
||||
.object({
|
||||
setup: PaseoLifecycleCommandRawSchema.optional(),
|
||||
teardown: PaseoLifecycleCommandRawSchema.optional(),
|
||||
terminals: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoConfigRawSchema = z
|
||||
.object({
|
||||
worktree: PaseoWorktreeConfigRawSchema.optional(),
|
||||
scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const WorktreeConfigSchema = PaseoWorktreeConfigRawSchema.extend({
|
||||
setup: z.unknown().transform(normalizeLifecycleCommands),
|
||||
teardown: z.unknown().transform(normalizeLifecycleCommands),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({ setup: [], teardown: [] });
|
||||
|
||||
export const ScriptEntrySchema = PaseoScriptEntryRawSchema.catch({});
|
||||
|
||||
export const PaseoConfigSchema = PaseoConfigRawSchema.extend({
|
||||
worktree: WorktreeConfigSchema.optional(),
|
||||
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({});
|
||||
|
||||
export const PaseoConfigRevisionSchema = z.object({
|
||||
mtimeMs: z.number(),
|
||||
size: z.number(),
|
||||
});
|
||||
|
||||
export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
|
||||
z.object({ code: z.literal("project_not_found") }),
|
||||
z.object({ code: z.literal("invalid_project_config") }),
|
||||
z.object({
|
||||
code: z.literal("stale_project_config"),
|
||||
currentRevision: PaseoConfigRevisionSchema.nullable(),
|
||||
}),
|
||||
z.object({ code: z.literal("write_failed") }),
|
||||
]);
|
||||
|
||||
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
|
||||
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
|
||||
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
|
||||
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
|
||||
export type ProjectConfigRpcError = z.infer<typeof ProjectConfigRpcErrorSchema>;
|
||||
@@ -1,14 +1,24 @@
|
||||
import { execFile, spawn } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
|
||||
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
|
||||
import { rm, stat } from "fs/promises";
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { z } from "zod";
|
||||
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
|
||||
import { readPaseoConfigJson } from "./paseo-config-file.js";
|
||||
export {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoLifecycleCommandRawSchema,
|
||||
PaseoScriptEntryRawSchema,
|
||||
PaseoWorktreeConfigRawSchema,
|
||||
PaseoConfigSchema,
|
||||
type PaseoConfig,
|
||||
type PaseoConfigRaw,
|
||||
} from "./paseo-config-schema.js";
|
||||
import { PaseoConfigSchema, type PaseoConfig } from "./paseo-config-schema.js";
|
||||
import {
|
||||
normalizeBaseRefName,
|
||||
readPaseoWorktreeMetadata,
|
||||
@@ -84,49 +94,6 @@ export interface WorktreeTerminalConfig {
|
||||
command: string;
|
||||
}
|
||||
|
||||
function normalizeLifecycleCommands(commands: unknown): string[] {
|
||||
if (typeof commands === "string") {
|
||||
return commands.trim().length > 0 ? [commands] : [];
|
||||
}
|
||||
if (!Array.isArray(commands)) {
|
||||
return [];
|
||||
}
|
||||
return commands.filter((command): command is string => {
|
||||
return typeof command === "string" && command.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
const LifecycleCommandsSchema = z.unknown().transform(normalizeLifecycleCommands);
|
||||
|
||||
const WorktreeConfigSchema = z
|
||||
.object({
|
||||
setup: LifecycleCommandsSchema,
|
||||
teardown: LifecycleCommandsSchema,
|
||||
terminals: z.unknown().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({ setup: [], teardown: [] });
|
||||
|
||||
const ScriptEntrySchema = z
|
||||
.object({
|
||||
type: z.unknown(),
|
||||
command: z.unknown(),
|
||||
port: z.unknown(),
|
||||
})
|
||||
.partial()
|
||||
.passthrough()
|
||||
.catch({});
|
||||
|
||||
const PaseoConfigSchema = z
|
||||
.object({
|
||||
worktree: WorktreeConfigSchema.optional(),
|
||||
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({});
|
||||
|
||||
type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
|
||||
|
||||
export interface PlainScriptConfig {
|
||||
type?: undefined;
|
||||
command: string;
|
||||
@@ -229,13 +196,13 @@ export class UnknownBranchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function readPaseoConfig(repoRoot: string): PaseoConfig | null {
|
||||
const paseoConfigPath = join(repoRoot, "paseo.json");
|
||||
if (!existsSync(paseoConfigPath)) {
|
||||
return null;
|
||||
}
|
||||
export function readPaseoConfig(repoRoot: string): PaseoConfig | null {
|
||||
try {
|
||||
return PaseoConfigSchema.parse(JSON.parse(readFileSync(paseoConfigPath, "utf8")));
|
||||
const json = readPaseoConfigJson(repoRoot);
|
||||
if (json === null) {
|
||||
return null;
|
||||
}
|
||||
return PaseoConfigSchema.parse(json);
|
||||
} catch {
|
||||
throw new Error(`Failed to parse paseo.json`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user